面试驱动技术 - Category 相关考点

面试驱动技术合集(初中级iOS开发),关注仓库,及时获取更新 Interview-series

image


I. Category

Category相关面试题

  • Category实现原理?
  • 实际开发中,你用Category做了哪些事?
  • Category能否添加成员变量,如果可以,如何添加?
  • load 、initialize方法的区别是什么,他们在category中的调用顺序?以及出现继承时他们之间的调用过程?
  • Category 和 Class Extension的区别是什么?
  • 为什么分类会“覆盖”宿主类的方法?

1.Category的特点

  • 运行时决议
    • 通过 runtime 动态将分类的方法合并到类对象、元类对象中
    • 实例方法合并到类对象中,类方法合并到元类对象中
  • 可以为系统类添加分类

2.分类中可以添加哪些内容

  • 实例方法
  • 类方法
  • 协议
  • 属性

分类中原理解析

使用 xcrun -sdk iphoneos clang -arch arm64 -rewrite-objc MNPerson+Test.m 函数,生产一个cpp文件,窥探其底层结构(编译状态)

struct _category_t {
    //宿主类名称 - 这里的MNPerson
    const char *name;
    
    //宿主类对象,里面有isa
    struct _class_t *cls;
    
    //实例方法列表
    const struct _method_list_t *instance_methods;
    
    //类方法列表
    const struct _method_list_t *class_methods;
    
    //协议列表
    const struct _protocol_list_t *protocols;
    
    //属性列表
    const struct _prop_list_t *properties;
};

//_class_t 结构
struct _class_t {
    struct _class_t *isa;
    struct _class_t *superclass;
    void *cache;
    void *vtable;
    struct _class_ro_t *ro;
};
  • 每个分类都是独立的
  • 每个分类的结构都一致,都是category_t

函数转换

@implementation MNPerson (Test)

- (void)test{
    NSLog(@"test - rua~");
}

@end
image
static void 
attachCategories(Class cls, category_list *cats, bool flush_caches)
{
    if (!cats) return;
    if (PrintReplacedMethods) printReplacements(cls, cats);

    bool isMeta = cls->isMetaClass();

    // fixme rearrange to remove these intermediate allocations
    
    /* 二维数组( **mlists => 两颗星星,一个)
     [
        [method_t,],
        [method_t,method_t],
        [method_t,method_t,method_t],
     ]
     
     */
    method_list_t **mlists = (method_list_t **)
        malloc(cats->count * sizeof(*mlists));
    property_list_t **proplists = (property_list_t **)
        malloc(cats->count * sizeof(*proplists));
    protocol_list_t **protolists = (protocol_list_t **)
        malloc(cats->count * sizeof(*protolists));

    // Count backwards through cats to get newest categories first
    int mcount = 0;
    int propcount = 0;
    int protocount = 0;
    int i = cats->count;//宿主类,分类的总数
    bool fromBundle = NO;
    while (i--) {//倒序遍历,最先访问最后编译的分类
        
        // 获取某一个分类
        auto& entry = cats->list[i];

        // 分类的方法列表
        method_list_t *mlist = entry.cat->methodsForMeta(isMeta);
        if (mlist) {
            //最后编译的分类,最先添加到分类数组中
            mlists[mcount++] = mlist;
            fromBundle |= entry.hi->isBundle();
        }

        property_list_t *proplist = 
            entry.cat->propertiesForMeta(isMeta, entry.hi);
        if (proplist) {
            proplists[propcount++] = proplist;
        }

        protocol_list_t *protolist = entry.cat->protocols;
        if (protolist) {
            protolists[protocount++] = protolist;
        }
    }

    auto rw = cls->data();

    prepareMethodLists(cls, mlists, mcount, NO, fromBundle);
    
    // 核心:将所有分类的对象方法,附加到类对象的方法列表中
    rw->methods.attachLists(mlists, mcount);
    free(mlists);
    if (flush_caches  &&  mcount > 0) flushCaches(cls);

    rw->properties.attachLists(proplists, propcount);
    free(proplists);

    rw->protocols.attachLists(protolists, protocount);
    free(protolists);
}
void attachLists(List* const * addedLists, uint32_t addedCount) {
    if (addedCount == 0) return;
    
    if (hasArray()) {
        // many lists -> many lists
        uint32_t oldCount = array()->count;
        uint32_t newCount = oldCount + addedCount;
        
        //realloc - 重新分配内存 - 扩容了
        setArray((array_t *)realloc(array(), array_t::byteSize(newCount)));
        array()->count = newCount;
        
        //memmove,内存挪动
        //array()->lists 原来的方法列表
        memmove(array()->lists + addedCount,
                array()->lists,
                oldCount * sizeof(array()->lists[0]));
        
        //memcpy - 将分类的方法列表 copy 到原来的方法列表中
        memcpy(array()->lists,
               addedLists,
               addedCount * sizeof(array()->lists[0]));
    }
    ...
}

画图分析就是

image
image
image
image
image

3.实际开发中,你用Category做了哪些事?

  • 声明私有方法
  • 分解体积庞大的类文件
    • Framework的私有方法公开
  • 。。。

4.Category实现原理?

  • Category编译之后,底层结构是category_t,里面存储着分类的各种信息,包括 对象方法、类方法、属性、协议信息
  • 分类的在编译后,方法并不会直接添加到类信息中,而是要在程序运行的时候,通过 runtime, 讲Category的数据,

5.为什么分类会“覆盖”宿主类的方法?

  • 其实不是真正的“覆盖”,宿主类的同名方法还是存在
  • 分类将附加到类对象的方法列表中,整合的时候,分类的方法优先放到前面
  • OC的函数调用底层走的是msg_send() 函数,它做的是方法查找,因为分类的方法优先放在前面,所以通过选择器查找到分类的方法之后直接调用,宿主类的方法看上去就像被“覆盖”而没有生效

6.Category 和 Class Extension的区别是什么?

Class Extension(扩展)

  • 声明私有属性
  • 声明私有方法
  • 声明私有成员变量
  • 编译时决议,Category 运行时决议
  • 不能为系统类添加扩展
  • 只能以声明的形式存在,多数情况下,寄生于宿主类的.m文件中



II. load 、initialize

load实现原理

  • 类第一次加载进内存的时候,会调用 + load 方法,无需导入,无需使用
  • 每个类、分类的 + load 在程序运行过程中只会执行一次
  • + load 走的不是消息发送的 objc_msgSend 调用,而是找到 + load 函数的地址,直接调用
void call_load_methods(void)
{
    static bool loading = NO;
    bool more_categories;

    loadMethodLock.assertLocked();

    // Re-entrant calls do nothing; the outermost call will finish the job.
    if (loading) return;
    loading = YES;

    void *pool = objc_autoreleasePoolPush();

    do {
        // 1. Repeatedly call class +loads until there aren’t any more
        while (loadable_classes_used > 0) {
            //先加载宿主类的load方法(按照编译顺序,调用load方法)
            call_class_loads();
        }

        // 2. Call category +loads ONCE
        more_categories = call_category_loads();

        // 3. Run more +loads if there are classes OR more untried categories
    } while (loadable_classes_used > 0  ||  more_categories);

    objc_autoreleasePoolPop(pool);

    loading = NO;
}
static void schedule_class_load(Class cls)
{
    if (!cls) return;
    assert(cls->isRealized());  // _read_images should realize

    if (cls->data()->flags & RW_LOADED) return;

    // Ensure superclass-first ordering
    // 递归调用,先将父类添加到load方法列表中,再将自己加进去
    schedule_class_load(cls->superclass);

    add_class_to_loadable_list(cls);
    cls->setInfo(RW_LOADED); 
}


调用顺序

  1. 先调用宿主类的+ load 函数
    • 按照编译先后顺序调用(先编译,先调用)
    • 调用子类的+load之前会先调用父类的+load
  2. 再调用分类的的+ load 函数
    • 按照编译先后顺序调用(先编译,先调用)

实验证明:宿主类先调用,分类再调用

2019-02-27 17:28:00.519862+0800 load-Initialize-Demo[91107:2281575] MNPerson + load
2019-02-27 17:28:00.520032+0800 load-Initialize-Demo[91107:2281575] MNPerson (Play) + load
2019-02-27 17:28:00.520047+0800 load-Initialize-Demo[91107:2281575] MNPerson (Eat) + load
image

2019-02-27 17:39:10.354050+0800 load-Initialize-Demo[91308:2303030] MNDog + load (宿主类1)
2019-02-27 17:39:10.354237+0800 load-Initialize-Demo[91308:2303030] MNPerson + load (宿主类2)
2019-02-27 17:39:10.354252+0800 load-Initialize-Demo[91308:2303030] MNDog (Rua) + load (分类1)
2019-02-27 17:39:10.354263+0800 load-Initialize-Demo[91308:2303030] MNPerson (Play) + load(分类2)
2019-02-27 17:39:10.354274+0800 load-Initialize-Demo[91308:2303030] MNPerson (Eat) + load(分类3)
2019-02-27 17:39:10.354285+0800 load-Initialize-Demo[91308:2303030] MNDog (Run) + load(分类4)


Initialize实现原理

  • 类第一次接收到消息的时候,会调用该方法,需导入,并使用
  • + Initialize 走的是消息发送的 objc_msgSend 调用

Initialize题目出现

/*父类*/
@interface MNPerson : NSObject

@end

@implementation MNPerson

+ (void)initialize{
    NSLog(@"MNPerson + initialize");
}

@end

/*子类1*/
@interface MNTeacher : MNPerson

@end

@implementation MNTeacher

@end

/*子类2*/
@interface MNStudent : MNPerson

@end

@implementation MNStudent

@end


---------------------------------------------
问题出现:以下会输出什么结果
int main(int argc, const char * argv[]) {
    @autoreleasepool {

        [MNTeacher alloc];
        [MNStudent alloc];
    }
    return 0;
}



结果如下:

2019-02-27 17:57:33.305655+0800 load-Initialize-Demo[91661:2331296] MNPerson + initialize
2019-02-27 17:57:33.305950+0800 load-Initialize-Demo[91661:2331296] MNPerson + initialize
2019-02-27 17:57:33.306476+0800 load-Initialize-Demo[91661:2331296] MNPerson + initialize

exo me? 为啥打印三次呢

image

原理分析:

  1. initialize 在类第一次接收消息的时候会调用,OC里面的 [ xxx ] 调用都可以看成 objc_msgSend,所以这时候,[MNTeacher alloc] 其实内部会调用 [MNTeacher initialize]
  2. initialize 调用的时候,要先实现自己父类的 initialize 方法,第一次调用的时候,MNPerson 没被使用过,所以未被初始化,要先调用一下父类的 [MNPerson initialize],输出第一个MNPerson + initialize
  3. MNPerson 调用了 initialize 之后,轮到MNTeacher 类自己了,由于他内部没有实现 initialize方法,所以调用父类的initialize, 输出第二个MNPerson + initialize
  4. 然后轮到[MNStudent alloc],内部也是调用 [MNStudent initialize], 然后判断得知 父类MNPerson类调用过initialize了,因此调用自身的就够了,由于他和MNTeacher 一样,也没实现initialize 方法,所以同理调用父类的[MNPerson initialize],输出第3个MNPerson + initialize

initialize 与 load 的区别

  • load 是类第一次加载的时候调用,initialize 是类第一次接收到消息的时候调用,每个类只会initialize一次(父类的initialize方法可能被调用多次)
  • load 和 initialize,加载or调用的时候,都会先调用父类对应的 load or initialize 方法,再调用自己本身的;
  • load 和 initialize 都是系统自动调用的话,都只会调用一次
  • 调用方式也不一样,load 是根据函数地址直接调用,initialize 是通过objc_msgSend
  • 调用时刻,load是runtime加载类、分类的时候调用(只会调用一次)
  • 调用顺序:
    • load:
      • 先调用类的load
        • 先编译的类,优先调用load
        • 调用子类的load之前,会先调用父类的load
      • 在调用分类的load
    • initialize:
      • 先初始化父列
      • 再初始化子类(可能最终调用的是父类的初始化方法)
/*父类*/
@interface MNPerson : NSObject

@end

@implementation MNPerson

+ (void)initialize{
    NSLog(@"MNPerson + initialize");
}

+ (void)load{
    NSLog(@"MNPerson + load");
}

/*子类1*/
@interface MNTeacher : MNPerson

@end

@implementation MNTeacher

+ (void)load{
    NSLog(@"MNTeacher + load");
}

/*子类2*/
@interface MNStudent : MNPerson

@end

@implementation MNStudent

+ (void)load{
    NSLog(@"MNStudent + load");
}


------------------------------------
问题出现:以下会输出什么结果?

int main(int argc, const char * argv[]) {
    @autoreleasepool {

        [MNTeacher load];
    }
    return 0;
}

答案出现!!!

2019-02-27 18:17:12.034392+0800 load-Initialize-Demo[92064:2370496] MNPerson + load
2019-02-27 18:17:12.034555+0800 load-Initialize-Demo[92064:2370496] MNStudent + load
2019-02-27 18:17:12.034569+0800 load-Initialize-Demo[92064:2370496] MNTeacher + load
2019-02-27 18:17:12.034627+0800 load-Initialize-Demo[92064:2370496] MNPerson + initialize
2019-02-27 18:17:12.034645+0800 load-Initialize-Demo[92064:2370496] MNPerson + initialize
2019-02-27 18:17:12.034658+0800 load-Initialize-Demo[92064:2370496] MNTeacher + load

exo me again!怎么这么多!连load 也有了?

image

解释:

  1. 前三个load不多bb了吧,程序一运行,runtime直接将全部的类加载到内存中,肯定最先输出;
  2. 第一个 MNPerson + initialize,因为是MNTeacher的调用,所以会先让父类MNPerson 调用一次initialize,输出第一个 MNPerson + initialize
  3. 第二个 MNPerson + initialize, MNTeacher 自身调用,由于他自己没有实现 initialize, 调用父类的initialize, 输出第二个 MNPerson + initialize
  4. 最后一个MNTeacher + load可能其实有点奇怪,不是说 load只会加载一次吗,而且他还不走 objc_msgSend 吗,怎么还能调用这个方法?
    • 因为!当类第一次加载进内存的时候,调用的 load 方法是系统调的,这时候不走 objc_msgSend
    • 但是,你现在是[MNTeacher load]啊,这个就是objc_msgSend(MNTeacher,@selector(MNTeacher)),这就跑到MNTeacher + load里了!
    • 只是一般没人手动调用load 函数,但是,还是可以调用的!


III. 关联对象AssociatedObject

Category能否添加成员变量,如果可以,如何添加?

这道题实际上考的就是关联对象

如果是普通类声明生命属性的话

@interface MNPerson : NSObject

@property (nonatomic, copy)NSString *property;

@end

上述代码系统内部会自动三件事:

  1. 帮我们生成一个生成变量_property
  2. 生成一个 get 方法 - (NSString *)property
  3. 生成一个 set 方法 - (void)setProperty:(NSString *)property
@implementation MNPerson{
    NSString *_property;
}

- (void)setProperty:(NSString *)property{
    _property = property;
}

- (NSString *)property{
    return _property;
}

@end


分类也是可以添加属性的 - 类结构里面,有个properties 列表,里面就是
存放属性的;

分类里面,生成属性,只会生成方法的声明,不会生成成员变量 && 方法实现!

image

人工智障翻译:实例变量不能放在分类中

所以:

不能直接给category 添加成员变量,但是可以间接实现分类有成员变量的效果(效果上感觉像成员变量)

@interface MNPerson (Test)

@property (nonatomic, assign) NSInteger age;

@end

@implementation MNPerson (Test)

@end
image

person.age = 10等价于 [person setAge:10],所以证明了,给分类声明属性之后,并没有添加其对应的实现!


关联对象

objc_setAssociatedObject Api

objc_setAssociatedObject(    <#id  _Nonnull object#>, (对象)
                             <#const void * _Nonnull key#>,(key)
                             <#id  _Nullable value#>,(关联的值)
                             <#objc_AssociationPolicy policy#>)(关联策略)

关联策略,等价于属性声明

typedef OBJC_ENUM(uintptr_t, objc_AssociationPolicy) {
    OBJC_ASSOCIATION_ASSIGN = 0,          
    OBJC_ASSOCIATION_RETAIN_NONATOMIC = 1, 
    OBJC_ASSOCIATION_COPY_NONATOMIC = 3,  
    OBJC_ASSOCIATION_RETAIN = 01401,      
    OBJC_ASSOCIATION_COPY = 01403         
};
image

比如这里的age属性,默认声明是@property (nonatomic, assign) NSInteger age;,就是 assign,所以这里选择OBJC_ASSOCIATION_ASSIGN


取值

objc_getAssociatedObject(<#id  _Nonnull object#>, <#const void * _Nonnull key#>)

面试题 - 以下代码输出的结果是啥

int main(int argc, const char * argv[]) {
    @autoreleasepool {

        MNPerson *person = [[MNPerson alloc]init];

        {
            MNPerson *test = [[MNPerson alloc]init];
            objc_setAssociatedObject(person,
                                     @"test",
                                     test,
                                     OBJC_ASSOCIATION_ASSIGN);
        }
        
        NSLog(@"%@",objc_getAssociatedObject(person, @"test"));
    }
    return 0;
}

image

原因,关联的对象是person,关联的value是 test,test变量 出了他们的{} 作用域之后,就会销毁;
此时通过key 找到 对应的对象,访问对象内部的value,因为test变量已经销毁了,所以程序崩溃了,这也说明了 => 内部 test 对 value是强引用!

关联对象的本质

在分类中,因为类的实例变量的布局已经固定,使用 @property 已经无法向固定的布局中添加新的实例变量(这样做可能会覆盖子类的实例变量),所以我们需要使用关联对象以及两个方法来模拟构成属性的三个要素。

引用自 关联对象 AssociatedObject 完全解析


关联对象的原理

实现关联对象技术的核心对象有

  • AssociationsManager
  • AssociationsHashMap
  • ObjectAssociationMap
  • ObjcAssociation
class AssociationsManager {
    static spinlock_t _lock;//自旋锁,保证线程安全
    static AssociationsHashMap *_map;
}
class AssociationsHashMap : public unordered_map<disguised_ptr_t, ObjectAssociationMap> 
class ObjectAssociationMap : public std::map<void *, ObjcAssociation>
class ObjcAssociation {
    uintptr_t _policy;
    id _value;
}

以关联对象代码为例:

  objc_setAssociatedObject(obj, @selector(key), @"hello world", OBJC_ASSOCIATION_COPY_NONATOMIC);
image
  • 关联对象并不是存储在被关联对象本身的内存中的
  • 关联对象,存储在全局的一个统一的AssociationsManager
  • 关联对象其实就是 ObjcAssociation 对象,关联的 value 就放在 ObjcAssociation
  • 关联对象由 AssociationsManager 管理并在 AssociationsHashMap 存储
  • 对象的指针以及其对应 ObjectAssociationMap 以键值对的形式存储在 AssociationsHashMap
  • ObjectAssociationMap 则是用于存储关联对象的数据结构
  • 每一个对象都有一个标记位 has_assoc 指示对象是否含有关联对象
  • 存储在全局的一个统一的AssociationsManager 内部有一持有一个_lock,他其实是一个spinlock_t(自旋锁),用来保证AssociationsHashMap操作的时候,是线程安全的


Category 相关的问题一般初中级问的比较多,一般最深的就问到关联对象,上面的问题以及解答已经把比较常见的 Category 的问题都罗列解决了一下,如果还有其他常见的 Category 的试题欢迎补充~

传言的互联网寒冬貌似真的来临了,在这种环境下,无法得知公司是否不裁员,还是让自己💪起来!19年的 铜三铁四 从明天就要开始拉开帷幕了,也希望近期找工作的iOS们能找到一份满意的工作,看下寒冬下,iOS开发是不是叕没人要了~



本文基于 MJ老师 的基础知识之上,结合了包括 draveness 在内的一系列大神的文章总结的,如果不当之处,欢迎讨论~


友情演出:小马哥MJ

参考资料:

关联对象 AssociatedObject 完全解析

associated-objects

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,126评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,254评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,445评论 0 341
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,185评论 1 278
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,178评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,970评论 1 284
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,276评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,927评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,400评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,883评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,997评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,646评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,213评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,204评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,423评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,423评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,722评论 2 345

推荐阅读更多精彩内容

  • 转至元数据结尾创建: 董潇伟,最新修改于: 十二月 23, 2016 转至元数据起始第一章:isa和Class一....
    40c0490e5268阅读 1,679评论 0 9
  • Swift1> Swift和OC的区别1.1> Swift没有地址/指针的概念1.2> 泛型1.3> 类型严谨 对...
    cosWriter阅读 11,089评论 1 32
  • Category(分类)这一Object-C 2.0之后添加的语言特性,在日常开发中使用频率非常高。而且面试时Ca...
    相瑾瑜阅读 388评论 0 0
  • 本文将对category的源码进行比较全面的整理分析,最后结合一些面试题进行总结,希望对读者有所裨益。GitHub...
    五分钟学算法阅读 605评论 0 6
  • 距离六级考试还有两天。今天什么都没干成,早上要去档案室上班。本来只有一个多小时的。结果听说下午还要上以后。改到了继...
    朴菘菘麻麻阅读 114评论 2 1