Android安装包瘦身详解

2,431 阅读15分钟

本文转自Android安装包瘦身详解

前言

前段时间跟我朋友讨论起安装包的瘦身问题,故此打算一篇结合过往项目经验安装包瘦身的总结,分享一些了解到的技巧. 不少朋友反映在wifi相当普遍的今天,而且5G都快要落地了,是否还有必要为安装包瘦身呢? 我的答案是肯定的.

谷歌在2018的IO大会上,透露了一张谷歌play上安装包体积和下载转化率的关系图:

转化率

从上面图中不难看出体积越小转化率越高.试想下当用户想下载你APP的时候,即使点了,也有可能因为网络缓慢而反悔取消.但如果你APP体积小,可能用户还在犹豫的时候已经下载好了.

推广成本

安装包的体积跟地推成本和手机厂商的预安装费都有着直接影响.特别是厂商预装,这主要是因为厂商留给 预装应用的总空间是有限的.如果你的包体积非常大,那就会影响厂商预装其他应用.

应用市场

不少应用市场对安装包的体积是有限制的,以谷歌play为例,它就对超过100M的应用上传方式做了限制,原因是包体积过大会对应用市场的服务器带宽成本造成压力

应用性能

安装包体积除了以上有影响外,对应用的性能也有这直接的影响.

  • 安装时间: 文件拷⻉、Library解压、编译ODEX、签名校验,特别对于Android 5、6系统来说,如果你的APP有多个Dex,那光是编译ODEX的时间可能长达几分钟.
  • 运行内存: 项目中的Resource资源、Library以及Dex类不作优化的加载到内存时,这些都会占用不少空间.
  • ROM空间: 如果应用的APK达到了100多MB那么解压出来后就可能接近300MB了,对于几年前的16G千元机来说,压力是相当大的, 很可能因为闪存不足造成写入放大

综上所述安装包的体积也是项目的一个重要技术指标,那该如何为应用瘦身呢?

安装包体积优化

安装包就是Dex、Resource、Assets、Library以及签名信息这五部分,下面我们就由浅入深来一一探讨

资源优化
1. SVG 配合 Tint 替换icon图片资源套图

  • 先从icon入手,在实际项目中一般可以让UI把png jpg的icon换成SVG格式的矢量图,然后再用AS转换下格式可以直接在Android上跑了.这样就可以用一个SVG文件即可代替多个不同分别率的图片资源.
    详细使用可以参考Android SVGSVG图片下载地址

  • 但如果同一个icon不同颜色怎么办呢?例如点击效果、不同业务状态等情况 这时候只需要配合Tint属性使用即可.不用多个图片资源文件
    详细使用可以参考SVG-Android开源库——图片颜色Manage、Tint及Selector扩展

2. Webp 和 图片压缩

3. 去除多余的国际化代码

  • 一般第三方包都会带上比较多的国际化配置,而我们的应用往往只需要在国内跑或者适配中英文即可.其他资源的配置就成多余的代码.这时候我们就应该通过gradle配置把多余的国际化配置去掉
    详细参考使用resConfigs去除无用语言资源

4. NDK库兼容

  • 一般NDK库都会分了多个包 如arm64-v8a、armeabi-v7a、armeabi、x86、mips64、mip等.其实大部分手机项目只需兼容armeabi系列的包,而NDK库一般还会向下兼容.
    如armeabi-v7a设备兼容armeabi-v7a、armeabi
    所以正常情况只保留armeabi-v7a即可.不过最好咨询下SDK的技术人员再作配置.
    详细参考so库兼容

5. 去除无用资源

  • 第一阶段:Lint

从Eclipse开始,就开发工具就支持Lint这个静态代码扫描工具,它里面就支持Unused Resources扫描.然后我们直接选择"Remove All Unused Resources",就可以轻松删除无用资源。既然它是第一阶段方案,那Lint 方案扫描缺点是什么呢? Lint作为一个静态扫描工具,它最大的问题在于没有考虑到ProGuard的代码裁剪。在ProGuard过程我们会shrink掉大量的无用 代码,但是Lint工具并不能检查出这些无用代码所引用的无用资源.
可以参考美团外卖Android Lint代码检查实践

  • 第二阶段:shrinkResources

所以Android在第二阶段增加了“shrinkResources”资源压缩功能,它需要配合ProGurad的“minifyEnabled”功能同时使用. 如果ProGuard把部分无用代码移除,这些代码所引用的资源也会被标记为无用资源,然后通过资源压缩功能将它们移除.

android {
    ...
    buildTypes {
        release {
            shrinkResources true
            minifyEnabled true
        }
    }
}

看起来不错,但是目前的shrinkResources实现起来还有几个缺陷.

  • 没有处理resources.arsc文件: 这样导致大量无用的String、ID、Attr、Dimen等资源并没有被删除.

  • 没有真正删除资源文件: 对于Drawable、Layout这些无用资源,shrinkResources也没有真正把它们删掉,而是仅仅替换为 一个空文件.

所以尽管我们的应用有大量的无用资源,但是系统目前的做法并没有真正减少文件数量。这样resources.arsc、签名信息以及 ZIP文件信息这几个“大头”依然没有任何改善. 那为什么Studio不把这些资源真正删掉呢?事实上Android也知道有这个问题,在它的核心实现ResourceUsageAnalyzer中的 注释也写得非常清楚,并尝试解决这个问题提供了思路.

<上图翻译>我们是否支持运行aapt两次,以重新生成resources.arsc文件 这样我们也可以剥离有价值的资源.我们还没有这样做,因为 ShrinkResources任务中详细说明的原因 我们有两种选择: (1)将资源文件复制到新的目标目录,过滤掉 删除文件资源并通过剥离重写有价值的资源文件 移除的值资源的声明。然后我们在这上面重新运行aapt 新的目标目录。

详细使用参考压缩、混淆和优化您的应用

  • 第三阶段:去除真实无用资源资源

那怎么样才能真正实现无用资源的删除功能呢?ResourceUsageAnalyzer的注释中就提供了一个思路,我们可以利用 resources.arsc中Public ID的机制,实现非连续的资源ID. 简单来说,就是keep住保留资源的ID,保证已经编译完的代码可以正常找到对应的资源.

但是重写resources.arsc的方法会比资源混淆更加复杂,我们既要从这个文件中抹去所有的无用资源相关信息,还要keep住所 有保留资源的ID,相当于把整个文件都重写了. 正因为异常复杂,所以目前Android还没有提供这套方案的完整实现.也还没有相关实现的文章,算是实验室方案.

代码优化

1.混淆

今天ProGuard已经是安卓中很常用的工具了,ProGuard的核心优化主要有三个:Shrink、Optimize和Obfuscate,也就是裁剪、优化和混淆.但是十个ProGuard配置九个有坑,特别是各种第三方SDK。我们需要仔细检查最终合并的ProGuard配置文件,是不是存在过度 keep的现象. 你可以通过下面的方法输出ProGuard的最终配置,尤其需要注意各种的keep *,很多情况下我们只需要keep其中的某个包、 某个方法,或者是类名就可以了

-printconfiguration configuration.txt

详细使用参考ProGuard 在 Android 上的使用姿势

除了ProGuard国内鹅厂也开源了一个神器工具AndResGuard,它主要有两个功能,一个是资源 混淆,一个是资源的极限压缩.
先说资源混淆,ProGuard的核心优化主要有三个:Shrink、Optimize和Obfuscate,也就是裁剪、优化和混淆.AndResGuard也有类似的实现, 资源混淆的思路其实非常简单,就是把资源和文件的名字混淆成短路径:

Proguard -> Resource Proguard
R.string.name -> R.string.a 
res/drawable/icon -> res/s/a

这样实现对资源文件有优化作用:

  • resources.arsc: 因为资源索引文件resources.arsc需要记录资源文件的名称与路径,使用混淆后的短路径res/s/a,可以减少整个文件的大小.
  • metadata签名文件: 签名文件MF与SF都需要记录所有文件的路径以及它们的哈希值,使用短路径可以减少这两个文件的大小.
  • ZIP文件索引: ZIP文件格式里面也需要记录每个文件Entry的路径、压缩算法、CRC、文件大小等信息。使用短路径,本身 就可以减少记录文件路径的字符串大小.

然后是压缩,AndResGuard的另外一个优化就是极限压缩,它的极限压缩功能体现在两个方面:

  • 更高的压缩率: 虽然我们使用的还是Zip算法,但是利用了7-Zip的大字典优化,APK的整体压缩率可以提升3%左右.
  • 压缩更多的文件: Android编译过程中,下面这些格式的文件会指定不压缩;在AndResGuard中,我们支持针对 resources.arsc、PNG、JPG以及GIF等文件的强制压缩.

详细使用参考APP瘦身大法

2.去掉Debug信息或者去掉行号

某个应用通过相同的ProGuard规则生成一个Debug包和Release包,其中Debug包的大小是4MB,Release包只有3.5MB。 既然它们ProGuard的混淆与优化的规则是一样的,那它们之间的差异在哪里呢?那就是DebugItem。

DebugItem里面主要包含两种信息: 调试的信息.函数的参数变量和所有的局部变量. 排查问题的信息。所有的指令集行号和源文件行号的对应关系. 事实上,在ProGuard配置中一般我们也会通过下面的方式保留行号信息.

-keepattributes SourceFile, LineNumberTable

对于去除debuginfo以及行号信息更详细的分析,推荐你认真看一下支付宝的一篇文章《Android包大小极致压缩》。通过这个 方法,我们可以实现既保留行号,但是又可以减少大约5%的Dex体积.

3.类重排

当我们在Android Studio查看一个APK的时候,不知你是否知道下图中“defines 19272 methods”和“references 40229 methods”的区别.

关于Dex的格式以及各个字段的定义,你可以参考《Dex文件格式详解》。为了加深对Dex格式的理解,推荐你使用 010Editor。

“define classes and methods”是指真正在这个Dex中定义的类以及它们的方法。而“reference methods”指的是define methods 以及define methods引用到的方法。 简单来说,如下图所示如果将Class A与Class B分别编译到不同的Dex中,由于method a调用了method b,所以在 classes2.dex中也需要加上method b的id.
因为跨Dex调用造成的这些冗余信息,会造成method id爆表。我们都知道每个Dex的method id需要小于65536,因为method id的大量冗余导致每个Dex真正可以放的 Class变少,这是造成最终编译的Dex数量增多。还会使信息冗余。因为我们需要记录跨Dex调用的方法的详细信息,所以在classes2.dex我们还需要记录Class B以及method b的 定义,造成string_ids、type_ids、proto_ids这几部分信息的冗余。那如何实现Dex信息有效率提升呢?
这时候我们可以使用Redex工具对dex文件进行类重排将有调用关系的类和方法分配到同一个Dex中,即减少跨Dex的调用的情况.
当然我们也可以 通过打印ClassLoader的类加载信息,获取到加载顺序然后用dx.bat自己重新生成dex文件. 但是由于类的调用关系非常复杂,我们不太可能可以计算出最优解,只能得到局部的最优解。建议还是使用Redex,ReDex在分析类的调用关系后,使用的 是贪心算法计算局部最优值,具体算法可查看CrossDexDefMinimizer
除了类重排外还Redex还有不少优化,详细了解可以参考基于 Facebook Redex 实现 Android APK 的压缩和优化

4.Dex压缩
我曾经反编译Facebook的APP,发现它竟然只有700多KB的dex文件,要知道谷歌play可是不允许动态下发代码的,那它究竟把代码放到哪里呢?

从上图我们可以看出代码很可能就放在assets文件夹下,而事实正是如此.Facebook App的classes.dex只是一个壳,真正的代码都放到assets下面.它们把所有的Dex都合并成同一个 secondary.dex.jar.xzs文件,并通过XZ压缩.

XZ压缩算法和7-Zip一样,内部使用的都是LZMA算法。对于Dex格式来说,XZ的压缩率可以比Zip高30%左右。但是不知道你 有没有注意到,这套方案似乎存在一些问题:

  • 首次启动时候解压时间长: 应用首次启动的时候,需要将secondary.dex.jar.xzs解压缩,根据上图的配置信息,应该一共有11个Dex。 Facebook使用多线程解压的方式,这个耗时在高端机是几百毫秒左右,在低端机可能需要3~5秒。这里为什么不采用 Zstandard或者Brotli呢?主要是压缩率与解压速度的权衡。
  • ODEX文件生成时间长: 前面我就讲过,当Dex非常多的时候会增加应用的安装时间。对于Facebook的这个做法,首次生成ODEX 的时间可能就会达到分钟级别。Facebook为了解决这个问题,使用了ReDex另外一个超级硬核的方法,那就是oatmeal

oatmeal的原理非常简单,就是根据ODEX文件的格式,自己生成一个ODEX文件。它生成的结果跟解释执行的ODEX一样,内部是没有机器码的.

如上图所见,对于正常的流程,我们需要fork进程来生成dex2oat,这个耗时一般都比较大。如果通过oatmeal,我们直接在本进程 生成ODEX文件。一个10MB的Dex,如果在Android 5.0生成一个ODEX的耗时大约在10秒以上,在Android 8.0使用speed模 式大约在1秒左右,而通过oatmeal这个耗时大约在100毫秒左右.但是oatmeal是需要分版本适配的,每个版本ODEX格式都有一些差异,大家使用的时候要多加注意.

5. Library优化

Library压缩: 跟Dex的压缩一样,Library优化最有效果的方法也是使用XZ或者7-Zip压缩。在默认的lib目录,我们只加载少数启动过程相关的Library,其他的Library就在首次启动时再解压。对于Library格式来 说,压缩率同样可以比Zip高30%左右,效果相当理想. Facebook有一个So加载的开源库SoLoader,它可以跟这套方案配合使用。和Dex压缩一样,压缩方案的主要缺点在于首次启 动的时间,毕竟对于低端机来说,多线程的意义并不大,因此我们要在包体积和用户体验之间做好平衡.

对于Native Library,Facebook中的编译构建工具Buck也有两个比较硬核的高科技。当然在官方文档中是完全找不到的,它们 都隐藏在源码.

  • Library合并: 在Android 4.3之前,进程加载的Library数量是有限制的。在编译过程,我们可以自动将部分Library合并成一个。具体思路可以参考《Android native library merging》
  • Library裁剪: Buck里面有一个relinker的功能,原理就是分析代码中JNI方法以及不同Library的方法调用,找到没有无用的 导出symbol,将它们删掉。这样linker在编译的时候也会把对应的无用代码同时删掉,这个方法相当于实现了Library的 ProGuard Shrinking功能.

总结


整理此文的时候,我感觉Android系统本身也存在不完善的地方,也正因为有这些不完善的地方,才有了现在百花齐放的开源方案.只有敢于突然系统限制,往往有意想不到到收获,同时我深感国内外大厂的优化手段层出不穷.

参考资料


How to reduce APK size in android

Android App包瘦身优化实践