---
title: "一个不规范的 Category 写法导致的“血案”"
description: "线上包体积少了 5M、启动快了 300ms，原因却是一个不规范的 Category 写法。用 Instruments 对比 getMethodNoSuper_nolock 耗时，逐步定位到消息发送阶段的膨胀。"
date: "2020-04-20"
tags: ["Crash"]
canonical: "https://vonglo.cn/posts/attention-to-category/"
license: "CC BY-NC-SA 4.0"
licenseUrl: "https://creativecommons.org/licenses/by-nc-sa/4.0/"
---
# 一个不规范的 Category 写法导致的“血案”

> 线上包体积少了 5M、启动快了 300ms，原因却是一个不规范的 Category 写法。用 Instruments 对比 getMethodNoSuper_nolock 耗时，逐步定位到消息发送阶段的膨胀。

名字取得有点标题党，不然没人愿意点进来😂😂😂

## 1.背景

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

![启动耗时&包体积变化](/media/Attention-to-Category/Attention-to-Category-01-15839096586934.jpg)

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

![Instrument 启动阶段耗时对比](/media/Attention-to-Category/Attention-to-Category-02-15839137733724.jpg)

## 2.继续分析

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

> `Kaleidoscope` 具体配置可以参考 nico 之前的文章[代码比对神器 Kaleidoscope](https://punmy.cn/2019/02/28/%E6%95%88%E7%8E%87%E7%A5%9E%E5%99%A8%20Kaleidoscope.html)。

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

![代码比对](/media/Attention-to-Category/Attention-to-Category-03-15839097887060.jpg)

看起来非常可疑，于是首先在 `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`。

<details>
<summary>📑 点击查看 `runtime` 源码</summary>

```cpp
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;
}
```
</details>

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

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

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

![调用层级](/media/Attention-to-Category/Attention-to-Category-04-15839148461085.jpg)

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

## 4.Demo 验证 & 给出石锤

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

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

![目录](/media/Attention-to-Category/Attention-to-Category-05-15839215574196.jpg)

### 4.1 包体积验证

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

![体积对比](/media/Attention-to-Category/Attention-to-Category-06-15839343247169.jpg)

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

![预处理](/media/Attention-to-Category/Attention-to-Category-07-15839334801466.jpg)

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

![linkmap](/media/Attention-to-Category/Attention-to-Category-08-15839344765704.jpg)

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

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

### 4.2 消息耗时

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

```objc
- (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` 却被多个类引用的情况下编译器链接时是有给出警告的。

![Xcode 警告](/media/Attention-to-Category/Attention-to-Category-09-15839361693286.jpg)

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

![Xcode 报错](/media/Attention-to-Category/Attention-to-Category-10-15839360983965.jpg)

> 1、文章中使用的设备是 iPhoneX iOS13.3,Xcode 11.3.1。Demo 使用 iPhone11 模拟器编译。
> 2、关于 linkmap 的组成推荐阅读[iOS APP可执行文件的组成](http://blog.cnbang.net/tech/2296/)
> 3、文章中引用的 `runtime(756.2)` 源码来自[objc-runtime](https://github.com/RetVal/objc-runtime)
> 4、关于 runtime 的更多文章，推荐这个[系列](https://draveness.me/tag/Runtime)
---

Canonical: https://vonglo.cn/posts/attention-to-category/
License: [CC BY-NC-SA 4.0](https://creativecommons.org/licenses/by-nc-sa/4.0/)
Author: Vong
