【iOS】Runtime - Part 3 && Category:加载、覆盖与关联对象

承接对象/类结构与消息发送机制,梳理 Category 的编译产物、运行时加载、方法列表附加、同名方法覆盖现象、+load/+initialize 差异与关联对象原理。

作者 TommyWu
封面圖片: 【iOS】Runtime - Part 3 && Category:加载、覆盖与关联对象

#这篇在系列中的位置

Part 1 讲了对象、类、元类、isaclass_ro_t / class_rw_t,回答的是:一个 Objective-C 对象和类在 Runtime 里到底长什么样

Part 2 讲了 objc_msgSend、方法缓存、慢速查找、动态方法解析和消息转发,回答的是:一条消息最终怎么找到 IMP,找不到又怎么进入转发

到了 Part 3,我们把视角放到 Category:它看起来像是在 “给已有类加东西”,但 Runtime 真正做的并不是改写原类源码,而是在加载 image 时把 Category 的方法、协议、属性元数据附加到目标类的运行时数据结构里。所谓 Category “覆盖” 原方法,也不是把原方法删掉,而是改变了 Part 2 里消息查找会看到的方法列表顺序。

所以本文主线可以压成一句话:

Category 的本质,是把一组新的 method_list_t / property / protocol 列表挂到目标类的 class_rw_t(或新版 class_rw_ext_t)上;它不改变对象内存布局,但会改变消息查找顺序。

围绕这句话,本文会回答几个问题:

  • Category 为什么能加方法,却不能加 ivar?
  • Category 的编译产物 category_t 长什么样?
  • Runtime 什么时候把 Category 挂到目标类上?
  • 同名方法为什么表现得像 “覆盖”?
  • +load 为什么不会被覆盖,而 +initialize 会受消息发送影响?
  • 关联对象为什么能让 Category “看起来” 拥有属性存储?

#先给结论:Category 的本质

这一节只做结构预告,不新增新概念。后面的源码细节都围绕同一个结论展开:Category 不改对象内存布局,只把自己的方法、属性元数据、协议列表附加到目标类的运行时可写数据里;所谓 “覆盖”,本质是消息查找顺序改变。

#Category 简介:给已有类追加行为

  • Category 用来给一个已经存在的类添加额外方法,而且不需要继承。
  • 可以把类的实现拆到几个不同的文件里:减少单个文件体积,把不同功能组织到不同 Category 中,也方便多人并行维护同一个类。
  • 声明私有方法

还有广大开发者衍生出的其他使用场景

  • 模拟多继承
  • 把 framework 私有方法公开

如果 Category 跟随动态库或 bundle 一起分发,也可以服务于插件化或按需加载场景:某个 image 被加载后,其中的 Category 才会被 runtime 读取并附加。这个能力不应该被用来制造业务上的加载顺序依赖,但它解释了为什么跨 image 的 Category 顺序更不稳定。

Category(分类)看起来和 Extension(扩展)有点相似。Extension(扩展)有时候也被称为 匿名分类。但两者实质上是不同的东西。 Extension(扩展)是在编译阶段与该类同时编译的,是类的一部分。而且 Extension(扩展)中声明的方法只能在该类的 @implementation 中实现,这也就意味着,你无法对系统的类(例如 NSString 类)使用 Extension(扩展)。

而且和 Category(分类)不同的是,Extension(扩展)不但可以声明方法,还可以声明成员变量,这是 Category(分类)所做不到的。


#为什么能加方法,不能加 ivar

先不从历史说起,直接看现代 Runtime 的结构。Category 的边界,根本上取决于 class_ro_tclass_rw_t 的分工:影响对象内存布局的东西必须编译期定死;不影响对象布局的元数据,才可以在运行期追加。

#OC 2.0 干的最关键的事:拆分 ro 和 rw

OC 2.0 把类的元数据拆成了两块:

Class
└─ bits ─→ class_rw_t(运行时可写)
├─ ro ─→ class_ro_t(只读、编译期固定)
│ ├─ baseMethodList / baseMethods() ← 类自己写的方法
│ ├─ ivars ← 内存布局(不可改)
│ ├─ baseProperties
│ └─ baseProtocols
├─ methods ← Category 方法附加到这里
├─ properties
└─ protocols
  • class_ro_t(read only):编译期定死的东西,放在 Mach-O 的只读数据里;现代系统上常见位置更准确地说是 __DATA_CONST,dyld 完成 fixup 后再变成只读。包含 ivarsinstanceSizebaseMethodList(通过 baseMethods() 访问)等等。
  • class_rw_t(read write):运行时才创建,可写。Category 方法、class_addMethod 加的方法,全部进这里。

为什么要这么拆? 还是回到那条主线 ——

  • ivar 影响内存布局,必须编译期定死 → 放 ro,只读,省内存又安全;
  • 方法、协议、属性元数据不影响内存布局,运行期可以追加 → 放 rw,可写。

WWDC 2020 之后,新版 objc4 进一步把 class_rw_t 中的可变数组外移到 class_rw_ext_t(rwe)里、按需分配,进一步省内存。具体细节我们留到后面 “目标类那一侧:class_rw_t 与 class_rw_ext_t” 一节展开。这里只要先记住一句抽象:编译期固定的东西在 ro,运行期可附加的东西在 rw(或 rwe)管理的列表里

记住这张图,后面所有源码都是在这个结构上做手术。


#Category 数据结构:编译产物 category_t

所有的 OC 类和对象,在 runtime 层都是用 struct 表示的,category 也不例外,在 runtime 层,category 用结构体 category_t(在 objc-runtime-new.h 中可以找到此定义),它包含了:

  • 1)、类的名字(name)
  • 2)、类(cls)
  • 3)、category 中所有给类添加的实例方法的列表(instanceMethods)
  • 4)、category 中所有添加的类方法的列表(classMethods)
  • 5)、category 实现的所有协议的列表(protocols)
  • 6)、category 中添加的实例属性元数据(instanceProperties)
  • 7)、category 中添加的类属性元数据(_classProperties,新版 runtime 中可见)
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; // 实例属性元数据
struct property_list_t *_classProperties; // 类属性元数据,新版 runtime 使用
};

category 中没有 ivars,也就是没有成员变量列表,这就解释了为什么它可以添加属性声明 / 属性元数据,但是不能直接添加成员变量。因为对象的内存布局由类的 ivar list 决定。一个对象创建时,它占多少内存、每个 ivar 在哪里,已经由类结构在 realization 后确定下来,运行期间添加成员变量会破坏原有内存布局。

Category 是后期附加的元数据,它不会改变对象内存大小。如果你想让 Category 里声明的属性真的能存值,需要自己实现 getter / setter,通常再用关联对象把值放到对象外部。

struct method_list_t : entsize_list_tt<method_t, method_list_t, 0xffff0003> {
// 成员变量和方法
};
template <typename Element, typename List, uint32_t FlagMask>
struct entsize_list_tt {
uint32_t entsizeAndFlags;
uint32_t count;
Element first;
};

这里的 entsize_list_tt 可以理解为一个容器,拥有自己的迭代器用于遍历所有元素。 Element 表示元素类型,List 用于指定容器类型,最后一个参数为标记位。早期源码里常见的 mask 是 0x3;objc4-818 之后要保留高位标记,例如 relative method list 相关信息,所以这里应按新版写成 0xffff0003

虽然这段代码实现比较复杂,但仍可了解到 method_list_t 是一个存储 method_t 类型元素的容器。method_t 结构体在旧版运行时里常被写成下面这样:

struct method_t {
SEL name;
const char *types; // 方法类型编码
IMP imp;
};

这个定义适合解释字段语义:一个方法记录就是 SEL、类型编码和 IMP。但如果全文以 WWDC 2020 / objc4-818 之后的新运行时为背景,就不能把它当成唯一的内存布局。新版 method_t 支持 big(绝对指针)和 small / relative 两类形态;relative method list 会把 nametypesimp 表示成相对当前结构的 32 位偏移,用更小的空间表达同样的语义。

当消息发送时,

1. 从 obj 的 isa 找到类对象 MyClass
2. 先查方法缓存 cache
3. cache 没有,就查方法列表 method_list_t
4. 在方法列表里查找 method_t
5. 找到 method_t.name == @selector(printName)
6. 拿到 method_t.imp
7. 调用 imp(obj, @selector(printName))
8. 可能把 selector -> imp 缓存起来

所以 method_t 就是消息发送最终要找的核心记录,是 Objective-C 方法在 Runtime 的核心结构。无论底层用绝对指针还是相对偏移,它表达的仍然是 “这个 selector 对应哪段实现,以及它的类型编码是什么”。

最后,我们还有一个结构体 category_list 用来存储所有的 category,它的定义如下:

struct locstamped_category_list_t {
uint32_t count;
locstamped_category_t list[0];
};
struct locstamped_category_t {
category_t *cat; // category本身
struct header_info *hi; // category来自的mach-o镜像信息
};
typedef locstamped_category_list_t category_list;

locstamped_category_t:Category + 来源镜像 locstamped_category_list_t/category_list:多个带来源信息的 Category

除了标记存储的 category 的数量外,locstamped_category_list_t 结构体还声明了一个长度为零的数组。这类写法常用于表达 “结构体尾部跟着一段可变长度数据”;标准 C99 的 flexible array member 写法通常是 list[],而 list[0] 更像是编译器扩展 / 历史写法。

可以这样理解:

  • category_t 是一个具体 Category 的 runtime 本体;
  • method_list_t 是 category_t 中保存实例方法 / 类方法的列表;
  • method_list_t 继承自 entsize_list_tt,内部存放多个 method_t;
  • method_t 是一个具体方法,包含 SEL、types、IMP;
  • locstamped_category_t 是 category_t 的包装,额外记录它来自哪个 Mach-O 镜像;
  • locstamped_category_list_t 是多个 locstamped_category_t 的列表;
  • category_list 是 locstamped_category_list_t 的别名,runtime 用它保存某个类待附加的 Category 集合。
category_list / locstamped_category_list_t
└── locstamped_category_t
├── category_t *cat
│ ├── method_list_t *instanceMethods
│ │ └── entsize_list_tt<method_t, method_list_t, ...>
│ │ └── method_t[]
│ │ ├── SEL name
│ │ ├── const char *types
│ │ └── IMP imp
│ │
│ ├── method_list_t *classMethods
│ │ └── entsize_list_tt<method_t, method_list_t, ...>
│ │ └── method_t[]
│ │
│ ├── protocol_list_t *protocols
│ ├── property_list_t *instanceProperties
│ └── property_list_t *_classProperties
└── header_info *hi

以上是 Category 这一侧 的数据结构。但 Category 加载本质上是” 把 Category 的内容附加到目标类上”,所以在进加载流程之前,还需要看一下 目标类那一侧 的数据结构 —— 也就是 class_rw_t


#目标类那一侧:class_rw_t 与 class_rw_ext_t

OC 2.0 那一节我们已经画过类结构图,知道 Category 加载的最终落点是 class_rw_t.methods / properties / protocols 这三个数组。这一节展开看这三个数组本身长什么样、Category 是怎么挂上去的,以及新版的 class_rw_ext_t 又是怎么回事。我们重点看 methodspropertiesprotocols 是同一套机制。

#class_rw_t 的早期定义

bits 中包含一个指向 class_rw_t 结构体的指针。早期版本的定义如下:

struct class_rw_t {
uint32_t flags;
uint32_t version;
const class_ro_t *ro;
method_array_t methods;
property_array_t properties;
protocol_array_t protocols;
};

在 Objective-C 2.0 的 non-fragile ABI 中,类的实例布局信息保存在 class_ro_t 中,例如 instanceStartinstanceSize 分别描述本类实例变量的起始位置和对象实例总大小。更重要的是,ivar 的偏移量不再被编译期完全写死,而是通过 ivar_list_t 中的 offset 指针等运行时元数据进行间接访问。这样当父类新增实例变量导致实例大小变化时,runtime 可以在类 realization 阶段重新调整子类 ivar 的偏移量,从而避免子类必须重新编译。这就是 Objective-C 2.0 ABI 稳定性的一部分。

但这并不意味着 Category 可以在运行时给一个已经存在的类插入 ivar。non-fragile ABI 解决的是” 父类布局变化时,子类不必重新编译” 的问题;Category 加载发生在类的运行时元数据附加阶段,它只能附加方法、属性元数据和协议,不能改变对象实例大小。

#method_array_t:可扩展的” 方法列表的列表”

class_rw_t.methods 的类型是 method_array_t,继承自 list_array_tt。它是一个用于管理若干个 list 的容器:对 methods 来说,它管理的是多个 method_list_t,每个 method_list_t 里面再存多个 method_t。从逻辑上可以类比为二维数组。

template <typename Element, typename List>
class list_array_tt {
struct array_t {
uint32_t count;
List* lists[0];
};
// 内部用一个指针/位标记来表示 empty / single list / array
};
class method_array_t : public list_array_tt<method_t, method_list_t>;

Element 是元数据的类型(如 method_t),List 是用于存储元数据的一维数组(如 method_list_t)。

list_array_tt 有三种状态:

  1. :没有任何 method_list_t,类比为 []
  2. 单列表:直接指向一个 method_list_t,类比为 [[m1, m2]]
  3. 多列表:指向一个 array_t,里面保存多个 method_list_t*,类比为 [[m1, m2], [m3, m4]]

类刚 realize 后,通常会把 class_ro_t 里的 baseMethodList(访问器常叫 baseMethods())放进 class_rw_t.methods。如果类本身没有方法,可能为空;如果有方法,可能是单列表状态。当 runtime 后续通过 Category、class_addMethod 等方式追加新的方法列表时,methods 通常会从空 / 单列表形态扩展为多列表形态。

这一节最重要的事实:Category 加载时,会把 Category 自己的 method_list_t 整体作为一个” 指针” 挂到 method_array_t 的最前面,而不是把方法逐个拷贝进原列表。这正好对应文章开头讲 OC 1.0 时的二级指针思想。

#attachLists:把新列表插到最前面

附加的核心函数是 attachLists

void attachLists(List* const * addedLists, uint32_t addedCount) {
if (addedCount == 0) return;
uint32_t oldCount = array()->count;
uint32_t newCount = oldCount + addedCount;
setArray((array_t *)realloc(array(), array_t::byteSize(newCount)));
array()->count = newCount;
// 把旧列表整体往后挪 addedCount 格
memmove(array()->lists + addedCount, array()->lists,
oldCount * sizeof(array()->lists[0]));
// 把新列表写到最前面
memcpy(array()->lists, addedLists,
addedCount * sizeof(array()->lists[0]));
}

逻辑很直白:realloc 扩容 → memmove 把旧列表整体往后挪 → memcpy 把新列表写到前面。新列表永远排在前面—— 这是后面 “覆盖” 假象的物理基础。

实际代码会更复杂一些。当 list_array_tt 处于” 单列表” 状态时,并不需要在原地扩容空间,而是只要重新申请一块内存,并将最后一个位置留给原来的集合即可。这样只多花费一点空间,但减少了内存拷贝,是一个空间换时间的权衡。无论走哪条路径,参数列表都会被添加到二维数组的前面这一事实不变。

#新版的变化:class_rw_ext_t

WWDC 2020 之后(对应 objc4-818 及以后的版本),苹果对这一块做了进一步优化:把 class_rw_t 中的 methods / properties / protocols 三个数组外移到一个新结构 class_rw_ext_t(简称 rwe)里,按需分配

理由很现实 ——绝大多数类一辈子都不会有 Category、不会被 class_addMethod 修改。给每个类都分配一个完整的、可写的 class_rw_t(包含三个数组)是浪费,因为这块属于 dirty memory,内存代价很高。新版变成:

class_rw_t.ro_or_rw_ext (一个 union 风格的指针)
├─ 平时 → 直接指向 class_ro_t(只读,可共享 page)
└─ 类被动态修改后 → 指向 class_rw_ext_t(包含 methods/properties/protocols)

触发 rwe 创建的关键函数是 extAllocIfNeeded

class_rw_ext_t *extAllocIfNeeded() {
auto v = get_ro_or_rwe();
if (fastpath(v.is<class_rw_ext_t *>())) {
return v.get<class_rw_ext_t *>(...); // 已经有了,直接返回
} else {
return extAlloc(v.get<const class_ro_t *>(...)); // 没有,现在创建
}
}

谁会触发?附加 Category、class_addMethodclass_addProtocol 这些动态修改类的操作。所以一个类只要” 被 Category 附加过一次”,rwe 就会被分配出来。

但要意识到一点:rwe 是性能优化,不是机制变化。从理解 Category 的角度,仍然可以把整个模型抽象成” 编译期固定信息在 ro,运行期可附加的方法 / 属性 / 协议在 rw(或 rw_ext)管理的列表里”。具体源码对照见下一节。


#为什么 Category 像是覆盖了原方法

objc_msgSend 查找方法的逻辑(简化版):

static method_t *getMethodNoSuper_nolock(Class cls, SEL sel) {
for (auto mlists = cls->data()->methods.beginLists(),
end = cls->data()->methods.endLists();
mlists != end; ++mlists) {
method_t *m = search_method_list(*mlists, sel);
if (m) return m; // 找到就返回,不再继续
}
return nil;
}
static method_t *search_method_list(const method_list_t *mlist, SEL sel) {
for (auto& meth : *mlist) {
if (meth.name == sel) return &meth;
}
return nil;
}

这段代码只是在说明” 列表之间” 的查找顺序:getMethodNoSuper_nolock 会从 class_rw_t.methods(或新版的 rwe->methods)管理的多个 method_list_t 中按顺序取列表,命中第一个列表后就返回。真实运行时在” 单个列表内部” 还会区分情况:已排序的方法列表通常走 findMethodInSortedMethodList 二分查找,未排序或小区间才线性扫描。

所以真正重要的不是” 每个列表内部一定线性遍历”,而是:列表之间从前往后查,第一个命中就返回。配上 attachLists “新列表在前” 的事实:

Category 中的同名方法不会替换类中原有的方法,但消息发送时会优先命中 Category 的版本。原方法依然在 class_rw_t.methods(或 rwe->methods)里,只是被排到了后面,永远轮不到它。

这就是 “覆盖” 假象的全部秘密。

#Runtime 什么时候加载 Category

dyld 加载的流程大致是这样:

  1. 配置环境变量;
  2. 加载共享缓存;
  3. 实例化主程序对象;
  4. 加载插入的动态库(DYLD_INSERT_LIBRARIES);
  5. 链接主程序;
  6. 链接插入的动态库;
  7. 初始化主程序:Objective-C、C++ 全局对象、+load 等初始化逻辑;
  8. 返回主程序入口函数。

初始化主程序中,Runtime 初始化的大致调用栈如下:

dyldbootstrap::start ---> dyld::_main ---> initializeMainExecutable ---> runInitializers ---> recursiveInitialization ---> doInitialization ---> doModInitFunctions ---> _objc_init

在 _objc_init 这一步中,Runtime 会向 dyld 注册回调。之后当 image 加载到内存后,dyld 会通知 Runtime 进行处理:map_images 负责解析和注册 image 中的 ObjC 元数据;_read_images / load_categories_nolock 会读取 Category 列表,并把 Category 的内容附加到目标类或元类上;随后 load_images 中会调用 call_load_methods,按规则调用 Class 和 Category 的 +load 方法。

加载 Category(分类)的调用栈如下:

_objc_init ---> 注册 dyld 回调 ---> map_images / map_images_nolock ---> _read_imagesload_categories_nolock(读取并附加分类) ---> load_images(调用 +load)。

一些说明:

不同版本的 objc4 在函数拆分和命名上会有差异。旧版源码 (可在早期开源版本中找到) 用 addUnattachedCategoryForClass + remethodizeClass + attachCategories 三个函数清晰地划分了” 登记 → 取出 → 合并” 三个步骤。新版源码做了路径优化:对已经 realized 的类直接调用 attachCategories 立即合并,只有未 realized 的类才会先放入 unattached category 表,等到类 realization 时再统一附加。

这里的 realize class 指的是 runtime 把编译产物里的 class_ro_t 展开成运行期可用的类结构:分配或准备 class_rw_t / class_rw_ext_t,修正 ivar 偏移,连接父类和元类关系,并把基础方法、属性、协议和等待附加的 Category 合并进去。理解这一点后,“未 realized 先暂存,已 realized 立即 attach” 就不难理解了。

但要意识到,新旧版的差异是性能优化,不是机制变化—— 核心思路都是” 延迟合并”:Category 加载时如果目标类还没准备好,就先存起来,等准备好了再合。本文下面保留旧版函数名来讲解,因为它把这个思路展示得最完整。理解了旧版,新版只是少了一次绕路。

忽略 _read_images 方法中其他与本文无关的代码,得到如下代码:

// 从当前 Mach-O 镜像 hi 中获取 Category 列表。
// catlist 指向当前镜像中的 category_t 指针数组。
// count 表示当前镜像中 Category 的数量。
category_t **catlist = _getObjc2CategoryList(hi, &count);
// 判断当前镜像是否包含 Category 的 class property 元数据。
// 新版 runtime 中会用这个标志决定是否读取 cat->_classProperties。
bool hasClassProperties = hi->info()->hasCategoryClassProperties();
// 遍历当前镜像中的所有 Category。
for (i = 0; i < count; i++) {
// 取出一个 Category。
category_t *cat = catlist[i];
// 根据 Category 中记录的目标类引用,找到 runtime 中真正的 Class。
// remapClass 用于处理类重映射、weak-linked class 等情况。
Class cls = remapClass(cat->cls);
// 如果目标类不存在,说明这个 Category 无法附加。
// 常见情况:目标类是 weak-linked,但当前系统中不存在。
if (!cls) {
catlist[i] = NULL;
continue;
}
// ============================================================
// 一、处理 Category 的“实例侧内容”
//
// 包括:
// 1. 实例方法 instanceMethods
// 2. 协议 protocols
// 3. 实例属性元数据 instanceProperties
//
// 这些内容会附加到普通类对象 cls 上。
// ============================================================
bool classExists = NO;
if (cat->instanceMethods ||
cat->protocols ||
cat->instanceProperties)
{
// 先把 Category 登记到目标类 cls 对应的待附加列表中。
//
// 注意:
// addUnattachedCategoryForClass 并不是真正合并方法列表。
// 它只是建立:
//
// cls -> category
//
// 的关联关系。
addUnattachedCategoryForClass(cat, cls, hi);
// 如果目标类已经 realized,
// 说明它已经完成 runtime 初始化,可以立刻合并 Category 内容。
if (cls->isRealized()) {
// 真正把 Category 的实例方法、属性、协议等附加到 cls 上。
remethodizeClass(cls);
classExists = YES;
}
}
// ============================================================
// 二、处理 Category 的“类侧内容”
//
// 包括:
// 1. 类方法 classMethods
// 2. 协议 protocols
// 3. 类属性元数据 _classProperties
//
// 这些内容会附加到目标类的元类 cls->ISA() 上。
// ============================================================
if (cat->classMethods ||
cat->protocols ||
(hasClassProperties && cat->_classProperties))
{
// 类方法本质上是元类的实例方法,
// 所以 Category 的类方法要登记到 cls->ISA() 上,
// 也就是目标类的元类。
addUnattachedCategoryForClass(cat, cls->ISA(), hi);
// 如果元类已经 realized,
// 就立刻把 Category 的类方法、类属性元数据、协议等附加进去。
if (cls->ISA()->isRealized()) {
remethodizeClass(cls->ISA());
}
}
}

这段伪代码主要用到了两个方法:

  • addUnattachedCategoryForClass(cat, cls, hi); 把分类先登记到目标类的待附加列表里
  • remethodizeClass(cls); 触发待附加分类真正合并到类的方法、属性、协议列表中

通过这两个方法达到了两个目的:

  1. 把 Category(分类) 的对象方法、协议、属性添加到类上;
  2. 把 Category(分类) 的类方法、协议、类属性元数据添加到类的 metaclass 上。

下面来说说上边提到的这两个方法。

// 将一个 Category 暂存到“目标类 cls 的待附加 Category 列表”中。
// 注意:这个函数并不会真正把 Category 的方法、属性、协议合并到类上。
// 真正合并发生在后面的 remethodizeClass(cls) 中。
static void addUnattachedCategoryForClass(category_t *cat,
Class cls,
header_info *catHeader)
{
// 当前必须已经持有 runtimeLock。
// 因为这里要修改 runtime 的全局 Category 映射表。
runtimeLock.assertLocked();
// 获取全局的“未附加 Category 映射表”。
//
// 这个表可以理解为:
//
// Class -> category_list
//
// 也就是:
//
// 某个类 -> 这个类当前等待附加的 Category 列表
//
NXMapTable *cats = unattachedCategories();
category_list *list;
// 根据目标类 cls,从映射表中取出它已有的待附加 Category 列表。
list = (category_list *)NXMapGet(cats, cls);
if (!list) {
// 如果这个类还没有待附加 Category 列表,
// 就创建一个新的 category_list。
//
// sizeof(*list):
// category_list 结构体头部大小,里面包含 count。
//
// sizeof(list->list[0]):
// 一个 locstamped_category_t 的大小。
//
// 因为 list 里使用了 list[0] 这种零长度数组技巧,
// 所以创建第一个元素时,需要额外申请一个元素的空间。
list = (category_list *)calloc(
sizeof(*list) + sizeof(list->list[0]),
1
);
} else {
// 如果这个类已经有待附加 Category 列表,
// 就扩容,多申请一个 locstamped_category_t 的空间。
//
// 原来能存 list->count 个 Category,
// 现在要存 list->count + 1 个。
list = (category_list *)realloc(
list,
sizeof(*list) + sizeof(list->list[0]) * (list->count + 1)
);
}
// 把当前 Category 和它所在的 Mach-O 镜像信息一起存入列表。
//
// locstamped_category_t 包含:
// cat : Category 本体
// catHeader : Category 来自哪个 Mach-O 镜像
//
// list->count++:
// 先把元素写入当前位置,再把 count 加 1。
list->list[list->count++] = (locstamped_category_t){ cat, catHeader };
// 把更新后的 list 重新插入全局映射表。
//
// 如果之前 cats 中已经有 cls 对应的旧 list,
// 这里会用扩容后的新 list 覆盖旧记录。
NXMapInsert(cats, cls, list);
}

addUnattachedCategoryForClass(cat, cls, hi); 的执行过程可以参考代码注释。执行完这个方法之后,系统会将当前分类 cat 放到该类 cls 对应的未依附分类 unattached 的列表 list 中。这句话有点拗口,简而言之,就是:先把类和分类做一个关联映射,真正合并稍后发生

实际上真正起到添加加载作用的是下边的 remethodizeClass(cls); 方法。

static void remethodizeClass(Class cls)
{
category_list *cats;
bool isMeta;
// 当前必须已经持有 runtimeLock。
// 因为这里会读取并修改 runtime 中类的全局结构。
runtimeLock.assertLocked();
// 判断当前传入的 cls 是普通类,还是元类。
//
// 如果是普通类:
// 处理 Category 的实例方法、实例属性、协议等。
//
// 如果是元类:
// 处理 Category 的类方法、类属性、协议等。
isMeta = cls->isMetaClass();
// 从全局 unattachedCategories 表中取出 cls 对应的待附加 Category 列表。
//
// false 表示当前不是在 realizing class 的流程中调用。
//
// 返回的 cats 可以理解为:
//
// cls -> [CategoryA, CategoryB, CategoryC]
//
// 这些 Category 之前是通过 addUnattachedCategoryForClass(cat, cls, hi)
// 暂存进去的。
cats = unattachedCategoriesForClass(cls, false /* not realizing */);
if (cats) {
// 真正把 cats 中的 Category 附加到 cls 上。
//
// attachCategories 会处理:
// 1. 方法列表 methods
// 2. 属性列表 properties
// 3. 协议列表 protocols
//
// 如果 cls 是普通类:
// Category 的实例方法会被附加到 cls。
//
// 如果 cls 是元类:
// Category 的类方法会被附加到 cls。
//
// true 表示附加完成后需要刷新方法缓存。
attachCategories(cls, cats, true /* flush caches */);
// cats 是从 unattachedCategoriesForClass 取出的临时列表。
// attach 完成后释放。
free(cats);
}
}

remethodizeClass(cls); 方法主要就做了一件事:调用 attachCategories(cls, cats, true); 方法将未依附分类的列表 cats 附加到 cls 类上。所以,我们就再来看看 attachCategories(cls, cats, true); 方法。

static void attachCategories(Class cls,
category_list *cats,
bool flush_caches)
{
if (!cats) return;
if (PrintReplacedMethods) {
printReplacements(cls, cats);
}
// 判断当前 cls 是普通类还是元类。
// 普通类:处理 Category 的实例方法、实例属性、协议。
// 元类:处理 Category 的类方法、类属性、协议。
bool isMeta = cls->isMetaClass();
// 创建三个临时数组,用来收集所有 Category 中的方法列表、属性列表、协议列表。
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));
// 记录实际收集到的方法列表、属性列表、协议列表数量。
int mcount = 0;
int propcount = 0;
int protocount = 0;
// 从后往前遍历 Category。
// 这样可以保证“最新加载的 Category”排在前面。
int i = cats->count;
// 标记这些 Category 是否来自 bundle。
bool fromBundle = NO;
while (i--) {
// 取出当前 Category 以及它所在的 Mach-O 镜像信息。
auto& entry = cats->list[i];
// 取出当前 Category 的方法列表。
//
// 如果 cls 是普通类:
// methodsForMeta(false) 取 instanceMethods。
//
// 如果 cls 是元类:
// methodsForMeta(true) 取 classMethods。
method_list_t *mlist = entry.cat->methodsForMeta(isMeta);
if (mlist) {
// 将当前 Category 的方法列表保存到临时方法列表数组中。
mlists[mcount++] = mlist;
// 记录是否有来自 bundle 的 Category。
fromBundle |= entry.hi->isBundle();
}
// 取出当前 Category 的属性列表。
//
// 如果 cls 是普通类:
// 通常取 instanceProperties。
//
// 如果 cls 是元类:
// 可能取 classProperties;
// 如果当前镜像不支持 classProperties,则可能返回 nil。
property_list_t *proplist =
entry.cat->propertiesForMeta(isMeta, entry.hi);
if (proplist) {
proplists[propcount++] = proplist;
}
// 取出当前 Category 声明遵守的协议列表。
protocol_list_t *protolist = entry.cat->protocols;
if (protolist) {
protolists[protocount++] = protolist;
}
}
// 取出当前类的运行时可变数据 class_rw_t。
auto rw = cls->data();
// 对即将附加的方法列表做准备工作。
//
// 例如:
// 排序
// 修正方法列表
// 检查特殊方法
// 标记是否来自 bundle
prepareMethodLists(cls, mlists, mcount, NO, fromBundle);
// 将 Category 的方法列表附加到 class_rw_t 的 methods 中。
//
// 注意:
// attachLists 会把新列表放到旧列表前面。
// 所以 Category 的同名方法会优先于原类方法被查找到。
rw->methods.attachLists(mlists, mcount);
// 临时数组已经被 attachLists 使用完,可以释放。
free(mlists);
// 如果需要刷新缓存,并且确实附加了方法列表,就清理方法缓存。
//
// 因为 Category 可能添加或“覆盖”同名方法,
// 如果不清缓存,objc_msgSend 可能继续命中旧 IMP。
if (flush_caches && mcount > 0) {
flushCaches(cls);
}
// 将 Category 的属性列表附加到 class_rw_t 的 properties 中。
rw->properties.attachLists(proplists, propcount);
// 释放属性临时数组。
free(proplists);
// 将 Category 的协议列表附加到 class_rw_t 的 protocols 中。
rw->protocols.attachLists(protolists, protocount);
// 释放协议临时数组。
free(protolists);
}

attachCategories 干的事可以总结成三步:

  1. 从后往前遍历 Category,把它们的方法列表收集到一个临时数组 mlists
  2. 一次性调用 rw->methods.attachLists(mlists, mcount),把这些列表整体插到 class_rw_t.methods 的最前面(参考前面 class_rw_t 那一节里讲过的 attachLists);
  3. 如果加了方法,清掉方法缓存,否则 objc_msgSend 可能继续命中旧 IMP。

注意第一步的” 从后往前”—— 这一步加上第二步的” 插到前面”,叠加效果就是:最后登记的 Category,它的方法会出现在 class_rw_t.methods 数组的最前面。这是后面 “覆盖” 假象的关键。

#Category 加载流程总览

把上面的源码细节收起来,Category 的加载流程可以理解成下面这条链路:

1. 编译器把 Category 编译成 category_t
2. category_t 被放进 Mach-O 的 __objc_catlist
3. runtime 在 _read_images 中读取 __objc_catlist
4. 得到 category_t **catlist
5. 遍历每个 category_t
6. 找到它的目标类 cls
7. 调用 addUnattachedCategoryForClass(cat, cls, hi)
8. 把 category_t 和 header_info 包装成 locstamped_category_t
9. 放入 cls 对应的 category_list
10. 如果 cls 已经 realized,调用 remethodizeClass(cls)
11. remethodizeClass 取出 category_list
12. attachCategories 遍历 category_list
13. 从每个 locstamped_category_t 里取出 category_t
14. 从 category_t 里取出 method_list_t
15. 把 method_list_t attach 到 class_rw_t.methods(新版通常落到 rwe->methods)

这里最关键的点有三个:

  1. Category 不是把方法 “拷贝进类原来的方法列表”,而是把自己的 method_list_t 挂到类的 method_array_t 前面。
  2. Category 的实例方法附加到类对象;Category 的类方法附加到元类,因为类方法本质上是元类的实例方法。
  3. Category 中的同名方法并没有真正删除或替换原方法,只是在普通消息查找顺序上排在更前面,所以表现为 “覆盖”。

#新版源码对照:实现变了,本质没变

上面的代码是早期 objc4 的实现。WWDC 2020 之后(objc4-818 及以后),同样这件事的源码组织有了几处变化,但核心机制完全没变。整理一下对照表:

维度旧版(早期 objc4)新版(objc4-818 起)
登记函数addUnattachedCategoryForClass(cat, cls, hi)objc::unattachedCategories.addForClass(...)(C++ 命名空间风格)
触发函数remethodizeClass(cls)attachToClass(cls, previously, flags) 取代,最终也是调到 attachCategories
合并函数attachCategories(cls, cats, flush)attachCategories(cls, cats_list, cats_count, flags)(签名变了)
方法挂载点rw->methods.attachLists(...)rwe->methods.attachLists(...),且 rwe 通过 extAllocIfNeeded() 按需创建
收集临时数组malloc(cats->count * sizeof(...))栈上固定 64 槽位 ATTACH_BUFSIZ,满了就 flush 一次

新版的 attachCategories 大致长这样:

static void attachCategories(Class cls,
const locstamped_category_t *cats_list,
uint32_t cats_count,
int flags)
{
constexpr uint32_t ATTACH_BUFSIZ = 64;
method_list_t *mlists[ATTACH_BUFSIZ]; // 栈上,固定 64 个,避免 malloc
property_list_t *proplists[ATTACH_BUFSIZ];
protocol_list_t *protolists[ATTACH_BUFSIZ];
uint32_t mcount = 0, propcount = 0, protocount = 0;
bool fromBundle = NO;
bool isMeta = (flags & ATTACH_METACLASS);
// 关键:按需分配 rwe(只有真要附加内容时才创建)
auto rwe = cls->data()->extAllocIfNeeded();
for (uint32_t i = 0; i < cats_count; i++) {
auto& entry = cats_list[i];
method_list_t *mlist = entry.cat->methodsForMeta(isMeta);
if (mlist) {
// 栈缓冲区满了就先 flush 一次
if (mcount == ATTACH_BUFSIZ) {
prepareMethodLists(cls, mlists, mcount, NO, fromBundle, __func__);
rwe->methods.attachLists(mlists, mcount);
mcount = 0;
}
// 倒序填充:让最终顺序一次 attachLists 就是对的
mlists[ATTACH_BUFSIZ - ++mcount] = mlist;
fromBundle |= entry.hi->isBundle();
}
// properties / protocols 同理...
}
// 处理剩余的(不满 64 个)
if (mcount > 0) {
prepareMethodLists(cls, mlists + ATTACH_BUFSIZ - mcount,
mcount, NO, fromBundle, __func__);
rwe->methods.attachLists(mlists + ATTACH_BUFSIZ - mcount, mcount);
if (flags & ATTACH_EXISTING) flushCaches(cls);
}
// 同样处理 properties / protocols...
}

几个变化点的解读:

  1. extAllocIfNeeded():旧版直接 cls->data() 拿到 class_rw_t,三个数组就在里面。新版要走 extAllocIfNeeded() 才能拿到 rwe—— 如果这个类还没被动态修改过,rwe 不存在,这一步会创建它。这就是 WWDC 2020 提到的” 按需分配,省 dirty memory”
  2. 栈缓冲区 + 分块 flush:旧版 malloc(cats->count * ...),一次性收集所有方法列表。新版用 64 个槽位的栈数组,满了就 flush 一次,避免堆分配 —— 绝大多数类的 Category 数量远小于 64,所以这是常见路径。
  3. 倒序填充 mlists[ATTACH_BUFSIZ - ++mcount] = mlist:旧版 while (i--) 倒序遍历得到” 最新 Category 在前”;新版正序遍历,但是用倒序下标填充缓冲区,最终效果一样 —— 最新 Category 在 mlists 数组的前面

总之,新旧版做的还是同一件事:

  • 收集所有 Category 的方法列表 →
  • attachLists 把它们整体插到目标类方法数组的最前面 →
  • 清缓存。

下面所有讨论的 “覆盖” 行为、+load 特性、Category 不能加 ivar 等结论,新旧版完全一致—— 它们的根都在更深的设计层(内存布局、消息发送),不在这些函数命名上。

#Category 和 Class 的 +load 方法

Category 的方法、属性元数据、协议附加到类上的操作,发生在 +load 之前。换句话说,+load 跑起来时,类已经是合并完所有 Category 之后的样子了。

#调用顺序

  1. 主类 +load 先调:按继承关系从父类到子类,同一层级内再受编译 / 链接顺序影响;
  2. 分类 +load 后调:同一个 Mach-O 内通常受编译 / 链接顺序影响,跨动态库、bundle 或不同 image 时不应依赖这个顺序;
  3. +load 默认只调一次,除非你主动调它。

主类的 +load 一定先于分类的 +load。但分类之间不是按继承关系调的,所以可能是 父类 → 子类 → 父类分类 → 子类分类,也可能是 父类 → 子类 → 子类分类 → 父类分类不要把多个 Category 的 +load 顺序设计成业务依赖

#为什么 Category 的 +load 不会 “覆盖” 主类的 +load?

很多人会困惑:前面那一节刚论证过 Category 同名方法会 “覆盖” 主类的,那为什么主类和所有 Category 的 +load 都会被各自调一遍?

答案是 +load 不走 objc_msgSend

回顾一下 “覆盖” 的本质 —— 它依赖 objc_msgSend 的查找规则:从 class_rw_t.methods(或 rwe->methods)从前往后查,第一个命中的胜出。Category 的方法被插在前面,所以 “赢” 了。

+load 完全不走这条路。runtime 在 load_images 阶段会直接从类的方法列表里把 +load 的 IMP 找出来,像调用普通 C 函数那样调用它—— 根本不经过方法查找、不经过缓存。所以:

  • 主类的 +load 是类方法,类方法本质上是元类的实例方法,所以它的 IMP 在元类class_ro_t 基础方法列表里,被找到并调用;
  • 每个 Category 的 +load IMP 在各自 Category 的方法列表里,也分别被找到并调用;
  • 它们井水不犯河水,全都执行。

#对比:+initialize 会被 Category “覆盖”

+initialize 看起来和 +load 很像,但行为差别巨大 —— 因为 **+initializeobjc_msgSend**。

+initialize 在类第一次收到消息时被 runtime 调用,调用方式就是普通的消息发送。所以:

  • 如果 Category 实现了 +initialize它会按 “覆盖” 规则把主类的 +initialize 屏蔽掉—— 查找时 Category 的版本先命中;
  • 如果子类没实现 +initialize,调用子类会沿继承链找到父类的,导致父类的 +initialize 被多次调用(每个未覆盖的子类一次)。

一句话总结+load 是” 直接拿地址调用”,所以人人有份;+initialize 是” 走消息发送”,所以会被 Category “覆盖”。

#Category 与关联对象

之前提到过,在 Category 中虽然可以声明 @property,但是不会自动生成对应的成员变量,也不会自动生成 getter / setter 实现。因此,如果只声明属性但不自己实现访问器,在调用 Category 中声明的属性时就会因为找不到方法而报错。

那么就没有办法使用 Category 中的属性了吗?

答案当然是否定的。我们可以自己实现 getter / setter,并借助关联对象(Objective-C Associated Objects)来存值。关联对象能在运行时把任意的值关联到一个对象上。

#三个 API

// 1. 设置:给 object 关联一个 (key, value)
void objc_setAssociatedObject(id object, const void *key,
id value, objc_AssociationPolicy policy);
// 2. 获取
id objc_getAssociatedObject(id object, const void *key);
// 3. 移除该对象所有关联值(一般不用手动调)
void objc_removeAssociatedObjects(id object);

关联对象的精髓只有一句:值根本没存在对象里,而是存在 runtime 维护的一张全局哈希表里

具体来说:

  • runtime 维护一张全局表 AssociationsHashMap
  • 外层 key 是对象指针的伪装形式(常见源码里是 DisguisedPtr),value 是这个对象自己的 ObjectAssociationMap
  • 内层 ObjectAssociationMap 再以你传入的 void *key 为 key,value 是你存的值,连同内存策略(assign / retain / copy)一起包装在 ObjcAssociation 里;
  • 当对象 dealloc 时,runtime 会自动清理这张表里属于它的所有条目(路径是 objc_destructInstance → _object_remove_assocations),所以一般不需要手动调 objc_removeAssociatedObjects

#为什么这能绕开” 不能加 ivar”

回到文章主线 ——

关联对象没有动对象的内存布局class_ro_t.ivars 没变,instanceSize 没变,所有偏移量都没变。值不在对象里,而在外部哈希表里。

所以” 看起来” 像是给对象加了个属性,本质上是先用对象找到它的关联表,再用关联 key 找到对应记录。也可以把它粗略理解成” 对象指针 + 关联 key” 共同决定一条值记录,但源码结构不是单层组合键,而是两级哈希表。这正好呼应文章一开始的判断:

类别影响内存布局?处理方式
ivar编译期定死,Category 不能加
方法class_rw_t.methods(或 rwe->methods),Category 可以加
属性元数据class_rw_t.properties,Category 可以加
属性的存储(绕开)不放对象里,放 runtime 全局哈希表

Category 不能加 ivar,但可以在 class_rw_t 里附加方法和属性元数据,再借助 runtime 的全局哈希表存值 —— 这两件事合起来,就让 Category 拥有了” 看起来能加属性” 的能力。

#历史旁注:从 OC 1.0 看 Category 的设计动机

typedef struct objc_class *Class;
@interface NSObject <NSObject> {
    Class isa  OBJC_ISA_AVAILABILITY;
}
struct objc_class {
    Class isa  OBJC_ISA_AVAILABILITY;
#if !__OBJC2__
    Class super_class                                        OBJC2_UNAVAILABLE;
    const char *name                                         OBJC2_UNAVAILABLE;
    long version                                             OBJC2_UNAVAILABLE;
    long info                                                OBJC2_UNAVAILABLE;
    long instance_size                                       OBJC2_UNAVAILABLE;
    struct objc_ivar_list *ivars  //一级指针                           OBJC2_UNAVAILABLE;
    struct objc_method_list **methodLists    //二级指针                OBJC2_UNAVAILABLE;
    struct objc_cache *cache                                 OBJC2_UNAVAILABLE;
    struct objc_protocol_list *protocols                     OBJC2_UNAVAILABLE;
#endif
} OBJC2_UNAVAILABLE;

在 Objc2 以前,class 源码如上。

ivars 是 objc_ivar_list 成员变量列表的指针;methodLists 是指向 objc_method_list 指针的指针。*methodLists 是指向方法列表的指针。运行时可以把新的方法列表指针挂到 methodLists 管理的列表里,这就是早期 Runtime 中 Category 附加方法的核心思路之一。Category 不能添加 ivar,这与对象内存布局不能在运行时随意改变有关。

一级指针的话,methodLists 直接指向一个方法列表,想新增方法只能重新分配内存、拷贝旧列表、追加新方法,代价很高。

二级指针的话,methodLists 指向的是一个指针数组,每个元素指向一个独立的方法列表:

methodLists ──→ [ *list1, *list2, *list3, ... ]
↓ ↓ ↓
原始方法 Category Category
列表 A的方法 B的方法

加载一个 Category 时,Runtime 不需要改写原来的方法列表内容,而是把 Category 自己的方法列表作为一个新的列表指针挂进去。可以把它理解成:新增一组方法列表入口,而不是把方法逐个拷贝进原列表

ivars 是一级指针,且不能像方法列表一样动态扩展,根本原因是:对象的内存布局不能在类已经投入运行后随意改变。对象实例需要多大、每个 ivar 位于哪里,都会影响对象创建、成员访问和父子类布局。如果运行时强行插入一个新 ivar,后面 ivar 的偏移量、对象大小、已经创建的实例都会受到影响。

所以 ivars 根本没有设计成可动态扩展的,不是技术上做不到,而是做了也没用,内存布局不允许

至于 “Category 不能添加属性” 这句话,更准确地说应该是 “Category 不能添加带存储的属性”。 @property 可以拆成几类信息:

  1. 一组属性元数据
  2. 一个 getter 方法声明
  3. 一个 setter 方法声明(只读属性没有 setter)
  4. 在普通类扩展或类实现中,编译器还可能自动合成 ivar 和 getter / setter 实现

Category 里声明 @property 没问题,它会产生属性元数据,也会让编译器知道有 getter/setter 这两个访问入口。但 Category 不能自动合成 ivar,也不会自动生成 getter/setter 实现。所以准确的说法是:Category 可以添加属性声明/属性元数据,但不能添加真正的 ivar 存储;如果要让这个属性可用,必须自己实现 getter/setter,通常再配合关联对象存值。 这也是为什么用 Associated Objects 能绕过去 —— 它根本没有修改对象的内存布局,而是在对象外部维护了一张独立的哈希表来存值,只是” 看起来” 像属性而已。

#例题:NSObject (Sark) 为什么不崩?

看一道常见题:

@interface NSObject (Sark)
+ (void)foo;
@end
@implementation NSObject (Sark)
- (void)foo {
NSLog(@"IMP: %@, self: %@", NSStringFromSelector(_cmd), self);
}
@end
int main(int argc, char * argv[]) {
[NSObject foo];
[[NSObject new] foo];
return 0;
}

表面上看,@interface 里声明的是类方法 +foo,但 @implementation 里实现的是实例方法 -foo。直觉可能会以为 [NSObject foo] 找不到类方法会崩,但实际并不会。

原因还是回到前面那套模型:

  1. 这个 Category 会被编译进 Mach-O 的 __objc_catlist
  2. Runtime 在 _read_images 里读到它,再经由 addUnattachedCategoryForClassremethodizeClassattachCategories 附加到目标类。
  3. -foo 是实例方法,所以会被附加到 NSObject 这个类对象的方法列表里。
  4. 类方法查找时,receiver 是类对象 NSObject,而类对象本身也是一个对象;如果在元类上找不到 +foo,沿继承链和根元类结构继续查找时,最终可能命中根类对象方法列表里的 -foo

这道题真正考的不是语法声明,而是两件事:

  • Category 的方法是按编译产物里的方法列表附加的,声明和实现不一致时,真正进入 Runtime 的是实现里的 -foo
  • 类对象、元类、根类之间的查找路径,会让 [NSObject foo][[NSObject new] foo] 都有机会命中同一个 -foo 实现。

同时,这道题也能把 Category 加载流程再串一次:

_objc_init
-> map_images
-> _read_images
-> 读取 __objc_catlist
-> addUnattachedCategoryForClass
-> remethodizeClass
-> attachCategories
-> attachLists 前置插入
-> 必要时 flushCaches

这里的 attachLists 前置插入和 flushCaches,正好呼应前文的两个结论:Category 方法会排到更靠前的位置;如果类的 cache 里已经有旧结果,方法列表变化后必须处理缓存一致性。

#小结:Category 把 Part 1 和 Part 2 连起来

回到整个 Runtime 系列的视角,Category 正好站在 Part 1 和 Part 2 的交界处。

从 Part 1 的类结构看,Category 能成立,是因为类的元数据被拆成了编译期固定的 class_ro_t 和运行期可变的 class_rw_t / class_rw_ext_tivarsinstanceSize 这类影响对象内存布局的东西必须留在 ro,所以 Category 不能加 ivar;方法、属性元数据、协议不改变实例布局,可以在运行时附加到 rw / rwe 管理的列表里。

从 Part 2 的消息发送看,Category 的 “覆盖” 又不是替换,而是查找顺序导致的结果。attachLists 把 Category 的方法列表插到前面,objc_msgSend 慢速查找时先看到它,于是同名 selector 优先命中 Category 的 IMP。原方法没有消失,只是排在后面。

所以 Category 的核心不是 “魔法”,而是两条 Runtime 规则叠在一起:

  1. 对象内存布局不能随便变,所以不能加 ivar。
  2. 方法列表可以运行期追加,而且消息查找按列表顺序命中第一个实现。

理解这两条,Category 的加载、覆盖、+load+initialize 和关联对象就都能落回同一个模型里。

#Q&A

Q:在多个分类中实现同名方法时,会调用哪个?

普通消息发送会命中方法列表中最靠前的那个实现。通常可以理解为” 后附加的分类优先级更高”,但跨 image 的加载顺序不应依赖。在 Xcode 里,“后附加” 基本由 Build Phases → Compile Sources 顺序决定(排在下面的后编译,通常也后附加)。

Q:被 “覆盖” 的原方法还在吗,能调到吗?

还在,没被删除 ——class_rw_t.methods(或 rwe->methods)里旧的 method_list_t 仍然存在,只是被排到了后面。但实践上很难直接调到:

  • [super xxx] 不行 —— 它走的是父类查找;
  • 想拿到原方法只能手动通过 class_copyMethodList 之类的接口去遍历,绕过 objc_msgSend

正常的 [obj method] 调用无法指定调用被遮蔽的主类实现或某个特定分类实现。如果业务确实需要保留旧实现,应该在附加 / 交换前主动保存 IMP,或者干脆避免在多个 Category 中定义同名方法。

Q:能在 Category 里调到主类的版本吗?

不能。多个 Category 都同名时,主类的实现一定排在最后,永远不会被 objc_msgSend 命中。

#参考链接