由 布多(budo) 发布于 2024-12-11 • 最后更新于2025-01-09
前言
Category 是 ObjC 中一个基础且重要的概念。本文将从 Runtime 源码入手,向你介绍 Category 的概念以及底层的实现原理。
Category 概念
Category 主要是用来给已存在的类动态添加方法实现,也可扩展协议和属性。基于此特性,我们可以用 Category 实现如下功能:
Category 之编译期实现细节
创建一个 ObjC 源代码文件并将其命名为 test_category.m
,然后在文件内输入如下代码:
#import <Foundation/Foundation.h>
@interface NSObject (WXLCategory)<NSCopying>
@property () NSInteger wxl_ist_prot;
@property (class) NSInteger wxl_cls_prot;
- (void)wxl_ist_func; + (void)wxl_cls_func;
@end
@implementation NSObject (WXLExtension)
- (void)wxl_ist_func {} + (void)wxl_cls_func {}
@end
|
这里我特意只写了方法的实现,而没有写属性和协议的实现,后面会解释为什么。
使用 xcrun -sdk iphoneos clang -arch arm64 -rewrite-objc test_category.m
命令可以将上述代码编译为 C++ 文件,代码精简后如下所示:
struct category_t { const char *name; struct class_t *cls; const struct method_list_t *instance_methods; const struct method_list_t *class_methods; const struct protocol_list_t *protocols; const struct prop_list_t *instanceProperties; const struct prop_list_t *_classProperties; };
static struct category_t _OBJC_$_CATEGORY_NSObject_$_WXLCategory __attribute__ ((used, section ("__DATA, __objc_const"))) = { "NSObject", 0, (const struct method_list_t *)&_OBJC_$_CATEGORY_INSTANCE_METHODS_NSObject_$_WXLCategory, (const struct method_list_t *)&_OBJC_$_CATEGORY_CLASS_METHODS_NSObject_$_WXLCategory, (const struct protocol_list_t *)&_OBJC_CATEGORY_PROTOCOLS_$_NSObject_$_WXLCategory, (const struct prop_list_t *)&_OBJC_$_INSTANCE_PROP_LIST_NSObject_$_WXLCategory, (const struct prop_list_t *)&_OBJC_$_CLASS_PROP_LIST_NSObject_$_WXLCategory, };
|
_classProperties
变量是我参考 Runtime 源码后手动加上的,你编译的代码可能会没有。
从编译后的代码中不难看出,一个 Category 对象,其底层其实就是1个 category_t
的结构体对象,这个结构体中包含了实例属性、类属性、实例方法、类方法以及协议等变量用来保存分类中的相关数据。
最后,编译器会把 category_t
相关数据保存在 Mach-O 文件的 objc_const 数据段下,等待运行时解析。
Category 之运行时实现细节:探索内部实现原理
相关代码整理后如下所示(代码有点长,不想看可以跳过,后面有解释):
本文使用的 Runtime 源码出自 objc4-928.2,为了方便大家阅读,我会对代码样式和排版略作修改以及删减一些不影响代码主逻辑的冗余代码。
我在 这里 维护了一个可以直接运行调试的 Runtime 项目,方便大家直接调试源码。
void load_images(const struct _dyld_objc_notify_mapped_info* info) { if (!hasLoadMethods((const headerType *)info->mh, info->sectionLocationMetadata)) return; loadAllCategoriesIfNeeded(); }
static bool didInitialAttachCategories = false;
void loadAllCategoriesIfNeeded() { if (!didInitialAttachCategories) {
for (auto *hi = FirstHeader; hi != NULL; hi = hi->getNext()) { load_categories_nolock(hi); } didInitialAttachCategories = true; } }
static void load_categories_nolock(header_info *hi) { bool hasClassProperties = hi->info()->hasCategoryClassProperties(); size_t count; auto processCatlist = [&](category_t * const *catlist) { for (unsigned i = 0; i < count; i++) { category_t *cat = catlist[i]; Class cls = remapClass(cat->cls); locstamped_category_t lc{cat, cls, hi}; if (cat->instanceMethods || cat->protocols || cat->instanceProperties) { if (cls->isRealized()) { attachCategories(cls, &lc, 1, cls, ATTACH_EXISTING); } else { objc::unattachedCategories.addForClass(lc, cls); } }
if (cat->classMethods || cat->protocols || (hasClassProperties && cat->_classProperties)) { if (cls->ISA()->isRealized()) { attachCategories(cls->ISA(), &lc, 1, cls, ATTACH_EXISTING | ATTACH_METACLASS); } else { objc::unattachedCategories.addForClass( lc.reSignedForMetaclass(cls), cls->ISA()); } } } }; processCatlist(hi->catlist(&count)); }
static void attachCategories(Class cls, const locstamped_category_t *cats_list, uint32_t cats_count, Class catsListKey, int flags) { constexpr uint32_t ATTACH_BUFSIZ = 64;
struct Lists { ReversedFixedSizeArray<method_list_t *, ATTACH_BUFSIZ> methods; ReversedFixedSizeArray<property_list_t *, ATTACH_BUFSIZ> properties; ReversedFixedSizeArray<protocol_list_t *, ATTACH_BUFSIZ> protocols; }; Lists normalLists; bool isMeta = (flags & ATTACH_METACLASS); auto rwe = cls->data()->extAllocIfNeeded(); for (uint32_t i = 0; i < cats_count; i++) { auto& entry = cats_list[i]; method_list_t *mlist = entry.getCategory(catsListKey)->methodsForMeta(isMeta); Lists *lists = &normalLists; bool isPreattached = entry.hi->info()->dyldCategoriesOptimized() && !DisablePreattachedCategories; if (mlist) { if (lists->methods.isFull()) { rwe->methods.attachLists(lists->methods.array, lists->methods.count, isPreattached, PrintPreopt ? "methods" : nullptr); lists->methods.clear(); } lists->methods.add(mlist); } property_list_t *proplist = entry.getCategory(catsListKey)->propertiesForMeta(isMeta, entry.hi); if (proplist) { if (lists->properties.isFull()) { rwe->properties.attachLists(lists->properties.array, lists->properties.count, isPreattached, PrintPreopt ? "properties" : nullptr); lists->properties.clear(); } lists->properties.add(proplist); } protocol_list_t *protolist = entry.getCategory(catsListKey)->protocolsForMeta(isMeta); if (protolist) { if (lists->protocols.isFull()) { rwe->protocols.attachLists(lists->protocols.array, lists->protocols.count, isPreattached, PrintPreopt ? "protocols" : nullptr); lists->protocols.clear(); } lists->protocols.add(protolist); } } auto attach = [&](Lists *lists, bool isPreattached) { rwe->methods.attachLists(lists->methods.begin(), lists->methods.count, isPreattached, PrintPreopt ? "methods" : nullptr); rwe->properties.attachLists(lists->properties.begin(), lists->properties.count, isPreattached, PrintPreopt ? "properties" : nullptr); rwe->protocols.attachLists(lists->protocols.begin(), lists->protocols.count, isPreattached, PrintPreopt ? "protocols" : nullptr); }; attach(&normalLists, false); }
void attachLists(List* const * addedLists, uint32_t addedCount, bool preoptimized, const char *logKind) { if (addedCount == 0) return; if (storage.isNull() && addedCount == 1) { storage.set(*addedLists); } else if (storage.isNull() || storage.template is<List *>()) { List *oldList = storage.template dyn_cast<List *>(); uint32_t oldCount = oldList ? 1 : 0; uint32_t newCount = oldCount + addedCount; array_t *array = (array_t *)malloc(array_t::byteSize(newCount)); storage.set(array); array->count = newCount;
if (oldList) array->lists[addedCount] = oldList; for (unsigned i = 0; i < addedCount; i++) array->lists[i] = addedLists[i]; } else if (array_t *array = storage.template dyn_cast<array_t *>()) { uint32_t oldCount = array->count; uint32_t newCount = oldCount + addedCount; array_t *newArray = (array_t *)malloc(array_t::byteSize(newCount)); newArray->count = newCount; for (int i = oldCount - 1; i >= 0; i--) newArray->lists[i + addedCount] = array->lists[i];
for (unsigned i = 0; i < addedCount; i++) newArray->lists[i] = addedLists[i]; free(array); storage.set(newArray);
} else if (auto *listList = storage.template dyn_cast<relative_list_list_t<List> *>()) { auto listListBegin = listList->beginLists(); uint32_t oldCount = listList->countLists(); uint32_t newCount = oldCount + addedCount; array_t *newArray = (array_t *)malloc(array_t::byteSize(newCount)); newArray->count = newCount; uint32_t i; for (i = 0; i < addedCount; i++) { newArray->lists[i] = addedLists[i]; } for (; i < newCount; i++) { newArray->lists[i] = *listListBegin; ++listListBegin; } storage.set(newArray); } }
|
从源码中不难发现,Category 中的数据(方法、属性、协议)都是在运行时通过 Runtime 动态添加到类中一个叫做 rwe 的对象中。
在 rwe 这个对象中拥有三个变量,分别是:方法列表、属性列表、协议列表,这个变量其实就是一个二维数组。以方法列表为例,类自身的所有方法是一个数组,每个 Category 中的所有方法是一个数组,它们都被放在这个二维数组中,注意,类自身的方法列表放在这个二维数组的最后面,最后编译的那个 Category 中的方法列表放在这个二维数组的最前面。
正是因为这个特点才导致了 Category 中的方法实现会覆盖与类本身同名的方法实现。所以,在开发过程中我们经常会看到很多框架都会给 Category 的方法和属性添加前缀,其目的就是为了降低重名的可能性。
有些人说 Category 不支持添加实例变量是因为 category_t
结构体中没有 ivars
字段。其实并不是添加一个字段的事,根本原因是因为开发者可能会用 Category 给已经编译好的类(例如系统类)添加数据,而这些类的内存布局与地址已经固定死了,如果要给它添加实例变量势必要修改其内存布局与地址。
另外,也不能给 Category 添加 weak 属性,如果一定要添加 weak 属性的话,可以采用中间者模式,即给 Category 添加一个中间者对象,然后给这个中间类声明一个 weak 属性。关于 weak 指针的更多细节请看我的另一篇文章 揭开 iOS 中 weak 指针的神秘面纱:从原理到实践
我在网上看到有些人说为什么要把 Category 设计成使用 Runtime 运行时加载,直接设计成编译时加载不是更好吗?他们的想法是:“在给项目中某个类(这个类是在项目中创建的),例如 CustomClass 创建分类时,编译器其实能拿到 CustomClass 的实现文件,那么只要把分类中的方法和这个类自身的方法合并不就行了,这样还能实现在 Category 中给这个类添加实例变量。” 乍一看没啥问题。但是,Category 还支持给已经编译好的类(例如系统类)添加方法实现,而这些类的布局和地址已经固定死了,因而不能这么干。
在阅读源码的过程中,我还发现了一些其它问题:
在前面的 test_category.m
文件中,我特意没有在实现中写上属性和协议的实现。因为经过我的调试发现,Runtime 在解析 Category 中的属性和协议时,只看声明并不看实现,只要有属性、协议声明,不管有没有实现都会被添加到类的属性列表和协议列表中。但是在解析方法的时候是反过来的,只会把有方法实现的那些方法添加到方法列表中。
在第1个函数 load_images 中有这么一个判断:如果该模块中没有 +load
方法实现就不添加 Category 数据。我查阅了许多资料,但是都没有找到可信的证据解释为什么要这样做?如果你知道为什么的话还请留言告知。
Runtime 会在 loadAllCategoriesIfNeeded
函数内一次性加载所有模块中的分类数据,而不是遍历一个模块加载一个模块中的分类数据。我想了一下,这么做有以下好处:降低程序的复杂度和提高性能。如果是遍历一个模块加载一个模块的数据,那就不能只使用一个全局变量 didInitialAttachCategories
来标记分类数据是否已加载?可能要维护一个字典,例如 key 是模块名称,value 表示该模块是否已加载分类。这么做显然比维护一个全局变量成本更高。
Category 与 Extension 的区别
经常有人把 Category 和 Extension 拿到一起来说,可能是因为它们的声明方式有点像吧,以下是 Category 和 Extension 的声明代码:
@interface Person (CategoryName) @end
@interface Person () @end
|
从代码来看,Category 似乎只多了一个 name 而已。所以导致很多人以为它们的底层实现可能差不多,但其实它们的实现压根不一样。
Extension 的特点
从功能和底层实现上来看,其实 Extension 和 Interface(类声明) 更像一些,Interface 能干的事,它基本上都能干,除了不能指定父类。
Interface 一般是用来对外提供接口数据,但有时候我们会想把一些属性、方法、实例变量隐藏起来。Extension 就是专门用来干这个的,因为 Interface 只能有一个,但 Extension 可以有多个。Extension 和类声明都是编译特性,你可以在 Extension 中声明实例变量、属性、方法、协议,这和在 Interface 中写本质上是一样的。
需要注意一点,虽然在 Extension 中可以声明实例变量,但仅在拥有 implementation 实现的这个文件中这么做才可以,例如以下代码就可以,因为这个文件中拥有 Book 的实现:
@interface Book () { NSString *_name; } @end
@implementation Book - (void)testFun { _name = @""; NSLog(@"%@", _name); } @end
|
例如下面的代码就不行,因为这个文件中没有 Book 类的实现,此时你会得到一个编译错误:
@interface Book () { NSString *_name; } @end
@implementation Book (CategoryName) - (void)testFun { _name = @""; NSLog(@"%@", _name); } @end
|
另外,虽然在任何地方都能使用 Extension,但是和上面的实例变量一样,如果需要编译器自动生成实现代码(例如属性),那就不能在 implementation 之外的文件使用,切记!!!
Extension 还有一个好用的功能就是声明私有方法,这样就能在后面的代码中直接调用这个方法了,而不是写成这样:[self performSelector:@selector(testFun)]
,网上有很多人是使用 Category 干这个事,其实 Extension 也可以,个人感觉这样更优雅。
Category 的特点
与 Extension 相比,Category 是编译器加上 Runtime 共同完成的。编译器负责将 Category 编译成 category_t
对象,然后添加到 Mach-O 文件中。Runtime 负责在运行时将 category_t
中的数据解析并添加到对应的类中。
总结
从苹果提供的源码中我们不难发现,其实 Category 的底层实现并不复杂,其本质就是将 Category 转化成一个结构体用来保存相关数据(属性、方法、协议),然后通过 Runtime 在运行时将这个结构体中的数据解析出来并且添加到类中。而这个类对象内部有一个二维数组来存储每个分类中的数据。