iOS 全面深入理解 Category 类别,+ (void)load 与 + (void)initialize及关联对象实现原理

引言

类别 category 允许你在没有源代码情况下,仍然可以向已有的类中添加方法。它的功能很强大,允许你无需子类化而扩展现有类。使用类别,还可以将类的实现分发到多个文件中。类扩展 extension 与此类似,但允许在主类@interface 块内以外的位置为类声明额外的 API。


  • 代码示例

Category:

#import "ClassName.h"
 
@interface ClassName (CategoryName)
// method declarations
@end

Extension:

@interface MyClass : NSObject
@property (nonatomic, copy, readonly) NSString *name;
@end
 
// Private extension, typically hidden in the main implementation file.
@interface MyClass ()
@property (nonatomic, copy, readwrite) NSString *name;
@end

  • 本质

您可以通过在接口文件中,以类别名称声明它们,并在实现文件中以相同名称定义它们来将方法添加到类。类别名称表明这些方法是对在别处声明的类的添加,而不是一个新类。但是,不能通过类别添加实例变量到类中。

类别添加的方法成为类类型的一部分。例如,在一个类别中添加到 NSArray类中的方法,是编译器期望 NSArray 实例在其配置表中包含的方法。然而,子类中添加到 NSArray 类中的方法并不包含在 NSArray 类型中。(这只对静态类型的对象有影响,因为静态类型是编译器知道对象类的唯一方式。)


  • 常用介绍

类别:

Category 在经历过编译后里面的内容:对象方法、类方法、协议、属性都转化为类型为 category_t 的结构体变量:

struct category_t {
    const char *name;
    classref_t cls;
    WrappedPtr<method_list_t, PtrauthStrip> instanceMethods;
    WrappedPtr<method_list_t, PtrauthStrip> 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;
    }
};

具体 category 都能做什么,常用的大致有如下几个场景:

  1. 在不修改原有类的基础上给原有类添加方法,因为 category 的结构体指针中没有属性列表,只有方法列表。所以原则来说只能给 category 添加方法,不能添加属性,如果需要给 category 添加类似属性功能,可以通过关联对象实现,下面会有具体介绍;
  2. Category 中的方法优先于原有类同名的方法,即会优先调用 category 中的方法,忽略原有类的方法。即 category 与原有类同名方法调用的优先级为: category > 本类 > 父类。开发中尽量不要覆盖本类的方法,如果覆盖会导致本类方法失效;
  3. 如果给 category 添加属性 @property,只会生成 setter/getter 方法的声明,并不会有具体的代码实现,详细解释可参考历史文章:iOS 属性 @property 详细探究
  4. Category 中可以访问原有类中 .h 中声明的成员变量;

类的扩展 Extension:

@interface Person ()

@end

类的 extension 看起来很像一个匿名的 category。通常用来声明私有方法,私有属性和私有成员变量。

extension 在编译期决议, category 在运行期决议。

类扩展不能像类别 category 那样拥有独立的实现部分(@implementation 部分)。也就是说,类的扩展所声明的方法必须依托原类的实现代码部分来实现。

因此,我们不能给系统类添加类扩展。即扩展的方法只能在原类中实现。例如我们扩展 NSString ,那么只能在 NSString的.m 中实现,但我们拿不到 NSString.m 的源码。因此,我们不能给 NSString 添加扩展,只能给 NSString 添加 category

定义在 .m 文件中的类扩展方法为私有的,如果需要声明私有方法,这种方式特别合适。定义在 .h 文件(头文件)中的类扩展方法为公有的。


类别 Category 与扩展 Extension 的区别

  1. Category 有名字,extension 没有名字,像是一个匿名的 category;
  2. Category 是运行时决议,而 extension 是编译时决议。所以 category 中的方法没有实现不会警告,而 extension 声明的方法不实现则会出现警告;
  3. Category 原则上可以增加属性,实例方法,类方法,而且外部类是可以访问的。extension 能添加属性、方法、实例变量,且默认是私有的;
  4. Category 有自己的实现部分,extension 没有自己的实现部分,只能依赖类本身来实现;
  5. 可以为系统类添加 category,而不能为系统类添加 extension;

关于类的 + (void)load+ (void)initialize 的区别

+ (void)load

+ (void)initialize

  • 两者的区别如下:
  1. 相同点:
  • 两个函数都是系统自动调用,因此无需手动调用(如果手动调用则与普通函数调用类似);
  • 两个函数都会隐士调用各自父类对应的 + (void)load+ (void)initialize 方法,即子类调用方法之前,会优先调用其父类对应的方法;
  • 两个函数内部都使用了锁,因此两个函数都是线程安全的;
  1. 不同点:
  1. 调用时机不同:+ (void)loadmain 函数之前执行,即 objc_init Runtime初始化时调用,且只会调用一次。 + (void)initialize 在类的方法首次被调用时执行,每个类只会调用一次,但父类可能会调用多次;
  2. 调用方式不同:+ (void)load 是根据函数地址直接调用,+ (void)initialize 是通过消息发送机制即 objc_msgSend(id self, SEL _cmd, ...) 调用;
  3. 子类父类调用关系不同:
  • 如果子类没有实现 + (void)load,则不会调用其父类的 + (void)load 方法。
  • 如果子类没有实现 + (void)initialize,则会调用其父类的方法,因此父类的 + (void)initialize 可能会调用多次;
  1. 类别 category 对调用的影响不同:
  • 如果 category 中实现了 + (void)load,则会优先调用原类的的 + (void)load,再调用 category 的,即优先级为:父类 > 原类 > category
  1. 没有继承关系的不同类中的 + (void)load 的调用顺序跟 Compile Sources 顺序有关,即在前面的优先编译的类或者 category 先调用( 备注: 所有类的 + (void)load 优先级大于 category 的优先级);
  2. 同一个类的 category+ (void)load 的调用顺序跟 Compile Sources 顺序有关,即在前面的优先编译的 category 会先调用;
  3. 同一镜像中主工程的 + (void)load 方法优先调用,然后再调用静态库的 + (void)load 方法。有多个静态库时,静态库之间的执行顺序与编译顺序有关,即它们在 Link Binary With Libraries 中的顺序;
  4. 不同镜像中,动态库的 + (void)load 方法优先调用,然后再调用主工程的 + (void)load,多个动态库的 + (void)load 方法的调用顺序跟编译顺序有关,即它们在 Link Binary With Libraries 中的顺序;
  • 如果 category 中实现了 + (void)initialize,则原类的 + (void)initialize 将不会再调用
  1. 多个 category 中同时实现了 + (void)initialize 方法时,Compile Sources中顺序最下面的一个,即最后一个被编译 Category 的 + (void)initialize 会执行;

类别 Category 中添加关联对象

Category 中添加属性 @property 在前文已做过简单介绍,具体可查看 iOS 属性 @property 详细探究,这里我们重点说一下关联对象的实现原理:

操作关联对象有三个核心方法:

  1. 设置关联对象方法:

objc_setAssociatedObject(id _Nonnull object, const void * _Nonnull key, id _Nullable value, objc_AssociationPolicy policy)

    1. id _Nonnull object: 给哪个对象添加关联对象,通常是当前对象,即用 self 即可;
    1. const void * _Nonnull key: 关联对象的 key,作为关联对象的唯一标识存在,它只要是一个非空指针即可;
    1. id _Nullable value: 关联对象的值,通过关联 key 进行设值及获取值,如果需要清除一个已存在的关联对象,将其值设置为 nil 即可;
    1. objc_AssociationPolicy policy: 关联策略,即关联对象的存储形式,其可选枚举值如下:
public enum objc_AssociationPolicy : UInt {
    case OBJC_ASSOCIATION_ASSIGN = 0 // 指定对关联对象的弱引用
    case OBJC_ASSOCIATION_RETAIN_NONATOMIC = 1 // 指定对关联对象的强引用,非原子性
    case OBJC_ASSOCIATION_COPY_NONATOMIC = 3 // 指定复制关联的对象,非原子性
    case OBJC_ASSOCIATION_RETAIN = 769 // 指定对关联对象的强引用,原子性
    case OBJC_ASSOCIATION_COPY = 771 // 指定复制关联的对象,原子性
} 

根据源码,我们可以知道 objc_setAssociatedObject 实际调用的是 _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));

    DisguisedPtr<objc_object> disguised{(objc_object *)object};
    ObjcAssociation association{policy, value};

    // retain the new value (if any) outside the lock.
    association.acquireValue();

    bool isFirstAssociation = false;
    {
        AssociationsManager manager;
        AssociationsHashMap &associations(manager.get());

        if (value) {
            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 */
            auto &refs = refs_result.first->second;
            auto result = refs.try_emplace(key, std::move(association));
            if (!result.second) {
                association.swap(result.first->second);
            }
        } else {
            auto refs_it = associations.find(disguised);
            if (refs_it != associations.end()) {
                auto &refs = refs_it->second;
                auto it = refs.find(key);
                if (it != refs.end()) {
                    association.swap(it->second);
                    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.
    if (isFirstAssociation)
        object->setHasAssociatedObjects();

    // release the old value (outside of the lock).
    association.releaseHeldValue();
}

根据上述源码可以发现,ObjcAssociation 根据传入的 valuepolicy 创建对象,并经过 acquireValue 函数处理生成新的 _valueacquireValue 函数内部是通过对策略 policy 的判断进行相应处理,生成新值,其实现如下:

inline void acquireValue() {
    if (_value) {
        switch (_policy & 0xFF) {
        case OBJC_ASSOCIATION_SETTER_RETAIN:
            _value = objc_retain(_value);
            break;
        case OBJC_ASSOCIATION_SETTER_COPY:
            _value = ((id(*)(id, SEL))objc_msgSend)(_value, @selector(copy));
            break;
        }
    }
}

接下来我们首先需要了解一下 AssociationsManagerAssociationsHashMap

class AssociationsManager {
    using Storage = ExplicitInitDenseMap<DisguisedPtr<objc_object>, ObjectAssociationMap>;
    static Storage _mapStorage;

public:
    AssociationsManager()   { AssociationsManagerLock.lock(); }
    ~AssociationsManager()  { AssociationsManagerLock.unlock(); }

    AssociationsHashMap &get() {
        return _mapStorage.get();
    }

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

由源码可以知道,AssociationsManager 是以 DisguisedPtr<objc_object> 即一个指针地址作为 key,以 ObjectAssociationMap 即一个关联表作为 value 的哈希表来使用的。其内部是使用一个全局静态变量 static Storage _mapStorage 来存储程序中所有的关联对象。

这里重点介绍一下全局静态变量 static Storage _mapStorage 的初始化时机。App 启动过程中,在 _objc_init 函数中会调用 void _dyld_objc_notify_register(...),具体如下:

void _objc_init(void)
{
    //...
    // 此处仅保留谈到的函数
    _dyld_objc_notify_register(&map_images, load_images, unmap_image);
    //...
}

dyld 源码中可以看到,函数 _dyld_objc_notify_register 中的三个参数为三个回调函数的指针,如下图:

回调函数会在所有镜像文件初始化完成之后,回调 map_images(unsigned count, const char * const paths[], const struct mach_header * const mhdrs[]) 函数。详细调用流程如下图:

备注:图中已对无关代码进行删减,仅用来展示调用流程

从上图我们可以知道,在 App 启动过程中 AssociationsManager 中的静态变量 static Storage _mapStorage 的初始化时机。在 App 启动之后,所有用到关联对象的地方,程序都是从这个全局静态变量 _mapStorage 中获取 AssociationsHashMap 来对关联对象进行进一步处理。

AssociationsManager 中,我们可以看到是由一个 AssociationsManagerLock 叫做 spinlock_t 的互斥锁:

using spinlock_t = mutex_tt<LOCKDEBUG>;

它是用来保障 AssociationsManager 中对 AssociationsHashMap 操作的线程安全。

AssociationsHashMap &get() {
    return _mapStorage.get();
}

对于 AssociationsHashMap 这个哈希表,则是由全局静态变量 _mapStorage 获取而来,因此不管任何时候操作关联对象,程序始终都是在操作这个 AssociationsHashMap 全局唯一的哈希表。

再回到上面 _object_set_associative_reference 源码中,当我们添加一个关联对象时,AssociationsHashMap 会调用如下函数:

auto refs_result = associations.try_emplace(disguised, ObjectAssociationMap{});

try_emplace 函数的源码如下:

  template <typename... Ts>
  std::pair<iterator, bool> try_emplace(const KeyT &Key, Ts &&... Args) {
    BucketT *TheBucket;
    if (LookupBucketFor(Key, TheBucket))
      return std::make_pair(
               makeIterator(TheBucket, getBucketsEnd(), true),
               false); // Already in map.

    // Otherwise, insert the new element.
    TheBucket = InsertIntoBucket(TheBucket, Key, std::forward<Ts>(Args)...);
    return std::make_pair(
             makeIterator(TheBucket, getBucketsEnd(), true),
             true);
  }

首先根据传来的 keydisguisedAssociationsHashMap 中查找对应的 ObjectAssociationMap 是否已在映射表中,如果不在则将元素插入。如果键不在,则创建一个 BucketT 即一个空的桶。在第二次调用 try_emplace 时将 ObjcAssociation (里面包含了 _policy_value )存储到这个 BucketT 空桶中。

当设置的关联 value 为空 nil 的时候会进入 if 判断的 else 里面:

auto refs_it = associations.find(disguised);
if (refs_it != associations.end()) {
    auto &refs = refs_it->second;
    auto it = refs.find(key);
    if (it != refs.end()) {
        association.swap(it->second);
        refs.erase(it);
        if (refs.size() == 0) {
            associations.erase(refs_it);

        }
    }
}

先去 AssociationsHashMap 里面查找 disguised ,如果找到则根据 key 查找到指定的关联对象,然后进行清除 erase 操作。之后判断当前 object 的关联对象是否为0,如果为0,则将当前关联对象从全局的 AssociationsHashMap 中移除。

  1. 获取关联对象方法:

objc_getAssociatedObject(id _Nonnull object, const void * _Nonnull key)

    1. id _Nonnull object: 获取哪个对象里面的关联对象;
    1. const void * _Nonnull key: 关联对象的 key ,与 objc_setAssociatedObject 中的 key 相对应,通过 key 值取出 value 即关联对象;

其内部调用的是 _object_get_associative_reference ,内部具体实现如下:

如果我们理解了设置关联对象的过程,上面的代码理解起来就比较简单了,从全局的 AssociationsHashMap 中取得 object 对象对应的 ObjectAssociationMap ,然后根据 keyObjectAssociationMap 获取对应的 ObjcAssociation ,然后根据关联策略 _policy 判断是否需要对 _value 执行 retain 操作。最后根据关联策略 _policy 判断是否需要将 _value 添加到自动释放池,并返回 _value

  1. 移除关联对象:
    上面已经提到,如果想要清除某一个特定关联对象,设置关联对象的 valuenil 即可。如果想要移除所有关联对象,则可以使用:

objc_removeAssociatedObjects(id _Nonnull object)

    1. id _Nonnull object: 移除指定对象的所有关联对象

其内部实现代码如下:


当调用移除关联对象操作时,会先判断 object 是否为空及是否有关联对象存在,如果存储则会调用 _object_remove_assocations 函数。

从上图其内部实现代码可以看到,程序会获取全局的 AssociationsHashMap 然后从中获取对象对应的 ObjectAssociationMap ,注释说如果不是 deallocating,则系统的关联对象将会保留。而 objc_removeAssociatedObjects 函数传入的 deallocating 参数为 false,因此我们可以推断,解除关联必定不是在调用 objc_removeAssociatedObjects 时。

于是,我搜索了一下 _object_remove_assocations,发现了真正的调用时机,即在 objc_destructInstance 函数调用时,如上图。

那什么时候会调用 objc_destructInstance 函数呢?带着这个疑问,我查了一下源码,这里简单说一下调用流程,后续会专门针对 dealloc 写相关文章,其大体流程如下:

dealloc.png

图中函数调用流程非常清晰,此处不做过多解释。由此,我们知道解除关联对象是在源对象 dealloc 时进行的。


拓展知识

  1. iOS 中变量修饰词 @public@protected@package@private 的作用:

@package // 常用于框架类的实例变量,使用 @private 太限制,使用 @protected 或者 @public 又太开放,这时可以使用 @package

@private // 作用范围只能在自身类,即使子类也无法使用,但 category 及 extension 类中可以使用

@protected // 系统默认为 @protected,作用范围在自身类及子类

@public // 公开类型,作用域大,只要能拿到所属实例对象就可以使用

实例变量范围图(@package 的范围图中未展示)

@interface Person : NSObject {
@package
    NSString *_country; // 框架内拿到 Person 及其子类的实例变量都可以使用

@protected
    NSString *_birthday; // 只能在自身类及子类中使用,包括 category 及 extension

@private
    NSString *_weight; // 只能在自身类中使用,包括 category 及 extension

@public
    NSString *_height; // 全局任意拿到 Person 及其子类实例变量的地方都可以使用
}

具体实例如下: Son 继承自 Person

@interface Son : Person

@end

@protected
从上图示例代码可以看到,在子类中是可以访问父类的 @protected _birthday 成员变量,但不能访问父类的 @private _weight 成员变量。

从上图示例代码可以看到,在其他类中是可以访问父类的 @protected _birthday 成员变量,但不能访问父类的 @private _weight 成员变量。


总结

类别 category 和扩展 extension 涉及到的东西还是挺多的,这里仅对其核心要关注的一些点进行了详细介绍。另外还有关于 category 装载的过程,有兴趣的同学可以查阅一下。以上就是本文对类别 category 和 扩展 extension 相关知识点的介绍,感谢阅读。


参考资料:


关于技术组

iOS 技术组主要用来学习、分享日常开发中使用到的技术,一起保持学习,保持进步。文章仓库在这里:https://github.com/minhechen/iOSTechTeam
微信公众号:iOS技术组,欢迎联系进群学习交流,感谢阅读。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念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

推荐阅读更多精彩内容