曾经在15年的时候,我在WeMobileDev公众号就写过一篇文章《Android安装包相关知识汇总》,也开源了一个不少同学都使用过的资源混淆工具AndResGuard

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

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

安装包的背景知识

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

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

1. 为什么要优化包体积

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

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

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

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

我还记得在15年的时候,为了让微信6.2版本小于30MB,我使用了各种各样的手段,把体积从34MB降到29.85MB,资源混淆工具AndResGuard也就是在那个优化专项中写的。几年过去了,微信包体积已经涨到100MB了,淘宝似乎也不容乐观。相比之下,QQ和支付宝相对还比较节制。

2. 包体积与应用性能

React Native 5MB、Flutter 4MB、浏览器内核20MB、Chromium网络库2MB…现在第三方开发框架和扩展库越来越多,很多的应用包体积都已经几十是MB起步了。

那包体积除了转化率的影响,它对我们应用性能还有哪些影响呢?

对于大部分一两年前的“千元机”,淘宝和微信都已经玩不转了。“技术短期内被高估,长期会被低估”,特别在业务高速发展的时候,性能往往就被排到后面。

包体积对技术人员来说应该是非常重要的技术指标,我们不能放任它的增长,它对我们还有不少意义。

包体积优化

国内地开发者都非常羡慕海外的应用,因为海外有统一的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混淆,但是需要完成下面几个工作:

// 情况一:变量
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的大小会造成哪些影响呢?

事实上,我自己定义了一个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%左右。但是不知道你有没有注意到,这套方案似乎存在一些问题:

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也有两个比较硬核的高科技。当然在官方文档中是完全找不到的,它们都隐藏在源码中。

包体积监控

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

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

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

总结

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

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

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

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

课后作业

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

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

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

评论