Android 包体的清道夫
一. 背景
随着业务需求不断地进行迭代,同时功能也在不断地变更,随之而来的就是无用功能没有被及时移除,从而造成安装包体积不断变大。
同时,我们所依赖的三方库,无论是内部库还是外部库,也会或多或少存在没有使用的代码和资源。依赖库越多,无用代码和资源也会越多。
二.安装包产物构成
Android 安装包的产物构成,主要是 Dex、Resource、Assets、Native Library。这次的无用代码和资源扫描,主要针对于 Dex 和 Resource。
三. 原理解析
1.概述
利用 ApkTool 对 Apk 里面的 dex、xml、resource.arsc 进行反编译,扫描并获取完整的类路径和完整的资源索引,再根据类与类、类与资源、资源与资源之间的依赖关系,构建有向依赖关系图。
Android 四大组件 和 Application 会在 AndroidManifest.xml 中进行注册和声明,即使没有注册声明的四大组件,也会与其他类相关联。因此 AndroidManifest.xml 作为 顶点。然后每个类,每个资源从自己开始,自下而上,做一次深度优先遍历。如果能顺着依赖关系图能遍历到该 顶点,则判定该类或资源为有用的。相反,则无用。
当然了,AndroidManifest.xml 是必需的 顶点,但不是唯一的 顶点。可以根据项目的具体情况,增加其它 顶点(也可以叫做白名单)
PS:以下的分析,暂且是以 AndroidManifest.xml 为唯一 顶点 去分析的。
2. Dex 转换成 Smali
将 apk 中的所有 dex 文件,利用 Apktool 将它们转换成 smali 文件,获取每一个 class(类),然后逐行读取该 class。使用正则表达式,匹配到每个类,每个资源。
在 smali 中,类名的表现形式是: Lcom/shey/dexresourcecleaner/MainActivity;
资源的表现形式有两种:1、索引值:0x7f000001; 2、R常量(最终也是索引值):R.drawable.ic_test;
匹配类名和资源时,一旦匹配上,就将 该类 或 资源 当做一个节点,并指向当前 class(当前 class 也是一个节点)。
如果当前 class 是 R类 时,需要对里面的资源索引常量值,进行拆分,为什么呢?
R类 里面全部都是资源索引值,那么这些索引值都会指向 R类
一旦 R类 指向了 Activity,那么里面所有资源都会被当做有用的资源。因此,需要对 R类 进行拆分,怎么拆分呢?把 R类 的每一个常量拆分成一个节点,如图:
如果你的项目中做了 R 内联,而且 R 文件全部被删除了,可以忽略上述 R 文件的处理。
动态加载类,比如 Class.forName、BaseDexClassLoader.loadClass 这两种方式可以通过类的路径去实现动态加载该类。动态加载类,同时也分为 静态 和 动态 的。动态 的只有实际运行时才能知道具体的值。但是 静态 的也可以在 smali 文件的 class 中,通过扫描常量字符串,去判断该字符串是不是类的路径。并将该类也当做一个类节点。
kotlin:
smali:
上述 smali 代码中:const-string v0, "com.shey.dexresourcecleaner.TestReflect",我们会把 com.shey.dexresourcecleaner.TestReflect 当做一个类节点,并指向当前 class 类。
3. 读取 resource.arsc
如果 apk 存在资源文件或者资源值,那 apk 解压后都会有一个 resource.asrc 文件,就是一个资源索引表。大家对 R 文件应该很熟悉,R 文件里都是静态常量值,值都是资源索引值。而 arsc 文件就是帮助我们根据索引值去寻找资源文件,或者资源具体值。
使用 apktool 解析 arsc 时,我们可以得到 ResPackage,一个包名对应一个 ResPackage,所以一般来说,基本都是一个 apk 只有一个 ResPackage
我们需要的是 ResResSpec
ResResSpec 在 ResPackage 里面是属于私有字段,并且我们只需要将一个索引值映射一个 ResResSpec 即可,不需要多余信息。
4. 反编译 xml 二进制文件
apk 中的 布局、动画等 xml 以及 AndroidManifest.xml 都是二进制文件,要想读取里面的类,以及资源索引值,需要对其进行反编译,解析成具有可读性的字符串文件。
根据上面读取 arsc 所获得的 ResResSpec,再遍历其中的 ResResource,如果是文件,且是 xml 文件,就反编译该 xml 文件。
开发过程中的 xml 文件:
反编译后的 xml 文件:
如果在布局 xml 中引用了其它的资源,它其实也是以索引值的形式存在。就这样读取每个 Element,如果是索引值,就构造一个节点,并指向该 xml 文件;同理,如果是包名+类型,也一样指向该 xml 文件。
5.遍历依赖图
分析完 dex 和 xml 后,已经能差不多构建完整的有向关系依赖图了。但是上述 dex 转 smali 时获取到的常量字符串类名、资源索引值,xml 中获取到的类名、资源索引值,需要进行有效性过滤。类名是否在 dex 中,资源索引值是否在 arsc 中。
在总的有向关系依赖图中,每个节点,自下而上,都往上做一次深度优先遍历,若遍历到 AndroidManifest.xml ,即该节点有用。相反,则无用。
四. 编译时删除
在分析完无用代码和资源后,会生成一份无用列表清单,分别是无用class,无用资源文件。那么在项目编译构建时,就需要进行删除,以达到减少包体的目的。
我们通过上面的过程,可以知道无用的 class 和 资源文件,不管是外部的依赖库,还是内部的依赖库。即便是 androidx,一旦发现存在无用的 class,也会进行该 class 的删除。
1.删除资源文件
Android 编译构建过程,都是一个个 Task 在执行,合并处理资源的任务是 processReleaseResource、processDebugResource。该任务的产物是 .ap_ 文件,其实它也就是一个 压缩文件,资源相关的文件全在里面,例如 xml、png、jpg、webp 等等。
在 process${变体}Resource 任务执行完毕后,将 .ap_ 文件解压,根据无用资源列表清单,对里面的文件做空文件替换,并重新压缩生成一个 .ap_ 文件。
2.删除 class
删除 class 在 gradle Transform 中进行删除,需要注意的是,输入文件有 jar、有 class 文件,需要分开处理。如果是 jar,类似上述处理 .ap_ 文件一样,解压后删除其中的 class,然后重新生成替换。而如果是 class 文件,删除即可。
五. 注意事项
无用代码资源的扫描,都是基于静态代码分析的,建立 类 与 索引值 之间的有向关系依赖图。但是 java 实际开发中,是存在通过类名去动态加载类的,比如: Class.forName()、BaseDexClassLoader.loadClass()、ServiceLoader.load(),同时,资源也是可以动态加载。native(即 so 库)也可以动态加载类。
类名同时也分为两种形式,一种是静态的,另一个种是动态的(比如 变量拼接)。静态的话,上面对 dex 的分析时,已经会自动处理类名的常量字符串。如果是动态的,就需要实际运行中才能知道其中的值了。
正因为类的动态加载机制,使得该扫描工具存在一定的风险。动态加载类的地方需要完全排除,才能完全避免风险。其实,也是类似于混淆场景,需要维护一份混淆规则列表。依赖库本身也是知道哪些类是不能混淆,并且是动态加载的,从而将这些类加入白名单。白名单上的类或资源,也将成为跟 AndroidManifest.xml 一样的 顶点。
六. 优化效果
应用的时候,包体优化了 13.5%。并与构建系统进行打通,实现了自动扫描和删除的处理。