You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

310 lines
22 KiB
Markdown

2 years ago
# 22 | 包体积优化(上):如何减少安装包大小?
曾经在15年的时候我在WeMobileDev公众号就写过一篇文章[《Android安装包相关知识汇总》](https://mp.weixin.qq.com/s/QRIy_apwqAaL2pM8a_lRUQ),也开源了一个不少同学都使用过的资源混淆工具[AndResGuard](https://mp.weixin.qq.com/s/6YUJlGmhf1-Q-5KMvZ_8_Q)。
现在再看看这篇4年前的文章就像看到了4年前的自己感触颇多啊。几年过去了网上随意一搜都有大量安装包优化的文章那还有哪些“高深”的珍藏秘笈值得分享呢
时至今日微信包体积也从当年的30MB增长到现在的100MB了。我们经常会想现在WiFi这么普遍了而且5G都要来了包体积优化究竟还有没有意义它对用户和应用的价值在哪里
## 安装包的背景知识
还记得在2G时代我们每个月只有30MB流量那个时候安装包体积确实至关重要。当时我在做“搜狗输入法”的时候我们就严格要求包体积在5MB以内。
几年过去了,我们对包体积的看法有什么改变吗?
**1\. 为什么要优化包体积**
在2018年的Google I/OGoogle透露了Google Play上安装包体积与下载转化率的关系图。
![](https://static001.geekbang.org/resource/image/f8/68/f8a5e264dee4ee6879cd6c30d4bbf368.png)
从这张图上看,大体来说,安装包越小,转化率越高这个结论依然成立。而包体积对应用的影响,主要有下面几点:
* **下载转化率**。一个100MB的应用用户即使点了下载也可能因为网络速度慢、突然反悔下载失败。对于一个10MB的应用用户点了下载之后在犹豫要不要下的时候已经下载完了。但是正如上图的数据安装包大小与转化率的关系是非常微妙的。**10MB跟15MB可能差距不大但是10MB跟40MB的差距还是非常明显的。**
* **推广成本**。一般来说,包体积对渠道推广和厂商预装的单价会有非常大的影响。特别是厂商预装,这主要是因为厂商留给预装应用的总空间是有限的。如果你的包体积非常大,那就会影响厂商预装其他应用。
* **应用市场**。苹果的App Store强制超过150MB的应用只能使用WiFi网络下载Google Play要求超过100MB的应用只能使用[APK扩展文件方式](https://developer.android.com/google/play/expansion-files)上传,由此可见应用包体积对应用市场的服务器带宽成本还是会有一点压力的。
目前成熟的超级App越来越多很多产品也希望自己成为下一个超级App希望功能可以包罗万象满足用户的一切需求。但这同样也导致安装包不断变大其实很多用户只使用到很少一部分功能。
下面我们就来看看微信、QQ、支付宝以及淘宝这几款超级App这几年安装包增长的情况。
![](https://static001.geekbang.org/resource/image/e0/76/e0c8bc58d363e81ff3ac7a141f784776.png)
我还记得在15年的时候为了让微信6.2版本小于30MB我使用了各种各样的手段把体积从34MB降到29.85MB资源混淆工具AndResGuard也就是在那个优化专项中写的。几年过去了微信包体积已经涨到100MB了淘宝似乎也不容乐观。相比之下QQ和支付宝相对还比较节制。
**2\. 包体积与应用性能**
React Native 5MB、Flutter 4MB、浏览器内核20MB、Chromium网络库2MB…现在第三方开发框架和扩展库越来越多很多的应用包体积都已经几十是MB起步了。
那包体积除了转化率的影响,它对我们应用性能还有哪些影响呢?
* **安装时间**。文件拷贝、Library解压、编译ODEX、签名校验特别对于Android 5.0和6.0系统来说Android 7.0之后有了混合编译微信13个Dex光是编译ODEX的时间可能就要5分钟。
* **运行内存**。在内存优化的时候我们就说过Resource资源、Library以及Dex类加载这些都会占用不少的内存。
* **ROM空间**。100MB的安装包启动解压之后很有可能就超过200MB了。对低端机用户来说也会有很大的压力。在“I/O优化”中我们讨论过如果闪存空间不足非常容易出现写入放大的情况。
对于大部分一两年前的“千元机”,淘宝和微信都已经玩不转了。“技术短期内被高估,长期会被低估”,特别在业务高速发展的时候,性能往往就被排到后面。
包体积对技术人员来说应该是非常重要的技术指标,我们不能放任它的增长,它对我们还有不少意义。
* **业务梳理**。删除无用或者低价值的业务,永远都是最有效的性能优化方式。我们需要经常回顾过去的业务,不能只顾着往前冲,适时地还一些“技术债务”。
* **开发模式升级**。如果所有的功能都不能移除那可能需要倒逼开发模式的转变更多地采用小程序、H5这样开发模式。
## 包体积优化
国内地开发者都非常羡慕海外的应用因为海外有统一的Google Play市场。它可以根据用户的ABI、density和language发布还有在2018年最新推出的[App Bundle](https://developer.android.com/platform/technology/app-bundle/)。
![](https://static001.geekbang.org/resource/image/3d/a2/3d27aa4b299f9768ef0e6a7771d436a2.png)
事实上安装包中无非就是Dex、Resource、Assets、Library以及签名信息这五部分接下来我们就来看看对于国内应用来说还有什么高级“秘籍”。
**1\. 代码**
对于大部分应用来说Dex都是包体积中的大头。看一下上面表格中微信、QQ、支付宝和淘宝的数据它们的Dex数量从1个增长到10多个我们的代码量真的增长了那么多倍吗
而且Dex的数量对用户安装时间也是一个非常大的挑战在不砍功能的前提下我们看看有哪些方法可以减少这部分空间。
**ProGuard**
“十个ProGuard配置九个坑”特别是各种第三方SDK。我们需要仔细检查最终合并的ProGuard配置文件是不是存在过度keep的现象。
你可以通过下面的方法输出ProGuard的最终配置尤其需要注意各种的keep \*很多情况下我们只需要keep其中的某个包、某个方法或者是类名就可以了。
```
-printconfiguration configuration.txt
```
那还有没有哪些方法可以进一步加大混淆力度呢这时我们可能要向四大组件和View下手了。一般来说应用都会keep住四大组件以及View的部分方法这样是为了在代码以及XML布局中可以引用到它们。
```
-keep public class * extends android.app.Activity
-keep public class * extends android.app.Application
-keep public class * extends android.app.Service
-keep public class * extends android.content.BroadcastReceiver
-keep public class * extends android.content.ContentProvider
-keep public class * extends android.view.View
```
事实上,我们完全可以把**非exported**的四大组件以及View混淆但是需要完成下面几个工作
* **XML替换**。在代码混淆之后需要同时修改AndroidManifest以及资源XML中引用的名称。
* **代码替换**。需要遍历其他已经混淆好的代码,将变量或者方法体中定义的字符串也同时修改。需要注意的是,代码中不能出现经过运算得到的类名,这种情况会导致替换失败。
```
// 情况一:变量
public String activityName = "com.sample.TestActivity";
// 情况二:方法体
startActivity(new Intent(this, "com.sample.TestActivity"));
// 情况三:通过运算得到,不支持
startActivity(new Intent(this, "com.sample" + ".TestActivity"));
```
代码替换的方法我推荐使用ASM。不熟悉ASM的同学也不用着急后面我会专门讲它的原理和用法。饿了么曾经开源过一个可以实现四大组件和View混淆的组件[Mess](https://github.com/eleme/Mess),不过似乎已经没在维护了,可供你参考。
Android Studio 3.0推出了[新Dex编译器D8与新混淆工具R8](https://blog.dreamtobe.cn/android_d8_r8/)目前D8已经正式Release大约可以减少3%的Dex体积。但是计划用于取代ProGuard的[R8](https://www.guardsquare.com/en/blog/proguard-and-r8)依然处于实验室阶段,期待它在未来能有更好的表现。
**去掉Debug信息或者去掉行号**
某个应用通过相同的ProGuard规则生成一个Debug包和Release包其中Debug包的大小是4MBRelease包只有3.5MB。
既然它们ProGuard的混淆与优化的规则是一样的那它们之间的差异在哪里呢那就是DebugItem。
![](https://static001.geekbang.org/resource/image/69/10/69ec4986053903876d55fbd37d47a710.png)
DebugItem里面主要包含两种信息
* **调试的信息**。函数的参数变量和所有的局部变量。
* **排查问题的信息**。所有的指令集行号和源文件行号的对应关系。
事实上在ProGuard配置中一般我们也会通过下面的方式保留行号信息。
```
-keepattributes SourceFile, LineNumberTable
```
对于去除debuginfo以及行号信息更详细的分析推荐你认真看一下支付宝的一篇文章[《Android包大小极致压缩》](https://mp.weixin.qq.com/s/_gnT2kjqpfMFs0kqAg4Qig)。通过这个方法我们可以实现既保留行号但是又可以减少大约5%的Dex体积。
事实上支付宝参考的是Facebook的一个开源编译工具[ReDex](https://github.com/facebook/redex)。ReDex除了没有文档之外绝对是客户端领域非常硬核的一个开源库非常值得你去认真研究。
ReDex这个库里面的好东西实在是太多了后面我们还会反复讲到其中去除Debug信息是通过StripDebugInfoPass完成。
```
{
"redex" : {
"passes" : [
"StripDebugInfoPass"
]
},
"StripDebugInfoPass" : {
"drop_all_dbg_info" : "0", // 去除所有的debug信息0表示不去除
"drop_local_variables" : "1", // 去除所有局部变量1表示去除
"drop_line_numbers" : "0", // 去除行号0表示不去除
"drop_src_files" : "0",
"use_whitelist" : "0",
"drop_prologue_end" : "1",
"drop_epilogue_begin" : "1",
"drop_all_dbg_info_if_empty" : "1"
}
}
```
**Dex分包**
当我们在Android Studio查看一个APK的时候不知道你是否知道下图中“defines 19272 methods”和“references 40229 methods”的区别。
![](https://static001.geekbang.org/resource/image/fb/c4/fbd2ebe2b0ffc43447e414994c56d6c4.png)
关于Dex的格式以及各个字段的定义你可以参考[《Dex文件格式详解》](https://www.jianshu.com/p/f7f0a712ddfe)。为了加深对Dex格式的理解推荐你使用010Editor。
![](https://static001.geekbang.org/resource/image/87/33/87815d218abfaff9dc02a46c079cfb33.png)
“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。
![](https://static001.geekbang.org/resource/image/96/d5/96d08f01c5fe27c74bfcd5ac529232d5.png)
因为跨Dex调用造成的这些冗余信息它对我们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信息有效率的指标希望保证Dex有效率应该在80%以上。**同时为了进一步减少Dex的数量我们希望每个Dex的方法数都是满的即分配了65536个方法。**
```
Dex信息有效率 = define methods数量/reference methods数量
```
那如何实现Dex信息有效率提升呢关键在于我们需要将有调用关系的类和方法分配到同一个Dex中即减少跨Dex的调用的情况。但是由于类的调用关系非常复杂我们不太可能可以计算出最优解只能得到局部的最优解。
为了提高Dex信息有效率我在微信时曾参与写过一个依赖分析的工具Builder。但在微信最新的7.0版本你可以看到上面表中Dex的数量和大小都增大了很多这是因为他们不小心把这个工具搞失效了。Dex数量的增多对于**Tinker热修复时间**、用户安装时间都有很大影响。如果把这个问题修复微信7.0版本的Dex数量应该可以从13个降到6个左右包体积可以减少10MB左右。
但是我在研究ReDex的时候发现它也提供了这个优化而且实现得比微信的更好。ReDex在分析类调用关系后使用的是[贪心算法](https://github.com/facebook/redex/blob/master/opt/interdex/InterDex.cpp#L619)计算局部最优值,具体算法可查看[CrossDexDefMinimizer](https://github.com/facebook/redex/blob/master/opt/interdex/CrossDexRefMinimizer.cpp)。
为什么我们不能计算到最优解因为我们需要在编译速度和效果之间找一个平衡点在ReDex中使用这个优化的配置如下
```
{
"redex" : {
"passes" : [
"InterDexPass"
]
},
"InterDexPass" : {
"minimize_cross_dex_refs": true,
"minimize_cross_dex_refs_method_ref_weight": 100,
"minimize_cross_dex_refs_field_ref_weight": 90,
"minimize_cross_dex_refs_type_ref_weight": 100,
"minimize_cross_dex_refs_string_ref_weight": 90
}
}
```
那么通过Dex分包可以对包体积优化多少呢因为Android默认的分包方式做得实在不好如果你的应用有4个以上的Dex我相信这个优化至少有10%的效果。
**Dex压缩**
我曾经在逆向Facebook的App时惊奇地发现它怎么可能只有一个700多KB的Dex。Google Play是不允许动态下发代码的那它的代码都放到哪里了呢
![](https://static001.geekbang.org/resource/image/00/f7/008dc38d277aab4eabfb580ccac7aef7.png)
事实上Facebook App的classes.dex只是一个壳真正的代码都放到assets下面。它们把所有的Dex都合并成同一个secondary.dex.jar.xzs文件并通过XZ压缩。
![](https://static001.geekbang.org/resource/image/66/22/66abe10ca8e67e86ced07087555b8f22.png)
[XZ压缩算法](https://tukaani.org/xz/)和7-Zip一样内部使用的都是LZMA算法。对于Dex格式来说XZ的压缩率可以比Zip高30%左右。但是不知道你有没有注意到,这套方案似乎存在一些问题:
* **首次启动解压**。应用首次启动的时候需要将secondary.dex.jar.xzs解压缩根据上图的配置信息应该一共有11个Dex。Facebook使用多线程解压的方式这个耗时在高端机是几百毫秒左右在低端机可能需要35秒。**这里为什么不采用Zstandard或者Brotli呢主要是压缩率与解压速度的权衡。**
* **ODEX文件生成**。前面我就讲过当Dex非常多的时候会增加应用的安装时间。对于Facebook的这个做法首次生成ODEX的时间可能就会达到分钟级别。Facebook为了解决这个问题使用了ReDex另外一个超级硬核的方法那就是[oatmeal](https://github.com/facebook/redex/tree/master/tools/oatmeal)。
oatmeal的原理非常简单就是根据ODEX文件的格式自己生成一个ODEX文件。它生成的结果跟解释执行的ODEX一样内部是没有机器码的。
![](https://static001.geekbang.org/resource/image/6c/f4/6c7c1cceca23db77f7f0f51509ef62f4.png)
如上图所示对于正常的流程我们需要fork进程来生成dex2oat这个耗时一般都比较大。通过oatmeal我们直接在本进程生成ODEX文件。一个10MB的Dex如果在Android 5.0生成一个ODEX的耗时大约在10秒以上在Android 8.0使用speed模式大约在1秒左右而通过oatmeal这个耗时大约在100毫秒左右。
我一直都很想把oatmeal引入进Tinker但是比较担心兼容性的问题。因为每个版本ODEX格式都有一些差异oatmeal是需要分版本适配的。
**2\. Native Library**
现在音视频、美颜、AI、VR这些功能在应用越来越普遍但这些库一般都是使用C或者C++写的也就是说我们的APK中Native Library的体积越来越大了。
对于Native Library传统的优化方法可能就是去除Debug信息、使用c++\_shared这些。那我们还有没有更好的优化方法呢
**Library压缩**
跟Dex压缩一样Library优化最有效果的方法也是使用XZ或者7-Zip压缩。
![](https://static001.geekbang.org/resource/image/8f/c6/8f8a924549a14fd298f6efb4564f8ac6.png)
在默认的lib目录我们只需要加载少数启动过程相关的Library其他的Library我们都在首次启动时解压。对于Library格式来说压缩率同样可以比Zip高30%左右,效果十分惊人。
Facebook有一个So加载的开源库[SoLoader](https://github.com/facebook/SoLoader),它可以跟这套方案配合使用。**和Dex压缩一样压缩方案的主要缺点在于首次启动的时间毕竟对于低端机来说多线程的意义并不大因此我们要在包体积和用户体验之间做好平衡。**
**Library合并与裁剪**
对于Native LibraryFacebook中的编译构建工具[Buck](https://buckbuild.com/)也有两个比较硬核的高科技。当然在官方文档中是完全找不到的,它们都隐藏在[源码](https://github.com/facebook/buck)中。
* **Library合并**。在Android 4.3之前进程加载的Library数量是[有限制的](https://android.googlesource.com/platform/bionic/+/ba98d9237b0eabc1d8caf2600fd787b988645249%5E%21/)。在编译过程我们可以自动将部分Library合并成一个。具体思路你可以参考文章[《Android native library merging》](https://code.fb.com/android/android-native-library-merging/)以及[Demo](https://github.com/fbsamples/android-native-library-merging-demo)。
* **Library裁剪**。Buck里面有一个[relinker](https://github.com/facebook/buck/blob/master/src/com/facebook/buck/android/relinker/NativeRelinker.java)的功能原理就是分析代码中JNI方法以及不同Library的方法调用找到没有无用的导出symbol将它们删掉。**这样linker在编译的时候也会把对应的无用代码同时删掉**这个方法相当于实现了Library的ProGuard Shrinking功能。
![](https://static001.geekbang.org/resource/image/b8/a0/b86745a05656f05116443549cec6f3a0.png)
## 包体积监控
关于包体积如果一直放任不管几个版本之后就会给你很大的“惊喜”。我了解到一些应用对包体积卡得很紧任何超过100KB的功能都需要审批。
对于包体积的监控,通常有下面几种:
* **大小监控**。这个非常好理解,每个版本跟上一个版本包体积的对比情况。如果某个版本体积增长过大,需要分析具体原因,是否有优化空间。
* **依赖监控**。每一版本我们都需要监控依赖这里包括新增JAR以及AAR依赖。这是因为很多开发者非常不细心经常会不小心把一些超大的开源库引进来。
* **规则监控**。如果发现某个版本包体积增长很大我们需要分析原因。规则监控也就是将包体积的监控抽象为规则例如无用资源、大文件、重复文件、R文件等。比如我在微信的时候使用[ApkChecker](https://mp.weixin.qq.com/s/tP3dtK330oHW8QBUwGUDtA)实现包体积的规则监控。
![](https://static001.geekbang.org/resource/image/bd/c9/bd20c2420a06e332a78737deaa0aedc9.png)
包体积的监控最好可以实现自动化与平台化,作为发布流程的其中一个环节。不然通过人工的方式,很难持续坚持下去。
## 总结
今天我们一起分析了实现难度比较大的包体积优化方法,可能有人会想这些方法实现难度那么大,真的有价值吗?根据我的理解,现在我们已经到了移动优化的“深水区”,网上那些千篇一律的文章已经无法满足需求。也就是说,简单的方法我们都掌握了,而且也都已经在做了,需要考虑接下来应该如何进一步优化。
这时候就需要静下心来学会思考与钻研再往底层走走。我们要去研究APK的文件格式进一步还要研究内部Dex、Library以及Resource的文件格式。同时思考整个编译流程才能找到那些可以突破的地方。
在实现AndResGuard的时候我就对resources.arsc格式以及Android加载资源的流程有非常深入的研究。几年过去了对于资源的优化又有哪些新的秘籍呢我们下一期就会讨论“资源优化”这个主题。
从Buck和ReDex看出来Facebook比国内的研究真的要高深很多希望他们可以补充一些文档让我们学习起来更轻松一些。
## 课后作业
你的应用会关注包体积吗?你做过哪些包体积优化的工作,有哪些好的方法可以跟同学们分享呢?欢迎留言跟我和其他同学一起讨论。
今天的练习[Sample](https://github.com/AndroidAdvanceWithGeektime/Chapter22)尝试使用ReDex这个项目来优化我们应用的包体积主要有下面几个小任务
* strip debuginfo
* 分包优化
欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。最后别忘了在评论区提交今天的作业,我也为认真完成作业的同学准备了丰厚的“学习加油礼包”,期待与你一起切磋进步哦。