22 包体积优化(上):如何减少安装包大小? 曾经在15年的时候,我在WeMobileDev公众号就写过一篇文章《Android安装包相关知识汇总》,也开源了一个不少同学都使用过的资源混淆工具AndResGuard

现在再看看这篇4年前的文章,就像看到了4年前的自己,感触颇多啊。几年过去了,网上随意一搜都有大量安装包优化的文章,那还有哪些“高深”的珍藏秘笈值得分享呢?

时至今日,微信包体积也从当年的30MB增长到现在的100MB了。我们经常会想,现在WiFi这么普遍了,而且5G都要来了,包体积优化究竟还有没有意义?它对用户和应用的价值在哪里?

安装包的背景知识

还记得在2G时代,我们每个月只有30MB流量,那个时候安装包体积确实至关重要。当时我在做“搜狗输入法”的时候,我们就严格要求包体积在5MB以内。

几年过去了,我们对包体积的看法有什么改变吗?

1. 为什么要优化包体积

在2018年的Google I/O,Google透露了Google Play上安装包体积与下载转化率的关系图。

从这张图上看,大体来说,安装包越小,转化率越高这个结论依然成立。而包体积对应用的影响,主要有下面几点:

  • 下载转化率。一个100MB的应用,用户即使点了下载,也可能因为网络速度慢、突然反悔下载失败。对于一个10MB的应用,用户点了下载之后,在犹豫要不要下的时候已经下载完了。但是正如上图的数据,安装包大小与转化率的关系是非常微妙的。10MB跟15MB可能差距不大,但是10MB跟40MB的差距还是非常明显的。
  • 推广成本。一般来说,包体积对渠道推广和厂商预装的单价会有非常大的影响。特别是厂商预装,这主要是因为厂商留给预装应用的总空间是有限的。如果你的包体积非常大,那就会影响厂商预装其他应用。
  • 应用市场。苹果的App Store强制超过150MB的应用只能使用WiFi网络下载,Google Play要求超过100MB的应用只能使用APK扩展文件方式上传,由此可见应用包体积对应用市场的服务器带宽成本还是会有一点压力的。

目前成熟的超级App越来越多,很多产品也希望自己成为下一个超级App,希望功能可以包罗万象,满足用户的一切需求。但这同样也导致安装包不断变大,其实很多用户只使用到很少一部分功能。

下面我们就来看看微信、QQ、支付宝以及淘宝这几款超级App这几年安装包增长的情况。

我还记得在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

事实上安装包中无非就是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,不过似乎已经没在维护了,可供你参考。

Android Studio 3.0推出了新Dex编译器D8与新混淆工具R8,目前D8已经正式Release,大约可以减少3%的Dex体积。但是计划用于取代ProGuard的R8依然处于实验室阶段,期待它在未来能有更好的表现。

去掉Debug信息或者去掉行号- 某个应用通过相同的ProGuard规则生成一个Debug包和Release包,其中Debug包的大小是4MB,Release包只有3.5MB。

既然它们ProGuard的混淆与优化的规则是一样的,那它们之间的差异在哪里呢?那就是DebugItem。

DebugItem里面主要包含两种信息:

  • 调试的信息。函数的参数变量和所有的局部变量。
  • 排查问题的信息。所有的指令集行号和源文件行号的对应关系。

事实上,在ProGuard配置中一般我们也会通过下面的方式保留行号信息。 -keepattributes SourceFile, LineNumberTable

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

事实上,支付宝参考的是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”的区别。

关于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调用造成的这些冗余信息,它对我们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在分析类调用关系后,使用的是贪心算法计算局部最优值,具体算法可查看CrossDexDefMinimizer

为什么我们不能计算到最优解?因为我们需要在编译速度和效果之间找一个平衡点,在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是不允许动态下发代码的,那它的代码都放到哪里了呢?

事实上,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引入进Tinker,但是比较担心兼容性的问题。因为每个版本ODEX格式都有一些差异,oatmeal是需要分版本适配的。

2. Native Library

现在音视频、美颜、AI、VR这些功能在应用越来越普遍,但这些库一般都是使用C或者C++写的,也就是说,我们的APK中Native Library的体积越来越大了。

对于Native Library,传统的优化方法可能就是去除Debug信息、使用c++_shared这些。那我们还有没有更好的优化方法呢?

Library压缩- 跟Dex压缩一样,Library优化最有效果的方法也是使用XZ或者7-Zip压缩。

在默认的lib目录,我们只需要加载少数启动过程相关的Library,其他的Library我们都在首次启动时解压。对于Library格式来说,压缩率同样可以比Zip高30%左右,效果十分惊人。

Facebook有一个So加载的开源库SoLoader,它可以跟这套方案配合使用。和Dex压缩一样,压缩方案的主要缺点在于首次启动的时间,毕竟对于低端机来说,多线程的意义并不大,因此我们要在包体积和用户体验之间做好平衡。

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

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

包体积监控

关于包体积,如果一直放任不管,几个版本之后就会给你很大的“惊喜”。我了解到一些应用对包体积卡得很紧,任何超过100KB的功能都需要审批。

对于包体积的监控,通常有下面几种:

  • 大小监控。这个非常好理解,每个版本跟上一个版本包体积的对比情况。如果某个版本体积增长过大,需要分析具体原因,是否有优化空间。
  • 依赖监控。每一版本我们都需要监控依赖,这里包括新增JAR以及AAR依赖。这是因为很多开发者非常不细心,经常会不小心把一些超大的开源库引进来。
  • 规则监控。如果发现某个版本包体积增长很大,我们需要分析原因。规则监控也就是将包体积的监控抽象为规则,例如无用资源、大文件、重复文件、R文件等。比如我在微信的时候,使用ApkChecker实现包体积的规则监控。

包体积的监控最好可以实现自动化与平台化,作为发布流程的其中一个环节。不然通过人工的方式,很难持续坚持下去。

总结

今天我们一起分析了实现难度比较大的包体积优化方法,可能有人会想这些方法实现难度那么大,真的有价值吗?根据我的理解,现在我们已经到了移动优化的“深水区”,网上那些千篇一律的文章已经无法满足需求。也就是说,简单的方法我们都掌握了,而且也都已经在做了,需要考虑接下来应该如何进一步优化。

这时候就需要静下心来,学会思考与钻研,再往底层走走。我们要去研究APK的文件格式,进一步还要研究内部Dex、Library以及Resource的文件格式。同时思考整个编译流程,才能找到那些可以突破的地方。

在实现AndResGuard的时候,我就对resources.arsc格式以及Android加载资源的流程有非常深入的研究。几年过去了,对于资源的优化又有哪些新的秘籍呢?我们下一期就会讨论“资源优化”这个主题。

从Buck和ReDex看出来,Facebook比国内的研究真的要高深很多,希望他们可以补充一些文档,让我们学习起来更轻松一些。

课后作业

你的应用会关注包体积吗?你做过哪些包体积优化的工作,有哪些好的方法可以跟同学们分享呢?欢迎留言跟我和其他同学一起讨论。

今天的练习Sample,尝试使用ReDex这个项目来优化我们应用的包体积,主要有下面几个小任务:

  • strip debuginfo
  • 分包优化

欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。最后别忘了在评论区提交今天的作业,我也为认真完成作业的同学准备了丰厚的“学习加油礼包”,期待与你一起切磋进步哦。

参考资料

https://learn.lianglianglee.com/%e4%b8%93%e6%a0%8f/Android%e5%bc%80%e5%8f%91%e9%ab%98%e6%89%8b%e8%af%be/22%20%e5%8c%85%e4%bd%93%e7%a7%af%e4%bc%98%e5%8c%96%ef%bc%88%e4%b8%8a%ef%bc%89%ef%bc%9a%e5%a6%82%e4%bd%95%e5%87%8f%e5%b0%91%e5%ae%89%e8%a3%85%e5%8c%85%e5%a4%a7%e5%b0%8f%ef%bc%9f.md