随着app size的日渐增加,大家都可能会有裁剪app size的需要。当然整块的砍掉使用率低的功能模块是最彻底也最简单的,但这会因为面临各种压力而难以执行……那么主要采用的就是一些常用技术方案:压缩图片、资源后下发、优化代码还有二进制重排列等。

还有一个重要的方向,就是删除无用的代码。不过这个方向在技术上还是有些难度的,因为OC这门语言的特殊性,很难使用静态检查的方法找出无用代码。那么就要借助在运行时实时分析的方案来做,分析的粒度大小主要分为函数粒度和类粒度。按照函数的粒度来分析,要么是在runtime里hook msg_send相关的入口,这样做对性能的影响蛮大;要么是深入了解底层原理后,在编译期做好插桩工作,这样做需要对相应底层技术有十分深入的理解。总之,要么很废,要么很难。所以我们这次利用runtime自带的特性,从类的粒度来分析一下类有没有被初始化过即可。

大致方案

很多技术博客里都提到了,ObjC类的meta class里,自带了一个标记自己有没有初始化过的flag。但是这些文章大部分都是点到为止,没有更深入的介绍具体的实践方法,今天我在这里就整理并提供一个可以实践的方案。

我们要做的就是利用runtime扫描一遍所有ObjC类的列表,然后再定期扫描这列表里所有的类有没有初始化过,获得大量数据后再进行分析,就可以得到初始化次数最少甚至为零的类啦。

听上去挺简单,但是具体有什么难点要实践了才知道,那就开始吧。

当然如果你不想看下面的长篇大论,可以直接看已经放在GitHub的开源库:SLMClassCoverage

扫描ObjC类的列表

扫描所有的ObjC类其实挺简单的,一句objc_copyClassList就搞定了。但是扫描完立刻就发现了问题,一个几乎空白的demo工程,系统类的数量就已经成千上万了。

找了找思路,发现可以用[NSBundle bundleForClass:XXXClass]是不是[NSBundle mainBundle]来判断一个类是不是主bundle里的类。经过了一番尝试,方案可行是可行,但是又发现了新的问题,这个NSBundle是线程不安全的。扫描大量的类是个耗时操作,直接扔在主线程来做可不好。

好在有了思路就不难办,仔细想想runtime的class_getImageName也可以完成类似的效果,记录下主bundle对应的image再进行比对就可以了,实验了下效果也很不错。

整理完app内用户自有类列表之后,梳理数据的时候又发现一个小问题。因为ObjC这个语言是动态的,可以在运行时动态添加类,一些observer操作还有RAC操作容易产生一些动态类成为干扰数据,这就有点难办了。有一些这样的类的类名是下划线开头的,我就顺手把这些类给屏蔽了;其它一些难以判断的,就只能加一些保护防止崩溃了。(如果有什么好的idea欢迎讨论)

至此,我们把要扫描的类列表就能整理出来了,详细的代码可以参照上面的源代码。

判断类有没有被初始化过

ObjC runtime的开源代码真的是一个好东西,因为C系列语言指针的自由度,了解runtime的结构后基本上可以完成各种各样的骚操作。咳咳,当然骚操作不建议瞎用,除非你真的把它搞明白了,因为有时候骚操作会带来很可怕的副作用。

ObjC 类的结构主要是在objc-runtime-new.h这个文件里定义的,下面是这个文件最新两版开源代码的地址:objc4-787.1objc4-818.2

以后面这个最新版为例,先打开文件,找到下面的内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// objc_class 代码片段
struct objc_class : objc_object {
...
class_data_bits_t bits; // class_rw_t * plus custom rr/alloc flags
...
class_rw_t *data() const {
return bits.data();
}
...
bool isInitialized() {
return getMeta()->data()->flags & RW_INITIALIZED;
}
...
}

这个objc_classisInitialized就是我们要用到的方法了。但是很可惜,这些方法对我们app来说,都是不可见的。所以在这里我教一下大家,怎么分析出我们要用到的变量的偏移地址,直接用指针来搞定问题。

从方法的调用情况看到我们要先找到类的meta class,这个是个runtime里可见的方法,直接调用就能拿到。然后回到objc_class的头部几行:

22-170300

⚠️ 注意:为了简化我们的问题,我们只考虑64位架构处理器的情况。毕竟iOS在11.0以后就木有支持32位的CPU了,多研究也没有很大价值。

从之前那段objc_class 代码片段可以看出来,objc_class->data()方法取到的就是头部定义的这个bits变量里的data(),我们最终要找的就是怎么拿到这个值,也就是先要找到上面截图里横线上这个成员变量bits的内存偏移地址。

runtime源代码是用C++来写的,函数都不会占用类meta信息(或者说实例)的内存地址,只有成员变量会占用地址。什么意思呢?看下面的图:

22-171509

这个就是objc_class的父结构体,具体代码在objc-private.h里。以这个结构体为例,isa这个成员变量会占用类实例的头8个bytes,也就是64位CPU的一个指针的长度;而下面的这堆方法,在实例里面一点内存都不占用。然后看完整个结构体,只有这么一个成员变量,所以总长度也就8个bytes。

23-111407

回到这张图里,对于子结构体来说,是需要继承父结构体的成员变量的。所以objc_class在继承objc_object时,就带上了父结构体的8 bytes长度的isa指针变量。

objc_class内前面几行那几个函数禁用语句,不会产生内存地址的偏移,先不管它们。

superclass作为一个指针(对,Class类型实际上是一个指向结构体的指针),长度也是8 bytes。

然后我们发现一个不太认识的cache_t类型的变量cache,它占用了多少bytes我们就要展开看看了:

18-172136

首先说一下explicit_atomic<T>,它只是负责把模板里的类型包成atomic的,不会对变量占用的内存长度产生影响。

然后要说一下union,C++的老朋友了,联合体内部的成员取最长的一个内存长度,就是联合体的内存长度。

mask_t的定义在文件头就能找到,其实在64位CPU下就是一个uint32_t而已。

基于上面的介绍,可以推断出整个cache_t结构体的长度就是16 bytes(详情参照上图标记)。最后得到:

18-172720

接下来就是要分析bits.data()->flag的位置了,很凑巧的是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// flag 取法代码片段
struct class_data_bits_t {
friend objc_class;
// Values are the FAST_ flags above.
uintptr_t bits;
...
class_rw_t* data() const {
return (class_rw_t *)(bits & FAST_DATA_MASK);
}
...
}

struct class_rw_t {
// Be warned that Symbolication knows the layout of this structure.
uint32_t flags;
...
}

bitsflags都是两个结构体里的第一个成员变量,那么我们就不需要继续计算偏移了,留意下data()函数的具体取值方法即可。

最终我们从一个Class类型,取到它有没有被初始化过的代码大致如下:

1
2
3
4
5
6
7
Class metaCls = objc_getMetaClass(class_getName(cls));
if (metaCls) {
uint64_t *bits = (__bridge void *)metaCls + 32; // 在 metaClass 基地址上加上 32 bits 的偏移
uint32_t *data = (uint32_t *)(*bits & FAST_DATA_MASK); // 模拟 data() 函数取值
return (*data & RW_INITIALIZED); // 模拟 isInitialized() 函数最后一步与操作
}
return NO;

补充

这个isInitialized的取法完成了,但是它只是数据收集开始的第一步。

扫描类有没有被初始化过是个很快的操作,但是扫描出整个类列表的过程很慢,主要是class_getImageName很慢,所以我在开源库里做了一些简单的缓存策略。这点需要大家自己留意下,根据自己的需要自行修改。

后面基于不同项目的情况和需要,大家还得自己设计数据上传及分析的流程。

希望这篇文章对大家有帮助吧,今天就写到这里。