iOS-类的加载(下)

前言

在之前的文章iOS-类的加载(上),我们探究了是如何加载到内存中以及懒加载类非懒加载类,这篇文章下我们将探寻一下分类的加载情况。

分类的本质

在main文件中新建一个ZGPerson分类,

@interface ZGPerson (ZG)

@property (nonatomic, strong) NSString *cate_name;
@property (nonatomic, assign) int cate_age;

- (void)cateA_instanceMethod1;

- (void)cateA_instanceMethod3;

- (void)cateA_instanceMethod2;

- (void)cateA_classMethod3;

@end

@implementation ZGPerson (ZG)


+ (void)load{
    
}

- (void)cateA_instanceMethod1{
    NSLog(@"%s",__func__);
}

- (void)cateA_instanceMethod3{
    NSLog(@"%s",__func__);
}

- (void)cateA_instanceMethod2{
    NSLog(@"%s",__func__);
}

- (void)cateA_classMethod3{
    NSLog(@"%s",__func__);
}

@end

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        // insert code here...

        ZGPerson *person = [ZGPerson alloc];
        NSLog(@"%p",person);
    }
    return 0;
}

我们可以通过下面几种方式探寻分类本质

  • 通过clang
  • 通过Xcode文档搜索Category
  • 通过objc源码搜索 category_t
通过clang

通过终端命令clang -rewrite-objc main.m -o main.cpp 查看底层编译,即 生成main.cpp,打开。

ZGPerson分类

_category_t

可见,分类的本质是_category_t

通过Xcode文档搜索Category

可以通过Xcode文档搜索Category文档

Category

通过objc源码搜索 category_t

在源码中搜索_category_t发现以下定义

struct category_t {
    const char *name;
    classref_t cls;
    struct method_list_t *instanceMethods;
    struct method_list_t *classMethods;
    struct protocol_list_t *protocols;
    struct property_list_t *instanceProperties;
    // Fields below this point are not always present on disk.
    struct property_list_t *_classProperties;

    method_list_t *methodsForMeta(bool isMeta) {
        if (isMeta) return classMethods;
        else return instanceMethods;
    }

    property_list_t *propertiesForMeta(bool isMeta, struct header_info *hi);
    
    protocol_list_t *protocolsForMeta(bool isMeta) {
        if (isMeta) return nullptr;
        else return protocols;
    }
};

其中

  • name:分类的名字
  • cls:对应的原类
  • instanceMethods:实例方法列表
  • classMethods:类方法列表
  • protocols:协议列表
  • instanceProperties:实例属性列表

分类加载流程

首先,创建两个分类ZGPerson + ZGAZGPerson + ZGB

@interface ZGPerson (ZGA)


@property (nonatomic, strong) NSString *cate_name;
@property (nonatomic, assign) int cate_age;

- (void)cateA_instanceMethod1;

- (void)cateA_instanceMethod3;

- (void)cateA_instanceMethod2;

- (void)cateA_classMethod3;

@end

@implementation ZGPerson (ZGA)

+ (void)load{
    
}

- (void)cateA_instanceMethod1{
    NSLog(@"%s",__func__);
}

- (void)cateA_instanceMethod3{
    NSLog(@"%s",__func__);
}

- (void)cateA_instanceMethod2{
    NSLog(@"%s",__func__);
}

- (void)cateA_classMethod3{
    NSLog(@"%s",__func__);
}

@end

ZGPerson (ZGB)

@interface ZGPerson (ZGB)


@property (nonatomic, strong) NSString *cate_name;
@property (nonatomic, assign) int cate_age;

- (void)cateA_instanceMethod1;

- (void)cateA_instanceMethod3;

- (void)cateA_instanceMethod2;

- (void)cateA_classMethod3;

@end

@implementation ZGPerson (ZGB)

+ (void)load{
    
}

- (void)cateA_instanceMethod1{
    NSLog(@"%s",__func__);
}

- (void)cateA_instanceMethod3{
    NSLog(@"%s",__func__);
}

- (void)cateA_instanceMethod2{
    NSLog(@"%s",__func__);
}

- (void)cateA_classMethod3{
    NSLog(@"%s",__func__);
}

@end

在上一篇iOS-类的加载(上)文章中分析了类的加载流程:realizeClassWithoutSwift -> methodizeClass -> attachToClass -> load_categories_nolock -> extAlloc ->attachCategories中提及了rwe的加载,其中分析了分类的data数据 时如何 加载到中的,分类的加载顺序是:是编译时加载进内存的顺序决定,并且越晚加进来,越在前面

methodizeClass的源码实现中,我们发现类的数据分类的数据是分开处理的

static void methodizeClass(Class cls, Class previously)
{
    .....省略一些代码

    // Attach categories. 链接分类
    if (previously) {
        if (isMeta) {
            objc::unattachedCategories.attachToClass(cls, previously,
                                                     ATTACH_METACLASS);
        } else {
            // When a class relocates, categories with class methods
            // may be registered on the class itself rather than on
            // the metaclass. Tell attachToClass to look for those.
            objc::unattachedCategories.attachToClass(cls, previously,
                                                     ATTACH_CLASS_AND_METACLASS);
        }
    }
    objc::unattachedCategories.attachToClass(cls, cls,
                                             isMeta ? ATTACH_METACLASS : ATTACH_CLASS);

#if DEBUG
    // Debug: sanity-check all SELs; log method list contents
    for (const auto& meth : rw->methods()) {
        if (PrintConnecting) {
            _objc_inform("METHOD %c[%s %s]", isMeta ? '+' : '-', 
                         cls->nameForLogging(), sel_getName(meth.name));
        }
        ASSERT(sel_registerName(sel_getName(meth.name)) == meth.name); 
    }
#endif
}

那么为什么是这样处理的呢?原来是因为在编译阶段,类就已经被确认好了内存位置,是clean memory(即实例方法存储在中,类方法存储在元类中),而分类是后面才加进来的

分类通过attatchToClass添加到本类,下面我们查看attatchToClass源码

attatchToClass源码
void attachToClass(Class cls, Class previously, int flags)
    {
        runtimeLock.assertLocked();
        ASSERT((flags & ATTACH_CLASS) ||
               (flags & ATTACH_METACLASS) ||
               (flags & ATTACH_CLASS_AND_METACLASS));

        
        const char *mangledName  = cls->mangledName();
        const char *ZGPersonName = "ZGPerson";

        if (strcmp(mangledName, ZGPersonName) == 0) {
            bool kc_isMeta = cls->isMetaClass();
            auto kc_rw = cls->data();
            auto kc_ro = kc_rw->ro();
            printf("%s: 这个是我要研究的 %s \n",__func__, ZGPersonName);

        }
        
        
        auto &map = get();
        auto it = map.find(previously);
        if (it != map.end()) { // 主类没有实现不会走这里的方法
            category_list &list = it->second;
            if (flags & ATTACH_CLASS_AND_METACLASS) {
                int otherFlags = flags & ~ATTACH_CLASS_AND_METACLASS;
                attachCategories(cls, list.array(), list.count(), otherFlags | ATTACH_CLASS);
                attachCategories(cls->ISA(), list.array(), list.count(), otherFlags | ATTACH_METACLASS);
            } else {
                attachCategories(cls, list.array(), list.count(), flags);
            }
            map.erase(it);
        }
    }

但是当我们运行的时候却发现并未走进attachCategories 方法(分类链接方法),这是为什么呢?

attachCategories源码
// Attach method lists and properties and protocols from categories to a class.
// Assumes the categories in cats are all loaded and sorted by load order, 
// oldest categories first.
static void
attachCategories(Class cls, const locstamped_category_t *cats_list, uint32_t cats_count,
                 int flags)
{
    if (slowpath(PrintReplacedMethods)) {
        printReplacements(cls, cats_list, cats_count);
    }
    if (slowpath(PrintConnecting)) {
        _objc_inform("CLASS: attaching %d categories to%s class '%s'%s",
                     cats_count, (flags & ATTACH_EXISTING) ? " existing" : "",
                     cls->nameForLogging(), (flags & ATTACH_METACLASS) ? " (meta)" : "");
    }

    /*
     * Only a few classes have more than 64 categories during launch.
     * This uses a little stack, and avoids malloc.
     *
     * Categories must be added in the proper order, which is back
     * to front. To do that with the chunking, we iterate cats_list
     * from front to back, build up the local buffers backwards,
     * and call attachLists on the chunks. attachLists prepends the
     * lists, so the final result is in the expected order.
     */
    constexpr uint32_t ATTACH_BUFSIZ = 64;
    method_list_t   *mlists[ATTACH_BUFSIZ];
    property_list_t *proplists[ATTACH_BUFSIZ];
    protocol_list_t *protolists[ATTACH_BUFSIZ];

    uint32_t mcount = 0;
    uint32_t propcount = 0;
    uint32_t protocount = 0;
    bool fromBundle = NO;
    bool isMeta = (flags & ATTACH_METACLASS);
    auto rwe = cls->data()->extAllocIfNeeded();// rwe 初始化 --> 要对copy出的干净内存进行方法插入操作
    // 自己加的调试代码
    const char *mangledName  = cls->mangledName();
    const char *ZGPersonName = "ZGPerson";

    if (strcmp(mangledName, ZGPersonName) == 0) {
        bool kc_isMeta = cls->isMetaClass();
        auto kc_rw = cls->data();
        auto kc_ro = kc_rw->ro();
        if (!kc_isMeta) {
            printf("%s: 这个是我要研究的 %s \n",__func__,ZGPersonName);
        }
    }

    // 遍历 分类数据的准备(method property protocol)
    for (uint32_t i = 0; i < cats_count; i++) {
        auto& entry = cats_list[I];

        method_list_t *mlist = entry.cat->methodsForMeta(isMeta);
        if (mlist) {
            if (mcount == ATTACH_BUFSIZ) {
                prepareMethodLists(cls, mlists, mcount, NO, fromBundle);
                rwe->methods.attachLists(mlists, mcount);
                mcount = 0;
            }
            mlists[ATTACH_BUFSIZ - ++mcount] = mlist;
            fromBundle |= entry.hi->isBundle();
        }

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

        protocol_list_t *protolist = entry.cat->protocolsForMeta(isMeta);
        if (protolist) {
            if (protocount == ATTACH_BUFSIZ) {
                rwe->protocols.attachLists(protolists, protocount);
                protocount = 0;
            }
            protolists[ATTACH_BUFSIZ - ++protocount] = protolist;
        }
    }
    // 分类方法的 排序、附着关联
    if (mcount > 0) {
        prepareMethodLists(cls, mlists + ATTACH_BUFSIZ - mcount, mcount, NO, fromBundle);
        rwe->methods.attachLists(mlists + ATTACH_BUFSIZ - mcount, mcount);
        if (flags & ATTACH_EXISTING) flushCaches(cls);
    }

    rwe->properties.attachLists(proplists + ATTACH_BUFSIZ - propcount, propcount);

    rwe->protocols.attachLists(protolists + ATTACH_BUFSIZ - protocount, protocount);
}

可以看出,这确实是链接分类的方法,但是为什么没调用呢?这个我们稍后再说,这里我们先分析一下这个方法做了什么?其中我们主要分析一下排序方法prepareMethodLists和插入方法attachLists

prepareMethodLists
static void 
prepareMethodLists(Class cls, method_list_t **addedLists, int addedCount,
                   bool baseMethods, bool methodsFromBundle)
{
    runtimeLock.assertLocked();
    
    const char *mangledName  = cls->mangledName();
    const char *ZGPersonName = "ZGPerson";

    if (strcmp(mangledName, ZGPersonName) == 0) {
        bool kc_isMeta = cls->isMetaClass();
        auto kc_rw = cls->data();
        auto kc_ro = kc_rw->ro();
        printf("%s: 这个是我要研究的 %s \n",__func__,ZGPersonName);
    }
    

    if (addedCount == 0) return;

    // There exist RR/AWZ/Core special cases for some class's base methods.
    // But this code should never need to scan base methods for RR/AWZ/Core:
    // default RR/AWZ/Core cannot be set before setInitialized().
    // Therefore we need not handle any special cases here.
    if (baseMethods) {
        ASSERT(cls->hasCustomAWZ() && cls->hasCustomRR() && cls->hasCustomCore());
    }

    // Add method lists to array.
    // Reallocate un-fixed method lists.
    // The new methods are PREPENDED to the method list array.

    for (int i = 0; i < addedCount; i++) {
        method_list_t *mlist = addedLists[I];
        ASSERT(mlist);

        // Fixup selectors if necessary
        if (!mlist->isFixedUp()) {
            fixupMethodList(mlist, methodsFromBundle, true/*sort*/);
        }
    }

    // If the class is initialized, then scan for method implementations
    // tracked by the class's flags. If it's not initialized yet,
    // then objc_class::setInitialized() will take care of it.
    if (cls->isInitialized()) {
        objc::AWZScanner::scanAddedMethodLists(cls, addedLists, addedCount);
        objc::RRScanner::scanAddedMethodLists(cls, addedLists, addedCount);
        objc::CoreScanner::scanAddedMethodLists(cls, addedLists, addedCount);
    }
}

从其中的排序方法fixupMethodList中可以看出,排序是通过方法的内存地址排序的即SEL name

static void 
fixupMethodList(method_list_t *mlist, bool bundleCopy, bool sort)
{
    runtimeLock.assertLocked();
    ASSERT(!mlist->isFixedUp());

    // fixme lock less in attachMethodLists ?
    // dyld3 may have already uniqued, but not sorted, the list
    if (!mlist->isUniqued()) {
        mutex_locker_t lock(selLock);
    
        // Unique selectors in list.
        for (auto& meth : *mlist) {
            const char *name = sel_cname(meth.name);
            meth.name = sel_registerNameNoLock(name, bundleCopy);
        }
    }
    // sel - imp
    // Sort by selector address.//通过内存地址排序
    if (sort) {
        method_t::SortBySELAddress sorter;
        std::stable_sort(mlist->begin(), mlist->end(), sorter);
    }
    
    // Mark method list as uniqued and sorted
    mlist->setFixedUp();
}
struct method_t {
    SEL name;
    const char *types;
    MethodListIMP imp;

    struct SortBySELAddress :
        public std::binary_function<const method_t&,
                                    const method_t&, bool>
    {
        bool operator() (const method_t& lhs,
                         const method_t& rhs)
        { return lhs.name < rhs.name; }
    };
};
attachLists
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;
            setArray((array_t *)realloc(array(), array_t::byteSize(newCount)));// 扩容
            array()->count = newCount;
            memmove(array()->lists + addedCount, array()->lists,
                    oldCount * sizeof(array()->lists[0]));// 移动旧的去后面
            memcpy(array()->lists, addedLists,
                   addedCount * sizeof(array()->lists[0]));// cpy 新的在前面
        }
        else if (!list  &&  addedCount == 1) {
            // 0 lists -> 1 list
            // 0到1 - list 没有数据第一次 - 第0个元素给list,此时 list 是一维的
            list = addedLists[0];
        }
        else {
            // 1 list -> many lists
            // 1+many - 举例 many lists 是3个
            List* oldList = list;
            uint32_t oldCount = oldList ? 1 : 0;
            uint32_t newCount = oldCount + addedCount;// 容量计算,旧+新的和 1+3=4
            setArray((array_t *)malloc(array_t::byteSize(newCount)));// 开辟总大小的空间 newCount
            array()->count = newCount;// array 的数量:是新的添加 manylists 后的数量
            if (oldList) array()->lists[addedCount] = oldList;// 旧的list 放最后面 第3个位置
            // memcpy(位置, 放谁, 大小)
            // 把新的 lists 从起始位置0开始放
            memcpy(array()->lists, addedLists,
                   addedCount * sizeof(array()->lists[0]));
        }
    }

这里加了一些注释,可以看出加入的array(方法列表,属性列表,协议列表)总是会加在的前面,这也就解释了为什么分类的方法会在本类之前调用

attachLists

attachCategories分析

刚才我们一直没调用到attachCategories方法,那我们只能想一下其他的办法,在源码中搜索attachCategories,看看都会在哪里调用,发现只有两处调用

  • attachToClass()
  • load_categories_nolock()

经过探索,如果想要调用attachCategories方法,我们只需要在分类里面加一个+ load方法,变会调用attachCategories方法

attachCategories

其中的函数调用栈如下


load_categories_nolock

而在 attachToClass方法中,这里经过调试发现,基本不会进到if流程调用attachCategories,除非加载两次,一般的类一般只会加载一次

所以接下来我们只需要研究load_categories_nolock()的调用,我们全局搜索load_categories_nolock的调用的地方,发现只有两次调用

static void loadAllCategories() {
    mutex_locker_t lock(runtimeLock);

    for (auto *hi = FirstHeader; hi != NULL; hi = hi->getNext()) {
        load_categories_nolock(hi);
    }
}
if (didInitialAttachCategories) {
        for (EACH_HEADER) {
            load_categories_nolock(hi);
        }
    }

经过我们调试发现,只有loadAllCategories方法才会调用load_categories_nolock,另外一个基本不会调用。

继续全局搜索loadAllCategories,发现只在load_images中调用

void
load_images(const char *path __unused, const struct mach_header *mh)
{
    if (!didInitialAttachCategories && didCallDyldNotifyRegister) {
        didInitialAttachCategories = true;
        loadAllCategories();
    }

    // Return without taking locks if there are no +load methods here.
    if (!hasLoadMethods((const headerType *)mh)) return;

    recursive_mutex_locker_t lock(loadMethodLock);

    // Discover load methods
    {
        mutex_locker_t lock2(runtimeLock);
        prepare_load_methods((const headerType *)mh);
    }

    // Call +load methods (without runtimeLock - re-entrant)
    call_load_methods();
}

所以综上所述,该情况下的分类的数据加载时机的反推路径为:attachCategories -> load_categories_nolock -> loadAllCategories -> load_images

而我们的分类加载正常的流程的路径为:realizeClassWithoutSwift -> methodizeClass -> attachToClass ->attachCategories

分类加载

分类和类的各种加载时机

我们可以大致将类 和 分类 是否实现+load的情况分为4种

类+分类
分类实现+load 分类未实现+load
类实现+load 非懒加载类+非懒加载分类 非懒加载类+懒加载分类
类未实现+load 懒加载类+非懒加载分类 懒加载类+懒加载分类
  • 【情况1】非懒加载类 + 非懒加载分类

  • 【情况2】非懒加载类 + 懒加载分类

  • 【情况3】懒加载类 + 懒加载分类

  • 【情况4】懒加载类 + 非懒加载分类

非懒加载类 + 非懒加载分类

  • 类的数据加载是通过 _getObjc2NonlazyClassList 加载,即对 rorw 的操作和对 rwe 赋值初始化,在extAlloc 方法中
  • 分类的数据加载是通过 load_images 加载到类中的

调用路径为

  • map_images -> map_images_nolock -> _read_images -> readClass -> _getObjc2NonlazyClassList -> realizeClassWithoutSwift -> methodizeClass -> attachToClass,此时的 mlists 是一维数组,然后走到 load_images 部分。
  • load_images -> loadAllCategories -> load_categories_nolock -> load_categories_nolock -> attachCategories -> attachLists,此时的 mlists 是二维数组。
非懒加载类 + 非懒加载分类

非懒加载类 + 懒加载分类

非懒加载类 + 懒加载分类
  • 类和分类的加载在 read_images 就加载好数据了
  • 其中 data 数据在编译期就已经完成了

懒加载类 + 非懒加载分类

懒加载类 + 非懒加载分类
  • 此情况下会迫使主类提前加载,即主类强行转换为 非懒加载类样式

懒加载类 + 懒加载分类

懒加载类 + 懒加载分类
  • 此情况下懒加载类与懒加载分类的数据加载是在 消息第一次调用时加载

总结

  • 非懒加载类 + 非懒加载分类,其数据的加载在load_images 方法中,首先对类进行加载,然后把分类的信息贴到类中。
  • 非懒加载类 + 懒加载分类,其数据加载在read_image 就加载数据,数据来自datadata在编译时期就已经完成,即data中除了类的数据,还有分类的数据,与类绑定在一起。
  • 懒加载类 + 懒加载分类,其数据加载推迟到 第一次消息时,数据同样来自data,data在编译时期就已经完成。
  • 懒加载类 + 非懒加载分类,只要分类实现了load,会迫使主类提前加载,即在_read_images 中不会对类做实现操作,需要在 load_images方法中触发类的数据加载,即rwe初始化,同时加载分类数据。
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 214,951评论 6 497
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,606评论 3 389
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 160,601评论 0 350
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,478评论 1 288
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,565评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,587评论 1 293
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,590评论 3 414
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,337评论 0 270
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,785评论 1 307
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,096评论 2 330
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,273评论 1 344
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,935评论 5 339
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,578评论 3 322
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,199评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,440评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,163评论 2 366
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,133评论 2 352