iOS底层原理20:类扩展与关联对象底层原理探索

在前面的文章中,我们分析了类和分类的本质和加载过程,本文主要来分析类扩展关联对象

【面试题】类扩展与分类的区别

1、category类别(分类)

  • 专门用来给类添加新的方法
  • 不能给类添加成员属性,添加了成员属性,也无法取到
  • 注意】:其实可以通过runtime 给分类添加属性,即属性关联,重写setter、getter方法
  • 分类中用@property定义的变量,只会生成变量的setter、getter方法的声明不能生成方法实现和带下划线的成员变量

2、extension(类扩展)

  • 可以说成是特殊的分类,也可称作匿名分类
  • 可以给类添加成员属性,但是是私有变量
  • 可以给类添加方法,也是私有方法

类扩展底层原理探索

类的扩展有两种创建方式:

  • 直接写在.m文件中,我们平时开发中最常用的写法
image
  • 通过 command+N 新建 -> 选择 Objective-C File -> 选择Extension
image

image

类扩展的本质

通过clang底层编译探索

  • main.m中定义HTPerson类及其扩展
image
  • 通过clang -rewrite-objc main.m -o main.cpp命令生成.cpp文件,打开main.cpp文件,搜索ext_name属性
image
  • 查看成员变量列表_ivar_list_t,发现有两个成员变量_name_ext_name
image
  • 查看对象方法列表
image
  • 查看类方法列表
image

从上面我们可以发现,类扩展中的方法,在编译过程中,方法就直接添加到类的 methodlist中,作为类的一部分,即编译时期直接添加到本类里面

通过源码调试探索

  • methodizeClass方法处设置断点,函数调用栈如下:
image
  • 查看ro数据
image

总结

  • 类的扩展在编译时会作为类的一部分,和类一起编译进来
  • 类的扩展只是声明依赖于当前的主类,没有.m文件,可以理解为一个·h文件

关联对象的底层原理

关联对象的底层原理的实现,主要分为两部分:

  • 通过objc_setAssociatedObject设值流程
  • 通过objc_getAssociatedObject取值流程
  • 通过objc_removeAssociatedObjects移除关联对象
image

分类中用@property定义的变量,只会生成变量的setter、getter方法的声明不能生成方法实现和带下划线的成员变量

关联对象-设值流程

  • 创建HTPerson (HT)分类,添加cat_namecat_age属性
image
  • 重写cat_namesetget方法,通过runtime的属性关联方法实现
image
  • 运行程序,断点断在main.mcat_name赋值处
image
  • 继续往下运行,断在分类的setCat_name方法中
image

其中objc_setAssociatedObject方法有四个参数,分别表示:

  • 参数1:要关联的对象,即给谁添加关联属性

  • 参数2:标识符,方便下次查找

  • 参数3:value

  • 参数4:属性的策略,即nonatomic、atomic、assign、copy等,如下所示

    image

  • 进入objc_setAssociatedObject源码实现

image

这种设计模式属于是接口模式,对外的接口不变,内部的逻辑变化不影响外部的调用, 类似于set方法的底层源码实现

  • 进入_object_set_associative_reference方法,源码如下
void
_object_set_associative_reference(id object, const void *key, id value, uintptr_t policy)
{
    // This code used to work when nil was passed for object and key. Some code
    // probably relies on that to not crash. Check and handle it explicitly.
    // rdar://problem/44094390
    if (!object && !value) return;

    if (object->getIsa()->forbidsAssociatedObjects())
        _objc_fatal("objc_setAssociatedObject called on instance (%p) of class %s which does not allow associated objects", object, object_getClassName(object));
    // 将 object 封装成 DisguisedPtr 目的是方便底层统一处理
    DisguisedPtr<objc_object> disguised{(objc_object *)object};
    // 将 policy和value 封装成ObjcAssociation,目的是方便底层统一处理
    ObjcAssociation association{policy, value};

    // retain the new value (if any) outside the lock.
    // 根据policy策略去判断是进去 retain 还是 copy 操作
    association.acquireValue();

    bool isFirstAssociation = false;//用来判断是否是,第一次关联该对象
    {
        // 实例化 AssociationsManager 注意这里不是单例
        AssociationsManager manager;
        // 实例化 全局的关联表 AssociationsHashMap 这里是单例
        AssociationsHashMap &associations(manager.get());

        if (value) {
            // AssociationsHashMap:关联表 ObjectAssociationMap:对象关联表
            // 首先根据对象封装的disguised去关联表中查找有没有对象关联表
            // 如果有直接返回结果,如果没有则根据`disguised`去创建对象关联表
            // 创建ObjectAssociationMap时当(对象的个数+1大于等于3/4,进行两倍扩容)
            auto refs_result = associations.try_emplace(disguised, ObjectAssociationMap{});
            if (refs_result.second) {
                /* it's the first association we make */
                // 表示第一次关联该对象
                isFirstAssociation = true;
            }

            /* establish or replace the association */
            // 获取ObjectAssociationMap中存储值的地址
            auto &refs = refs_result.first->second;
            // 将需要存储的值存放在关联表中存储值的地址中
            // 同时会根据key去查找,如果查找到`result.second` = false ,如果找不到就创建`result.second` = true
            // 创建association时,当(association的个数+1)超过3/4,就会进行两倍扩容
            auto result = refs.try_emplace(key, std::move(association));
            if (!result.second) {
                // 交换association和查询到的`association`
                // 其实可以理解为更新查询到的`association`数据,新值替换旧值
                association.swap(result.first->second);
            }
        } else { // value没有值走else流程
            // 查找disguised 对应的ObjectAssociationMap
            auto refs_it = associations.find(disguised);
            // 如果找到对应的 ObjectAssociationMap 对象关联表
            if (refs_it != associations.end()) {
                // 获取 refs_it->second 里面存放了association类型数据
                auto &refs = refs_it->second;
                // 根据key查询对应的association
                auto it = refs.find(key);
                if (it != refs.end()) {
                    // 如果找到,更新旧的association里面的值
                    association.swap(it->second);
                    // value= nil时释放关联对象表中存的`association`
                    refs.erase(it);
                    if (refs.size() == 0) {
                        // 如果该对象关联表中所有的关联属性数据被清空,那么该对象关联表会被释放
                        associations.erase(refs_it);

                    }
                }
            }
        }
    }

    // Call setHasAssociatedObjects outside the lock, since this
    // will call the object's _noteAssociatedObjects method if it
    // has one, and this may trigger +initialize which might do
    // arbitrary stuff, including setting more associated objects.
    // 首次关联对象调用setHasAssociatedObjects方法
    // 通过setHasAssociatedObjects方法`标记对象存在关联对象`设置`isa指针`的`has_assoc`属性为`true`
    if (isFirstAssociation)
        object->setHasAssociatedObjects();

    // release the old value (outside of the lock).
    // 释放旧值因为如果有旧值会被交换到`association`中
    // 原来`association`的新值会存放到对象关联表中
    association.releaseHeldValue();
}

_object_set_associative_reference方法主要有下列两步操作:

  • 根据object全局关联表(AssociationsHashMap)中查询ObjectAssociationMap,如果没有就去开辟内存创建ObjectAssociationMap,创建的规则就是在3/4时,进行两倍扩容,扩容的规则和cache方法存储的规则是一样的
  • 将根据key查询到相关的association(即关联的数据 valuepolicy),如果查询到直接更新里面的数据,如果没有则去获取空的asociation类型然后将值存放进去,扩容的规则和cache方法存储的规则是一样的

AssociationsManager

AssociationsManager manager并不是单例,AssociationsHashMap &associations(manager.get());获取的关联表是全局唯一的

  • AssociationsManager的源码如下:
class AssociationsManager {
    using Storage = ExplicitInitDenseMap<DisguisedPtr<objc_object>, ObjectAssociationMap>;
    static Storage _mapStorage;

public:
    // 构造函数(在作用域内加锁)
    AssociationsManager()   { AssociationsManagerLock.lock(); }
    // 析构函数(离开作用域,解锁)
    ~AssociationsManager()  { AssociationsManagerLock.unlock(); }
    // 获取全局的一张AssociationsHashMap表
    AssociationsHashMap &get() {
        return _mapStorage.get();
    }

    static void init() {
        _mapStorage.init();
    }
};

从源码我们可以发现:static Storage _mapStorage;_mapStorage是全局静态变量,因此获取的AssociationsHashMap关联表也是全局唯一的一份

AssociationsManager的构造函数AssociationsManager()和析构函数~AssociationsManager()主要是在相应作用域内加锁,为了防止多线程访问出现混乱

try_emplace方法探究

try_emplace方法的作用就是去表中查找Key相应的数据,不存在就创建:

  • 通过LookupBucketFor方法去表中查找Key对应的TheBucket是否有存在,如果存在对TheBucket进行包装然后返回
  • 如果不存在,通过InsertIntoBucket方法插入新值,扩容的规则和cache方法存储的规则是一样的
template <typename... Ts>
std::pair<iterator, bool> try_emplace(const KeyT &Key, Ts &&... Args) {
    BucketT *TheBucket;
    // 根据key去查找对应的TheBucket
    if (LookupBucketFor(Key, TheBucket))
      // 通过make_pair生成相应的键值对
      return std::make_pair(
               makeIterator(TheBucket, getBucketsEnd(), true),
               false); // Already in map.表示【表中】已经存在bucket
    
    // Otherwise, insert the new element.
    // 如果没有查询到 将数据(键值)插入TheBucket中
    TheBucket = InsertIntoBucket(TheBucket, Key, std::forward<Ts>(Args)...);
    // 通过make_pair生成相应的键值对
    return std::make_pair(
             makeIterator(TheBucket, getBucketsEnd(), true),
             true); // true表示第一次往哈希关联表中添加bucket
}
LookupBucketFor方法

这个方法就是 根据Key去表中查找Bucket,如果已经缓存过,返回true,否则返回false

////// LookupBucketFor - Lookup the appropriate bucket for Val, returning it in
/// FoundBucket.  If the bucket contains the key and a value, this returns
/// true, otherwise it returns a bucket with an empty marker or tombstone and
/// returns false.
template<typename LookupKeyT>
bool LookupBucketFor(const LookupKeyT &Val,
                     const BucketT *&FoundBucket) const {
  // 获取buckets的首地址
  const BucketT *BucketsPtr = getBuckets();
  // 获取可存储的buckets的总数
  const unsigned NumBuckets = getNumBuckets();

  if (NumBuckets == 0) {
    // 如果NumBuckets = 0 返回 false
    FoundBucket = nullptr;
    return false;
  }

  // FoundTombstone - Keep track of whether we find a tombstone while probing.
  const BucketT *FoundTombstone = nullptr;
  const KeyT EmptyKey = getEmptyKey();
  const KeyT TombstoneKey = getTombstoneKey();
  assert(!KeyInfoT::isEqual(Val, EmptyKey) &&
         !KeyInfoT::isEqual(Val, TombstoneKey) &&
         "Empty/Tombstone value shouldn't be inserted into map!");
  // 计算hash下标
  unsigned BucketNo = getHashValue(Val) & (NumBuckets-1);
  unsigned ProbeAmt = 1;
  while (true) {
    // 内存平移:找到hash下标对应的Bucket
    const BucketT *ThisBucket = BucketsPtr + BucketNo;
    // Found Val's bucket?  If so, return it.
    if (LLVM_LIKELY(KeyInfoT::isEqual(Val, ThisBucket->getFirst()))) {
      // 如果查询到`Bucket`的`key`和`Val`相等 返回当前的Bucket说明查询到了
      FoundBucket = ThisBucket;
      return true;
    }

    // If we found an empty bucket, the key doesn't exist in the set.
    // Insert it and return the default value.
    // 如果bucket为空,说明当前key还不在表中,返回false,后续进行插入操作
    if (LLVM_LIKELY(KeyInfoT::isEqual(ThisBucket->getFirst(), EmptyKey))) {
      // If we've already seen a tombstone while probing, fill it in instead
      // of the empty bucket we eventually probed to.
      FoundBucket = FoundTombstone ? FoundTombstone : ThisBucket;
      return false;
    }

    // If this is a tombstone, remember it.  If Val ends up not in the map, we
    // prefer to return it than something that would require more probing.
    // Ditto for zero values.
    if (KeyInfoT::isEqual(ThisBucket->getFirst(), TombstoneKey) &&
        !FoundTombstone)
      FoundTombstone = ThisBucket;  // Remember the first tombstone found.
    if (ValueInfoT::isPurgeable(ThisBucket->getSecond())  &&  !FoundTombstone)
      FoundTombstone = ThisBucket;

    // Otherwise, it's a hash collision or a tombstone, continue quadratic
    // probing.
    if (ProbeAmt > NumBuckets) {
      FatalCorruptHashTables(BucketsPtr, NumBuckets);
    }
    // 重新计算hash下标
    BucketNo += ProbeAmt++;
    BucketNo &= (NumBuckets-1);
  }
}
InsertIntoBucket方法

通过断点调试,我们看看InsertIntoBucket方法做了什么

  • InsertIntoBucket方法调用出设置断点,打印TheBucket的值为nil
image
  • 执行完InsertIntoBucket方法后,继续打印TheBucket的值
image

👇我们来看看InsertIntoBucket方法源码

template <typename KeyArg, typename... ValueArgs>
BucketT *InsertIntoBucket(BucketT *TheBucket, KeyArg &&Key,
                          ValueArgs &&... Values) {
  // 根据Key 找到TheBucket的内存地址
  TheBucket = InsertIntoBucketImpl(Key, Key, TheBucket);
  // 将 Key 和 Values保存到TheBucket中
  TheBucket->getFirst() = std::forward<KeyArg>(Key);
  ::new (&TheBucket->getSecond()) ValueT(std::forward<ValueArgs>(Values)...);
  return TheBucket;
}
InsertIntoBucketImpl方法分析

主要的工作都是在InsertIntoBucketImpl方法中完成的

  • 计算实际占用buckets的个数,如果超过负载因子(3/4),进行扩容操作this->grow(NumBuckets * 2);
  • 找到TheBucket的内存地址:LookupBucketFor(Lookup, TheBucket);
  • 更新占用的容量个数:incrementNumEntries();
template <typename LookupKeyT>
BucketT *InsertIntoBucketImpl(const KeyT &Key, const LookupKeyT &Lookup,
                              BucketT *TheBucket) {
  // If the load of the hash table is more than 3/4, or if fewer than 1/8 of
  // the buckets are empty (meaning that many are filled with tombstones),
  // grow the table.
  //
  // The later case is tricky.  For example, if we had one empty bucket with
  // tons of tombstones, failing lookups (e.g. for insertion) would have to
  // probe almost the entire table until it found the empty bucket.  If the
  // table completely filled with tombstones, no lookup would ever succeed,
  // causing infinite loops in lookup.
  // 计算实际占用buckets的个数,如果超过负载因子(3/4),进行扩容操作
  unsigned NewNumEntries = getNumEntries() + 1;
  // 获取buckets的总容量
  unsigned NumBuckets = getNumBuckets();
  if (LLVM_UNLIKELY(NewNumEntries * 4 >= NumBuckets * 3)) {
    // 如果哈希表的负载大于等于3/4,进行二倍扩容
    this->grow(NumBuckets * 2); // 首次分配 4 的容量
    LookupBucketFor(Lookup, TheBucket);
    NumBuckets = getNumBuckets();
  } else if (LLVM_UNLIKELY(NumBuckets-(NewNumEntries+getNumTombstones()) <=
                           NumBuckets/8)) {
    this->grow(NumBuckets);
    LookupBucketFor(Lookup, TheBucket);
  }
  ASSERT(TheBucket);

  // Only update the state after we've grown our bucket space appropriately
  // so that when growing buckets we have self-consistent entry count.
  // If we are writing over a tombstone or zero value, remember this.
  if (KeyInfoT::isEqual(TheBucket->getFirst(), getEmptyKey())) {
    // Replacing an empty bucket.
    // 更新占用的容量个数
    incrementNumEntries();
  } else if (KeyInfoT::isEqual(TheBucket->getFirst(), getTombstoneKey())) {
    // Replacing a tombstone.
    incrementNumEntries();
    decrementNumTombstones();
  } else {
    // we should be purging a zero. No accounting changes.
    ASSERT(ValueInfoT::isPurgeable(TheBucket->getSecond()));
    TheBucket->getSecond().~ValueT();
  }

  return TheBucket;
}
try_emplace方法调用两次

我们发现 try_emplace方法调用了两次,这两次有什么区别呢,通过断点来看看

image

  • 第一次try_emplace方法调用前,通过p associations来查看全局的关联表,发现是空的
image
  • 第一次try_emplace方法调用后
image
  • 第二次try_emplace方法调用前,refs是对象关联表,此时关联属性还未存储,所以refs的值也是空的
image
  • 第二次try_emplace方法调用后
image
  • 查看关联属性返回的结果result
image

isFirstAssociation首次关联对象

首次关联对象,需要更新对象isa的标志位has_assoc,表示是否有关联对象

image

// Call setHasAssociatedObjects outside the lock, since this
// will call the object's _noteAssociatedObjects method if it
// has one, and this may trigger +initialize which might do
// arbitrary stuff, including setting more associated objects.
// 首次关联对象调用setHasAssociatedObjects方法
// 通过setHasAssociatedObjects方法`标记对象存在关联对象`设置`isa指针`的`has_assoc`属性为`true`
if (isFirstAssociation)
    object->setHasAssociatedObjects();
  • 查看setHasAssociatedObjects方法
image

通过setHasAssociatedObjects方法设置对象存在关联对象,即isa指针has_assoc位域设置为true 最后通过releaseHeldValue方法释放旧值

关联对象的数据结构

image

关联对象-取值流程

id
objc_getAssociatedObject(id object, const void *key)
{
    return _object_get_associative_reference(object, key);
}

objc_getAssociatedObject调用了_object_get_associative_reference。进入_object_get_associative_reference方法,关联对象取值就是比较简单的了就是查表,源码如下:

id
_object_get_associative_reference(id object, const void *key)
{
    // 创建空的关联对象
    ObjcAssociation association{};

    {
        // 实例化 AssociationsManager 注意这里不是单例
        AssociationsManager manager;
        // 实例化 全局的关联表 AssociationsHashMap 这里是单例
        AssociationsHashMap &associations(manager.get());
        // iterator是个迭代器,实际上相当于找到object和对应的ObjectAssociationMap(对象关联表)
        AssociationsHashMap::iterator i = associations.find((objc_object *)object);
        if (i != associations.end()) {
            // 获取ObjectAssociationMap(对象关联表)
            ObjectAssociationMap &refs = i->second;
            // 迭代获取key对应的数据
            ObjectAssociationMap::iterator j = refs.find(key);
            if (j != refs.end()) {
                // 获取 association
                association = j->second;
                // retain 新值
                association.retainReturnedValue();
            }
        }
    }
    // release旧值,返回新值
    return association.autoreleaseReturnedValue();
}

关联对象-移除流程

关联对象的移除流程分类两种情况:

  • 手动调用objc_removeAssociatedObjects方法进行移除
  • 对象销毁时,系统会自动移除关联对象

objc_removeAssociatedObjects方法

void objc_removeAssociatedObjects(id object) 
{
    if (object && object->hasAssociatedObjects()) {
        _object_remove_assocations(object, /*deallocating*/false);
    }
}
image
  • _object_remove_assocations源码如下
// Unlike setting/getting an associated reference,
// this function is performance sensitive because of
// raw isa objects (such as OS Objects) that can't track
// whether they have associated objects.
void
_object_remove_assocations(id object, bool deallocating)
{
    ObjectAssociationMap refs{};

    {
        AssociationsManager manager;
        AssociationsHashMap &associations(manager.get());
        AssociationsHashMap::iterator i = associations.find((objc_object *)object);
        if (i != associations.end()) {
            refs.swap(i->second);

            // If we are not deallocating, then SYSTEM_OBJECT associations are preserved.
            bool didReInsert = false;
            if (!deallocating) {
                for (auto &ref: refs) {
                    if (ref.second.policy() & OBJC_ASSOCIATION_SYSTEM_OBJECT) {
                        i->second.insert(ref);
                        didReInsert = true;
                    }
                }
            }
            if (!didReInsert)
                associations.erase(i);
        }
    }

    // Associations to be released after the normal ones.
    SmallVector<ObjcAssociation *, 4> laterRefs;

    // release everything (outside of the lock).
    for (auto &i: refs) {
        if (i.second.policy() & OBJC_ASSOCIATION_SYSTEM_OBJECT) {
            // If we are not deallocating, then RELEASE_LATER associations don't get released.
            if (deallocating)
                laterRefs.append(&i.second);
        } else {
            i.second.releaseHeldValue();
        }
    }
    for (auto *later: laterRefs) {
        later->releaseHeldValue();
    }
}

对象销毁dealloc时,销毁相关的关联对象

调用流程:dealloc --> _objc_rootDealloc --> rootDealloc --> object_dispose --> objc_destructInstance --> _object_remove_assocations

image

image

image

image

image

总结

总的来说,关联对象主要就是两层哈希map的处理,即存取时都是两层处理,类似于二维数组

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

推荐阅读更多精彩内容