(1个静态库文件动辄几百兆,在打包后它到底多大?接入或者更新一个三方库对包大小有多少影响?项目中有多少无用类?如果符号表丢失了如何日志符号化?
以上问题58的开源代码:基于Mach-O文件分析的开源工具WBBlades 可以帮你找到答案https://github.com/wuba/WBBlades/stargazers ,如果感觉有用的话帮忙star一下下)
一、为什么要做无用代码检测
58在前段时间通过无用图片扫描和无用图片线上监控实现了对APP资源的瘦身,并且取得了不错的效果。但是目前缺乏对代码进行瘦身的技术手段。并且从分析结果来看,目前58APP的代码占比也是相当庞大的。以安居客、租房、招聘、二手车、黄页5个业务线的为例,arm64架构的代码占比与3x图经过assert压缩后的数据如下:
从数据可以看出代码的占比还是很高的。因此可以说明无用代码检测是能带来一定收益的。
二、业界有哪些技术方案
目前主流的技术方案有四种:
1、脚本分析源代码。
2、基于linkmap+clang的检测技术。
3、针对framework目标文件优化的技术方案。
4、基于otool+mach-o的检测技术。
方案1:
此方案是最容易想到的方案,但是存在一定的不可行性。脚本如何处理空格、换行、注释、子串、分类等问题存在一定的难度。
方案2:
方案2的原理是通过linkmap文件获取到项目中的类集合,通过在Xcode上集成自定义的Clang插件,在代码编译时通过拦截遍历抽象语法树的VisitDecl函数和VisitStmt函数,可以获取到在哪个方法中有哪些表达式,即可得知哪些方法中调用了哪些函数。
方案3:
方案3原理是,通过脚本剥离动态库中的目标文件,生成新的framework进行链接,如果不报错则可认为该文件没有被使用。
方案4:
方案4的原理是通过otool命令提取可执行文件的classlist section 和 classref section。形成classlist和classref的地址差集。在通过otool 命令获取到地址和类名的映射关系,解析获取差集中的类名。方案4的优点是方便简单,运行速度极快。方案的缺点是存在一定的误差,像58这样的大型项目中存在数以万计的类和数十万的方法数,误差率会影响一定的可用性。
三、58APP的技术方案
1、方案分析
实际上上述4个方案也是对应着编译前->编译期间->链接->链接后4个阶段,之所以这四个阶段都可以进行无用代码检测,其本质是由于无论哪个阶段,他们对应的程序都是同一个。只不过在这四个阶段中程序的熵值在不断降低,代码的规则性和确定性在不断增强。例如方案2与方案1相比,方案二的代码在经过编译器进行词法分析和语法分析后,无需再关注空格、换行、注释等逻辑,只需将重心关注在有特定规则的字符串上,因此更加具备可行性。方案4与方案2相比,在经过链接器的链接后,程序对自身定义的类和系统定义的类做了明确的划分,并且程序自身已经记录了自身一共有多少类,哪些类是被引用的类。因此方案4与方案2相比更加高效。在技术方案的选型上,我们更倾向于针对熵值更低的可执行文件进行分析。这一点与方案4接近。方案4采用的是通过otool命令获取Mach-O文件的信息,使用otool的好处是方便,对文件的解析命令已经处理好,使用成本较低。缺点是需要对文本文件进行分析,在面对各个数据复杂的寻址跳转时不太方便。对结构复杂的数据进行解析时尽量避免对文本文件进行分析,避免因为读取文本格式的问题导致数据解析失败。因此58APP的整体方式是:通过读取应用程序可执行文件,按Mach-O格式解析二进制文件,获取二进制文件的各个段信息,从而避免了对文本的处理,将程序中的数据以结构体或对象的形式读取。在上文介绍方案4时简单提到了方案4的结果会存在误差,那到底哪里存在误差呢?这种误差在58中会不会被放大呢?首先先来介绍下classlist section 和 classref section。
classlist 存放的是类的指针集合,集合中只包含在代码中自己定义的类,系统类(如NSObject)并不在classlist中,arm64架构下每个类的指针长度为8字节。
指针指向位于objc_data section的Class64 结构体。
struct class64 {
unsigned long long isa;
unsigned long long superClass;
unsigned long long cache;
unsigned long long vtable;
unsigned long long data;
};
class64 的data指针指向真正的位于__objc_const section 的class64 info结构体。注意不是isa指针,isa记录的是元类的地址,如果想查找类方法则需要通过isa找到元类,并查找元类的method list。
struct class64Info {
unsigned int flags;
unsigned int instanceStart;
unsigned int instanceSize;
unsigned int reserved;
unsigned long long instanceVarLayout;
unsigned long long name;
unsigned long long baseMethods;
unsigned long long baseProtocols;
unsigned long long instanceVariables;
unsigned long long weakInstanceVariables;
unsigned long long baseProperties;
};
因此只要遍历classlist 即可间接获取其所指向的类的一切信息,包括名称、成员变量、方法列表等。因此,classlist 是我们所需要的类的集合。
回头在看classref,classref的数据结构与classlist 一样,也是存放指向位于objc_data section的Class64 结构体。但是classref存在以下问题:
(1)、动态调用的类并不会被加入到classref中。
(2)、作为基类、作为属性、成员变量不会被加入到classref。
(3)、调用了load方法,将自身注册到其他数据中(如RN的module注册)不会被放入到classref中。
(4)、自己类调用自己类,但是外界并没有调用这个类,这个类也被放入classref中。
基于以上4点,classref 中有的类是没被使用的但是被添加到ref中,有的类应该被放入ref中,但是没有放入。因此classlist-classref会存在一定程度的偏差,这个偏差与当前项目的代码结构有较强的关联。如果动态调用较多、RN的代码较多、无用类自身可能存在很多类方法调用,那么这种偏差会被放大。
2、如何解决
针对问题(1),解决方式为将__cfstring section 提取出来,__cfstring存放的是字符串,假如某个类名在__cfstring中能查找到,则可认为该类会被动态调用。当然这种情况可能存在一定的误差,但是能避免58同城中大部分在字典中注册类名的情况。
针对问题(2),解决方式为在遍历classlist时,查找其父类信息,并将父类添加到引用集合中。并查找成员变量的列表。
成员变量列表中,type即为成员变量的类型地址,读取字符串后即为类型名,并将同名的类加入到被引用类的集合中。
针对问题(3),只需要读取__nlclslist section 即可,__nlclslist section存放的是实现了load方法的类的地址,因此需要按上面的方式读取类的名称,并将类名加入到被引用的类的集合中。
问题4的解决方式比较困难,思路有两种:1、借助clang 在编译时获取当前类调用的所有类,如果调用的类与当前类不同,则标记被调用的类为真正被用到的类。2、借助Mach-O的符号表确定所有类的函数范围,并遍历汇编指令查看要确定的类的地址在其他类的函数返回中是否存在,如果存在则认为该类被调用。如果采用思路1,则以为这存在要临时切换技术栈,并且代码量比较庞大的情况下,文本分析的可能比较困难,并且还需要剔除系统类的影响。如果采用思路2,则需要解决两个技术问题:
(1)、符号表能够告诉我们每个类、每个函数的起始地址,但是我们怎么才能知道这个函数在哪里结束?
(2)、在二进制文件中,函数都是4字节的汇编指令集合,我们怎么才能知道这段函数中是否调用了某个class?
现代计算机并未脱离冯诺依曼型计算机的工作原理,所有的指令都是按地址顺序执行,因此函数都是连续的并且无论有多少if else 多少return,函数只存在一个ret指令位于函数指令结尾。因此可以认为当遍历指令时,遇到ret则认为函数结束。那么如何确定类是否被引用呢?引用类会将类的地址放入寄存器中,类的地址是classref中记录的地址,并且,如果类的地址如果低12位为0,则会将类地址一次性放入寄存器。如果低12位不为0,则会先将高位存入寄存器中,再将低12位存入寄存器中,经过运算加法形成真正的地址。举个例子,classref中有类WBAAA,地址为0x100008eb8
因此简单来看,就是在函数范围内,先查看是否直接命中目标函数地址,如果命中则认为类被调用,如果没有直接命中则查看是否先命中高位,再命中低位,如果都命中,则认为类被调用。细心的同学可能会发现,假设存在高位命中了,但是低位被其他地址命中了,则会存在被误报的情况。这个问题确实是存在的,但是不用担心,目前的无用代码检测工具都无法一次性检测出所有的无用类,当删除部门无用类后,二进制文件重新打包会引起地址变化,原先误报的情况可能就不存在了。
在二进制中,存放的都是4字节指令,如何将指令转为助记符呢?这就需要借助反汇编引擎,在业界反汇编引擎中比较出色的是capstone,其优点在于指令解析比较完善其次是支持架构比较完善。缺点在于性能较差,在detail 模式下,58同城2800万指令反汇编需要30GB内存。
3、效率问题
工具运行检测58全部代码,需要2小时20分钟。起初运行一次需要57小时,因此对工具进行优化,通过工具查看耗时主要存在于反汇编阶段,因此采取的方式是一次性对全部指令进行反汇编,保存结果不释放,以空间换时间。即使如此,工具运行一遍仍然需要3小时10分钟,因此查看代码发现耗时主要存在于对OC字符串的处理上,之前为了字符串处理方便将C字符串都转换为OC字符串借助OC代码处理,因此将核心代码转换为C函数,减少字符串转换过程,因此运行一遍能达到2小时20分钟。为了能够方便查看各个业务线各自的无用代码,工具支持按静态库未读进行检测扫描,以比较庞大的安居客静态库集合为例,输入多个静态库,工具会提取静态库中的所有类,并在工具中针对性检测这些类是否有用。结果显示安居客的代码扫描一遍需要半小时左右,libHouse 单个静态库的类仅需要14分钟,其他业务线可能耗时更少。内存方面,我们没有选择detail 模式进行反汇编,因此需要6G 内存,内存的主要开销在于反汇编的
cs_insn结构体,2800万指令对应2800万的结构体,因此需要对结构体进行优化,不必要的成员可以剔除,在只保留助记符和操作码外加一个指针的情况下,内存消耗能减小到4G。
四、数据结果展示
只检测WB、AJK、AIF、HS、UC、YP等58业务代码特征的类,共产出数据1280条。抽查了30个数据,其中1个类为plist注册了跳转协议,可以认为有用外,其他29个类没有调用的地方。