一个不规范的 Category 写法导致的“血案”

2391次阅读  |  发布于4年以前

1.背景

项目前后两个版本,线上监控显示整体启动时间缩短了近 300ms,而且包体积也变小了 5M(提交到 App Store 的ipa包)。但是新版本没有大的需求插入,只是 bugfix 版本,启动阶段的代码也没有相关改动。为何会引起包体积和启动时间的变化呢?

使用 Instrument 多次跑耗时分析,发现两个版本启动阶段的 getMethodNoSuper_nolock() 函数的耗时的差异非常明显。关键是这个还是系统函数,看起来是消息发送阶段的耗时增加了。

2.继续分析

上面 Instrument 的方法耗时在设置隐藏系统库之后,对比发现没有明显的变化,说明不是启动阶段(didFinishLaunchingRootVC viewDidAppear)有大的改动导致的,这样一下子也没法定位到问题。所以只能比对版本间的代码,这里使用的是 Kaleidoscope

Kaleidoscope 具体配置可以参考 nico 之前的文章代码比对神器 Kaleidoscope[1]

两个版本对比下来,代码的主要变更点是几个 pod 库的更新以及不影响启动速度的其它业务代码变更。于是基于控制变量法,打算在 6.4.3 的代码基础上轮流更新单个 pod,同时在比对的时候发现一处非常怪异的变更。6.4.3版本 UIView+ConstraintHolder 这个分类的接口声明和实现都放在 .h 文件,而6.5.5版本将对应的实现移入到了新建的 .m 文件里,如下图

看起来非常可疑,于是首先在 6.4.3 基础上只更新了这个分类所在的 pod,然后跑 Instrument,发现 getMethodNoSuper_nolock() 耗时回到了和 6.5.5 相当的水平。然后进一步,把这个 pod 的变更只留下对 UIView+ConstraintHolder 分类的更改上,其他的更改暂时去除,继续跑 Instrument,发现耗时还是和 6.5.5 基本持平,这样基本上可以确认是这个分类的变更导致的问题了。但是为了严谨性,也轮流更新其它单个 pod,发现耗时和 6.4.3 持平,也就进一步肯定问题出在那个分类上。

3.合理猜测

3.1 包体积

由于这个分类会被主工程中很多地方 import,而实现直接写在头文件中,相当于头文件的内容直接 copy 到对应文件,相当于把对应符号也编译到了对应的 .o 中,导致 .o 变大,也就解释了为什么包体积会变大。

3.2 启动耗时

由于是 copy 到多个文件内,相当于生成了多个具有相同方法的分类,导致 UIView 的方法列表变长,进而导致在 UIView 消息发送过程中的查找时间变长,也就是上面 Instrument getMethodNoSuper_nolock 这个方法耗时增大的原因。

相信大家都知道调用对象的方法,最终都是转化成 objc_msgSend(或其变种),由于它是汇编实现的,最终间接调用到 lookUpImpOrForward,而 查看 runtime 源码可以看到 lookUpImpOrForward 的实现,可以看到在方法没有缓存时,会走到这个函数 getMethodNoSuper_nolock


IMP lookUpImpOrForward(Class cls, SEL sel, id inst,

bool initialize, bool cache, bool resolver)
{

IMP imp = nil

// 中间省略了很多有缓存或者类未实现或未初始化时的处理代码

// Try this class's method lists.

{

Method meth = getMethodNoSuper_nolock(cls, sel);

if (meth) {

log_and_fill_cache(cls, meth->imp, sel, inst, cls);

imp = meth->imp;

goto done;

}

}

// Try superclass caches and method lists.

{

unsigned attempts = unreasonableClassCount();

for (Class curClass = cls->superclass;

curClass != nil;

curClass = curClass->superclass)

{

// 中间省略了很多父类有缓存时的处理代码

// Superclass method list.

Method meth = getMethodNoSuper_nolock(curClass, sel);

if (meth) {

log_and_fill_cache(cls, meth->imp, sel, inst, curClass);

imp = meth->imp;

goto done;

}

}

}

// 省略消息转发处理代码

done:

runtimeLock.unlock();

return imp;

}

static method_t *

getMethodNoSuper_nolock(Class cls, SEL sel)

{

runtimeLock.assertLocked();

assert(cls->isRealized());

// fixme nil cls?

// fixme nil 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;

}

这里只看没有缓存的情况下,lookUpImpOrForward 会先从当前类的方法列表查对应方法,如果查到则填充,没有查到则继续查父类的方法列表,依次类推, getMethodNoSuper_nolock 其实就是遍历当前类的方法列表。

从这里我们可以看出,如果方法列表比较长,查找的耗时也会增加。由于我们是给 UIView 加了多个具有相同方法的分类,而启动时 UIView 及其子类几乎时刻都在接收消息,所以导致启动阶段耗时增加。其实时时刻刻 UIView 的消息发送过程都会有相应的耗时增加(无方法缓存的情况下)。尽管 runtime 会有方法缓存,但是缓存不会一股脑的递增,会有释放时机,否则内存肯定吃不消。关于 runtime 方法缓存以及消息发送细节可以查阅从源代码看 ObjC 中消息的发送[2]。下面引用了其中关于方法缓存的一段话:

在缓存翻倍的过程中,当前类全部的缓存都会被清空,Objective-C 出于性能的考虑不会将原有缓存的 bucket_t 拷贝到新初始化的内存中。

展开 Instrument 的调用栈也可以发现,6.4.3 很多 UIView 的方法调用都被列了出来。

4.Demo 验证 & 给出石锤

上面的猜想停留在理论层面,如何验证呢?由于工程比较大,编译会非常耗时,于是搞了个 Demo 来验证上面的猜想。

新建4个测试类,再加一个头文件,里面放上从项目里拷贝出来的 UIView+ConstraintHolder 对应的接口和实现代码,目录如下

4.1 包体积验证

在4个测试类 .m 中都导入 UIView 的分类头文件,发现导入前后,.app 体积增加了 12KB,测试类对应的 .o 每个增加 10KB(iPhone 11 模拟器)。

通过 Xcode 查看文件预处理后的样子,也证实了 import 确实是 copy 了对应 .h 的内容,如下图

同样我们还可通过 linkmap 来看 .o 中的符号,对比前后的 linkmap.txt,截取一部分如下

Test2 的符号在 import 了头文件之后,新增了分类中的那几个方法。

包体积增大的石锤已找到。

4.2 消息耗时

首先在那个 UIView 分类里添加 + (void)load 方法,然后跑起来发现 load 方法执行了 4 次,和上面提到的符号会在多个 .o 出现吻合。由于 Demo 引用的次数不多耗时不明显,直接通过在项目内获取 UIView 的方法列表来证实上面猜测,代码如下


- (void)printViewMethods {

unsigned int methodCount;

Method *methodList = class_copyMethodList([UIView Class], &methodCount);

for (NSInteger i = 0; i < methodCount; i++) {

Method method = methodList[i];

NSString *methodName = [NSString stringWithCString:sel_getName(method_getName(method)) encoding:NSUTF8StringEncoding];

NSLog(@"%@", methodName);

}

free(methodList);

NSLog(@"%@", @(methodCount));

}

输出来的结果真的很惊人,那个分类里的每个方法(共5个方法)在 log 里出现了 3165 次,也就是说项目中 UIView 的方法列表加长了 15820(=3164 * 5),而正常情况下其长度为 2118。通过上面的理论分析和这里的数据,可以得出:这样写真的非常影响 UIView 及其子类消息发送过程中的耗时。

5.结论及复盘

相信看到这里大家应该有一个清晰的结论了,就是:

@implementation 不要写在头文件,尤其是这个头文件可能被 import 到多个地方的情况下!!!@implementation 不要写在头文件,尤其是这个头文件可能被 import 到多个地方的情况下!!!@implementation 不要写在头文件,尤其是这个头文件可能被 import 到多个地方的情况下!!!

重要的是请说三次。否则会影响 ①包体积 ②消息发送时间(尤其是高频使用的类)。

回过头再来看 Demo,分类实现放在 .h 却被多个类引用的情况下编译器链接时是有给出警告的。

![](https://oss-cn-hangzhou.aliyuncs.com/codingsky/cdn/img/opt/1/cf6cfc06aedc63df825b281e319f72fd)
Xcode 警告

为啥项目中没有得到警告呢(而且我们还开启了把警告当错误处理)?是因为这个分类在 pod 当中,而我们在 podfile 中使用 inhibit_all_warnings! 屏蔽了所有 podwarning。同时如果直接把类的声明和实现写在 .h,如果只被一处引用的话,编译器不会警告也不会报错,但是超过一处引用编译器链接时就会报符号冲突的错误。

1、文章中使用的设备是 iPhoneX iOS13.3,Xcode 11.3.1。Demo 使用 iPhone11 模拟器编译。

2、关于 linkmap 的组成推荐阅读iOS APP可执行文件的组成[3]

3、文章中引用的 runtime(756.2) 源码来自objc-runtime[4]

4、关于 runtime 的更多文章,推荐这个系列[5]

参考资料

[1] 代码比对神器 Kaleidoscope: https://punmy.cn/2019/02/28/%E6%95%88%E7%8E%87%E7%A5%9E%E5%99%A8%20Kaleidoscope.html

[2] 从源代码看 ObjC 中消息的发送: https://draveness.me/message

[3] iOS APP可执行文件的组成: http://blog.cnbang.net/tech/2296/

[4] objc-runtime: https://github.com/RetVal/objc-runtime

[5] 系列: https://draveness.me/tag/Runtime

Copyright© 2013-2019

京ICP备2023019179号-2