iOS 底层原理 Category load initialize 关联对象

编译期,生成 category_t 结构体

在编译阶段,Clang 编译器并不会将 Category 的方法直接塞进原类中,而是将其打包成一个独立的 C 语言结构体 category_t。在 objc-runtime-new.h 中,它的定义如下

arduino 复制代码
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; // 属性列表
};

从结构体定义可以看出,Category 没有实例变量(ivar)字段 。因为类的内存布局(instanceSize)在编译期已经固化,运行时无法安全扩展对象的内存空间

运行时:类的"实现化"与分类挂载

当程序启动时,dyld 将镜像加载到内存,Runtime 会执行 _read_images 函数进行类的初始化。

当一个类首次被引用或加载时,Runtime 会调用 realizeClass 方法,对尚未"实现化"的类执行结构构建。此时,Runtime 会遍历所有已注册的 Category,并调用 attachCategories 函数将其挂载到目标类上:

scss 复制代码
// objc4 源码简化版
static void attachCategories(Class cls, category_list *cats, bool flush_caches) {
    // ...
    // 将分类的方法、协议、属性附加到类中
    auto rw = cls->data(); 
    rw->methods.attachLists(mlists, mcount);
    rw->properties.attachLists(proplists, propcount);
    rw->protocols.attachLists(protolists, protocount);
    // ...
}

核心机制:头插法(Prepend)与优先级

这是 Category 最核心的底层逻辑。在 attachLists 函数中,Runtime 采用了前置插入(头插法) 策略:

scss 复制代码
// objc4 源码:list_array_tt::attachLists()
void attachLists(List* const * addedLists, uint32_t addedCount) {
    if (hasArray()) {
        // 扩容数组,将原有的方法列表向后移动
        // 将新加入的 Category 方法列表放在数组的最前面
        memmove(array()->lists + addedCount, array()->lists, 
                array()->count * sizeof(List*));
        memcpy(array()->lists, addedLists, 
               addedCount * sizeof(List*));
    }
}
  • 原类的方法列表被整体向后移动,Category 的方法列表被放在了 methodLists 数组的头部
  • objc_msgSend 触发消息调度时,会在方法列表中进行二分查找。因为 Category 的方法在前面,所以会优先被命中
  • 这就是为什么 Category 中的同名方法会"覆盖"原类方法------它并没有真正删除原方法,只是通过改变查找顺序实现了"就近覆盖"的语义。

多 Category 冲突:逆序遍历规则

如果一个类有多个 Category,且它们包含同名方法,谁优先?

attachCategories 的源码中,有一个非常巧妙的倒序遍历逻辑

scss 复制代码
// objc4 源码:attachCategories()
// Add in reverse order (按相反顺序添加)
// Count backwards through cats to get newest categories first
int i = cats->count;
while (i--) {
    auto& entry = cats->list[i];
    method_list_t *mlist = entry.cat->methodsForMeta(isMeta);
    if (mlist) {
        mlists[mcount++] = mlist;
    }
}

多个 Category 存在方法冲突时,后编译的 Category 优先级更高

objc2的实现是每个对象的方法都保存在methodlist的一维数组。而obj4的实现是每个category都会增加一个methodlist,实际是一个二维数组。查找方法的时候,根据先编译后查找的顺序,遍历每个category。而遍历到元类的时候,支持二分查找。category本身还是线性查找。

关联对象(Associated Objects)的底层实现

既然 Category 不能添加实例变量,@property 是如何工作的?答案是通过关联对象。

当你在 Category 中实现 setter/getter 并调用 objc_setAssociatedObject 时,底层是这样工作的:

typescript 复制代码
// objc4 源码:_object_set_associative_reference()
void _object_set_associative_reference(id object, const void *key, id value, uintptr_t policy) {
    // 1. 获取全局的关联对象管理器
    AssociationsManager manager;
    // 2. 获取全局的二级哈希表
    AssociationsHashMap &associations = manager.associations();
    
    // 3. 使用 DISGUISE(obj) 对对象指针做位运算伪装作为 Key
    disguised_ptr_t disguised_object = DISGUISE(object);
    
    // 4. 在哈希表中查找或创建该对象的关联映射表,并将 key-value 存入
    // ...
}
  • Runtime 维护了一个全局的 AssociationsHashMap(二级哈希表)。
  • 一级 Key 是对象地址的伪装值(DISGUISE(object)),二级 Key 是你传入的 key(通常是静态变量的地址)。
  • 这种设计将附加数据与对象的生命周期绑定,当对象被销毁(dealloc)时,Runtime 会自动清理这张表,避免内存泄漏。

load方法

调用时机:在 main() 函数之前执行

+load 方法的调用发生在 App 启动的 Pre-main 阶段。具体来说,当动态链接器 dyld 将可执行文件和动态库加载到内存后,会触发 Runtime 的初始化入口函数 _objc_init

_objc_init 中,Runtime 向 dyld 注册了一个回调函数 load_images。每当有新的二进制镜像(Image)被加载时,dyld 就会调用 load_images,从而触发所有 +load 方法的执行。这意味着,所有的 +load 方法执行完毕后,程序才会进入 main() 函数

底层执行流程:生产者与消费者模型

load_images 函数内部,+load 方法的执行被清晰地划分为"准备(生产)"和"调用(消费)"两个阶段

arduino 复制代码
void load_images(const char *path, const struct mach_header *mh) {
    // 如果没有 +load 方法,直接返回
    if (!hasLoadMethods((const headerType *)mh)) return;
    
    // 1. 准备阶段:将包含 +load 的类和分类加入待执行列表
    prepare_load_methods((const headerType *)mh);
    
    // 2. 调用阶段:真正执行 +load 方法
    call_load_methods();
}
  • 准备阶段 (prepare_load_methods) :Runtime 会遍历当前镜像中的类列表和分类列表。如果发现有实现了 +load 方法的类或分类,就会调用 schedule_class_load 递归地将其父类加入 loadable_classes 数组,然后将自身也加入该数组。对于分类,则将其加入 loadable_categories 数组。
  • 调用阶段 (call_load_methods) :Runtime 会循环消费上述两个数组中的元素。通过直接获取方法的函数指针(IMP),以 (*load_method)(cls, SEL_load) 的形式直接调用 +load 方法。

极其严格的调用顺序

苹果在源码中严格保证了 +load 方法的调用顺序,这主要体现在以下四个方面:

  • 父类优先于子类 :在 schedule_class_load 中,Runtime 会递归调用父类的加载逻辑,确保父类的 +load 先被加入待执行列表。
  • 主类优先于分类 :在 call_load_methods 的循环中,Runtime 会先遍历执行完所有的 loadable_classes,然后再去执行 loadable_categories
  • 分类之间的顺序 :同一个类的多个分类,其 +load 方法的执行顺序取决于它们在 Xcode 项目中的编译顺序(Compile Sources)
  • 类之间的顺序 :不同类之间的 +load 执行顺序同样取决于编译顺序,开发者不应在代码中依赖跨类的 +load 执行先后。

核心底层特性

  • 直接函数调用,非消息发送+load 方法不走 objc_msgSend 消息发送机制,而是通过函数指针直接调用。因此,它不会被继承 ,如果子类没有实现 +load,Runtime 绝不会去调用父类的 +load
  • 主类与分类共存 :与普通的实例方法不同,如果一个类和它的分类都实现了 +load,Runtime 会同时调用这两个方法,不会发生方法覆盖。
  • 阻塞主线程 :因为 +loadmain() 之前执行,且运行在单线程环境下,如果在其中执行耗时操作,会严重拖慢 App 的启动速度。

💡 总结与实战建议

+load 方法最经典的底层应用是 Method Swizzling(方法交换) ,因为它在类加载时执行且只执行一次,能保证方法交换的绝对安全9。但在实际开发中,除了方法交换,应尽量避免在 +load 中编写复杂的业务逻辑,以免阻塞 App 启动。对于常规的业务初始化,推荐使用懒加载的 +initialize 方法。

initialize

只有第一次执行objc_msgSend的时候才会被执行,且优先执行父类的initialize,再执行自己类的initialize

scss 复制代码
static void initializeNonMetaClass(Class cls) {
    // 1. 获取当前类的父类
    Class supercls = cls->superclass; 
    
    // 2. 【核心】如果父类存在,且父类【尚未被初始化】,则递归初始化父类
    if (supercls && !supercls->isInitialized()) {
        initializeNonMetaClass(supercls); 
    }
    
    // ... (省略获取 imp 的代码)
    // 3. 调用当前类的 +initialize 方法
    callInitialize(cls); 
}

集合源码分析,先调用父类的initialize,再执行自己的initialize。但是如果父类实现了initialize,有2个子类继承了父类,且子类都没有实现自己的initialize。那么在子类执行initialize的时候是通过objc_msgSend,根据继承链,又会调用父类的方法。所以父类可能被多次调用。

另外,每次initialize的时候会优先调用父类的方法。因此自己类的initialize中不要调用父类的initialize

调用时机(Pre-main vs 懒加载)

  • +load :发生在 App 启动的 Pre-main 阶段 。当 dyld 将镜像加载到内存后,Runtime 会立即执行所有类和分类的 +load 方法。它先于 main() 函数执行,且保证每个类/分类只执行一次。
  • +initialize :发生在懒加载阶段 。只有当该类或其子类第一次接收到消息 (如 objc_msgSend)时,Runtime 才会触发 +initialize。如果程序运行期间从未使用过某个类,它的 +initialize 永远不会被调用。

2. 底层调用机制(函数指针 vs 消息发送)

  • +load :不走 objc_msgSend 消息发送机制。Runtime 在源码中通过函数指针直接调用(*load_method)(cls, SEL_load))。这意味着它完全绕过了消息查找机制。
  • +initialize :走标准的 objc_msgSend 消息发送机制 。Runtime 在 lookUpImpOrForward 中如果发现当前类没有 +initialize 的缓存,就会触发该方法的调用。

3. 继承规则(不继承 vs 继承)

  • +load不继承 。如果子类没有实现 +load,Runtime 绝不会去调用父类的 +load
  • +initialize会继承 。如果子类没有重写 +initialize,当子类首次接收消息时,Runtime 会沿着继承链向上查找,最终调用父类的 +initialize。(注:这也是为什么苹果官方建议在 +initialize 内部加上 if (self == [ClassName class]) 判断的原因,防止父类方法被子类触发多次)。

4. 线程安全性(单线程 vs 线程安全锁)

  • +load :在 dyld 加载镜像时执行,此时系统处于单线程环境 ,不需要加锁。因此,在 +load 中执行耗时操作会直接阻塞 App 的启动。
  • +initialize :Runtime 在底层对其加了全局互斥锁(Mutex Lock) 。这保证了即使多个线程同时首次触发同一个类的消息,+initialize 也只会执行一次。但这也意味着,如果在 +initialize 中执行耗时操作,会导致所有等待该类的线程全部阻塞。

5. 主类与分类的共存规则(共存 vs 覆盖)

  • +load共存 。如果一个类和它的分类都实现了 +load,Runtime 会全部调用(先调用主类的,再调用分类的),不会发生方法覆盖。
  • +initialize覆盖 。由于走的是消息发送机制,如果分类重写了 +initialize,根据 Category 的"头插法"原理,分类的方法优先级更高,主类的 +initialize 将被完全覆盖。

💡 核心总结与实战建议

对比维度 +load +initialize
调用时机 镜像加载时(main 之前) 首次接收消息时(懒加载)
调用机制 函数指针直接调用 objc_msgSend 消息发送
继承性 不继承 继承
线程安全 单线程环境,无锁 互斥锁保护,多线程安全
分类冲突 共存(全部执行) 覆盖(分类优先)

实战建议

  • +load :仅用于Method Swizzling(方法交换) 等必须在程序启动最早期完成的底层 Hook 操作。
  • +initialize :适用于全局单例的初始化全局配置项的懒加载等不需要在 Pre-main 阶段抢占资源的业务逻辑。

关联对象

关联对象的使用

关联对象的核心是 Runtime 提供的三个 C 语言 API,使用时需引入 <objc/runtime.h> 头文件:

1. 设置关联对象

csharp 复制代码
void objc_setAssociatedObject(id object, const void *key, id value, objc_AssociationPolicy policy);
  • object:宿主对象(你要给哪个对象添加属性)。
  • key:全局唯一的标识符(通常使用静态变量的地址 &key@selector()绝不可用字符串字面量,以防编译器优化合并导致 Key 冲突)。
  • value:关联的值(支持 nil)。
  • policy:内存管理策略,对标 @property 的修饰符

2. 获取关联对象

csharp 复制代码
id objc_getAssociatedObject(id object, const void *key);

3. 移除关联对象

csharp 复制代码
void objc_removeAssociatedObjects(id object);

生产环境中极少手动调用此函数,因为宿主对象在 dealloc 时 Runtime 会自动清理所有关联对象,手动调用极易引发未预期的逻辑中断

二、 关联对象的底层原理

关联对象并不是存放在宿主对象本身的内存(Ivar)中的,而是由 Runtime 在外部维护了一张全局的二级哈希表

1. 核心数据结构

Runtime 内部通过 AssociationsManager 管理着一个全局的 AssociationsHashMap

  • 第一层哈希表(AssociationsHashMap) :以宿主对象的内存地址(经过 DISGUISE 位运算伪装)为 Key,映射到该对象专属的 ObjectAssociationMap
  • 第二层哈希表(ObjectAssociationMap) :以开发者传入的 key 为子键,存储具体的 ObjcAssociation 结构体。
  • ObjcAssociation :封装了实际的 value(关联的值)和 policy(内存管理策略)。

这种设计保证了查找效率为 O(1),且关联数据与宿主对象的内存布局完全隔离213。

2. 内存管理策略(Policy)

objc_AssociationPolicy 决定了 Runtime 如何管理关联值的内存生命周期:

  • OBJC_ASSOCIATION_ASSIGN:弱引用(不持有),等同于 assign/unsafe_unretained
  • OBJC_ASSOCIATION_RETAIN_NONATOMIC:强引用、非原子性(最常用,等同于 strong)。
  • OBJC_ASSOCIATION_COPY_NONATOMIC:Copy 语义、非原子性(适用于 Block 和 NSString)。
  • OBJC_ASSOCIATION_RETAIN / OBJC_ASSOCIATION_COPY:原子性的强引用/Copy(性能稍低,极少使用)。
3. 生命周期与自动清理机制

关联对象的生命周期严格绑定于宿主对象 。当宿主对象的引用计数归零并触发 dealloc 时,Runtime 会在调用 object->isa->dealloc 之前,先执行 _object_remove_assocations() 函数。

该函数会遍历该对象在 AssociationsHashMap 中的所有 ObjcAssociation,并根据其绑定的 policy 自动执行 releaseautorelease 或置 nil 操作,最后清空哈希表。这意味着开发者完全不需要手动干预内存释放,从根本上避免了野指针和内存泄漏的风险。

推荐语法

arduino 复制代码
static void *kButtonActionKey = &kButtonActionKey;
static const char a;虽然内存更省,但不推荐

1. 内存占用对比

  • static void *kButtonActionKey;:作为一个指针变量,在 64 位系统下占用 8 个字节
  • static char a;:作为一个字符型变量,仅占用 1 个字节

所以,单从声明这个变量本身来看,使用 char 确实比使用 void * 节省了 7 个字节的内存空间。

2. 为什么业界更推荐用 void *

虽然 char 更省内存,但在苹果官方文档和主流开源库中,static void * 才是标准写法,主要出于以下两个工程化考量:

  • 语义表达更清晰 :关联对象的 Key 本质上是一个内存地址(指针) 。使用 void * 能够极其准确地表达"我只是一个占位用的内存地址,并不关心它指向的具体数据类型"这一语义。而 char 会让阅读代码的人误以为这里存储的是某种字符数据。
  • 规避编译器警告 :在 objc_setAssociatedObject 中,Key 的参数类型是 const void *。如果你声明的是 static char a;,在传入 &a 时,由于 char *void * 类型不同,在某些严格的编译器设置下可能会触发类型转换的警告。而直接使用 void * 则能完美避免这个问题。
相关推荐
用户2181697049308 小时前
iOS 底层原理 block
objective-c
2501_915106329 小时前
Swift 4开发环境设置教程:xCode安装与Playground入门
ide·vscode·ios·objective-c·个人开发·swift·敏捷流程
97650333513 小时前
iOS 上架/审核 4.3a全面解读-最新
flutter·ios·objective-c·uniapp
秋雨梧桐叶落莳4 天前
【iOS】从源码深入理解RunLoop机制
macos·ios·objective-c·cocoa·cocoapods·uikit
2501_915106325 天前
第一次开发 iPhone App,可能碰到的问题,解决办法
ide·vscode·ios·objective-c·个人开发·swift·敏捷流程
sakiko_7 天前
OC基础语法(与Swift对比)-1
开发语言·ios·objective-c·swift
2501_916008898 天前
Flutter 开发 iOS,项目打包成 IPA,命令与免 Xcode两种方法
ide·vscode·ios·objective-c·个人开发·swift·敏捷流程
无定义_11 天前
萌新联赛(六)
macos·objective-c·cocoa
FairGuard手游加固11 天前
iOS 游戏加固技术解析:代码混淆、资源加密、内存保护与重签名检测
安全·游戏·macos·unity·ios·objective-c