怎么判断一个OC类有没有被初始化过
随着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.1,objc4-818.2
以后面这个最新版为例,先打开文件,找到下面的内容:
1 | // objc_class 代码片段 |
这个objc_class
的isInitialized
就是我们要用到的方法了。但是很可惜,这些方法对我们app来说,都是不可见的。所以在这里我教一下大家,怎么分析出我们要用到的变量的偏移地址,直接用指针来搞定问题。
从方法的调用情况看到我们要先找到类的meta class,这个是个runtime里可见的方法,直接调用就能拿到。然后回到objc_class
的头部几行:
⚠️ 注意:为了简化我们的问题,我们只考虑64位架构处理器的情况。毕竟iOS在11.0以后就木有支持32位的CPU了,多研究也没有很大价值。
从之前那段objc_class 代码片段
可以看出来,objc_class->data()
方法取到的就是头部定义的这个bits
变量里的data()
,我们最终要找的就是怎么拿到这个值,也就是先要找到上面截图里横线上这个成员变量bits
的内存偏移地址。
runtime源代码是用C++来写的,函数都不会占用类meta信息(或者说实例)的内存地址,只有成员变量会占用地址。什么意思呢?看下面的图:
这个就是objc_class
的父结构体,具体代码在objc-private.h里。以这个结构体为例,isa
这个成员变量会占用类实例的头8个bytes,也就是64位CPU的一个指针的长度;而下面的这堆方法,在实例里面一点内存都不占用。然后看完整个结构体,只有这么一个成员变量,所以总长度也就8个bytes。
回到这张图里,对于子结构体来说,是需要继承父结构体的成员变量的。所以objc_class
在继承objc_object
时,就带上了父结构体的8 bytes长度的isa
指针变量。
objc_class
内前面几行那几个函数禁用语句,不会产生内存地址的偏移,先不管它们。
superclass
作为一个指针(对,Class
类型实际上是一个指向结构体的指针),长度也是8 bytes。
然后我们发现一个不太认识的cache_t
类型的变量cache
,它占用了多少bytes我们就要展开看看了:
首先说一下explicit_atomic<T>
,它只是负责把模板里的类型包成atomic的,不会对变量占用的内存长度产生影响。
然后要说一下union
,C++的老朋友了,联合体内部的成员取最长的一个内存长度,就是联合体的内存长度。
mask_t
的定义在文件头就能找到,其实在64位CPU下就是一个uint32_t
而已。
基于上面的介绍,可以推断出整个cache_t
结构体的长度就是16 bytes(详情参照上图标记)。最后得到:
接下来就是要分析bits.data()->flag
的位置了,很凑巧的是:
1 | // flag 取法代码片段 |
bits
和flags
都是两个结构体里的第一个成员变量,那么我们就不需要继续计算偏移了,留意下data()
函数的具体取值方法即可。
最终我们从一个Class类型,取到它有没有被初始化过的代码大致如下:
1 | Class metaCls = objc_getMetaClass(class_getName(cls)); |
补充
这个isInitialized
的取法完成了,但是它只是数据收集开始的第一步。
扫描类有没有被初始化过是个很快的操作,但是扫描出整个类列表的过程很慢,主要是class_getImageName
很慢,所以我在开源库里做了一些简单的缓存策略。这点需要大家自己留意下,根据自己的需要自行修改。
后面基于不同项目的情况和需要,大家还得自己设计数据上传及分析的流程。
希望这篇文章对大家有帮助吧,今天就写到这里。