# 26 | 关于编译,你需要了解什么? 作为Android工程师,我们每天都会经历无数次编译;而对于大型项目来说,每次编译就意味着要花去一杯咖啡的时间。可能我讲具体的数字你会更有体会,当时我在微信时,全量编译Debug包需要5分钟,而编译Release包更是要超过15分钟。 如果每次编译可以减少1分钟,对微信整个Android团队来说就可以节约1200分钟(团队40人 × 每天编译30次 × 1分钟)。所以说优化编译速度,对于提升整个团队的开发效率是非常重要的。 那应该怎么样优化编译速度呢?微信、Google、Facebook等国内外大厂都做了哪些努力呢?除了编译速度之外,关于编译你还需要了解哪些知识呢? ## 关于编译 虽然我们每天都在编译,那到底什么是编译呢? 你可以把编译简单理解为,将高级语言转化为机器或者虚拟机所能识别的低级语言的过程。对于Android来说,这个过程就是把Java或者Kotlin转变为Android虚拟机运行的[Dalvik字节码](https://source.android.com/devices/tech/dalvik/dalvik-bytecode)的过程。 编译的整个过程会涉及词法分析、语法分析 、语义检查和代码优化等步骤。对于底层编译原理感兴趣的同学,你可以挑战一下编译原理的三大经典巨作:[龙书、虎书、鲸鱼书](https://www.itcodemonkey.com/article/3521.html)。 但今天我们的重点不是底层的编译原理,而是希望一起讨论Android编译需要解决的问题是什么,目前又遇到了哪些挑战,以及国内外大厂又给出了什么样的解决方案。 **1\. Android编译的基础知识** 无论是微信的编译优化,还是Tinker项目,都涉及比较多的编译相关知识,因此我在Android编译方面研究颇多,经验也比较丰富。Android的编译构建流程主要包括代码、资源以及Native Library三部分,整个流程可以参考官方文档的[构建流程图](https://developer.android.com/studio/build/?hl=zh-cn)。 ![](https://static001.geekbang.org/resource/image/7e/87/7e66559bb42ce21a538ab34f596c1f87.png) [Gradle](https://docs.gradle.org/current/userguide/userguide.html)是Android官方的编译工具,它也是GitHub上的一个[开源项目](https://github.com/gradle/gradle)。从Gradle的[更新日志](https://gradle.org/releases/)可以看到,当前这个项目还更新得非常频繁,基本上每一两个月都会有新的版本。对于Gradle,我感觉最痛苦的还是Gradle Plugin的编写,主要是因为Gradle在这方面没有完善的文档,因此一般都只能靠看源码或者[断点调试](https://fucknmb.com/2017/07/05/%E5%8F%88%E6%8E%8C%E6%8F%A1%E4%BA%86%E4%B8%80%E9%A1%B9%E6%96%B0%E6%8A%80%E8%83%BD-%E6%96%AD%E7%82%B9%E8%B0%83%E8%AF%95Gradle%E6%8F%92%E4%BB%B6/)的方法。 但是编译实在太重要了,每个公司的情况又各不相同,必须强行造一套自己的“轮子”。已经开源的项目有Facebook的[Buck](https://github.com/facebook/buck)以及Google的[Bazel](https://github.com/bazelbuild/bazel)。 为什么要自己“造轮子”呢?主要有下面几个原因: * **统一编译工具**。Facebook、Google都有专门的团队负责编译工作,他们希望内部的所有项目都使用同一套构建工具,这里包括Android、Java、iOS、Go、C++等。编译工具的统一优化,所有项目都会受益。 * **代码组织管理架构**。Facebook和Google的代码管理有一个非常特别的地方,就是整个公司的所有项目都放到同一个仓库里面。因此整个仓库非常庞大,所以他们也不会使用Git。目前Google使用的是[Piper](http://www.ruanyifeng.com/blog/2016/07/google-monolithic-source-repository.html),Facebook是基于[HG](https://www.mercurial-scm.org/)修改的,也是一种基于分布式的文件系统。 * **极致的性能追求**。Buck和Bazel的性能的确比Gradle更好,内部包含它们的各种编译优化。但是它们或多或少都有一些定制的味道,例如对Maven、JCenter这样的外部依赖支持的也不是太好。 ![](https://static001.geekbang.org/resource/image/fa/90/fa5da989a8047e935ec15803235ecd90.png) “程序员最痛恨写文档,还有别人不写文档”,所以它们的文档也是比较少的,如果想做二次定制开发会感到很痛苦。如果你想把编译工具切换到Buck和Bazel,需要下很大的决心,而且还需要考虑和其他上下游项目的协作。当然即使我们不去直接使用,它们内部的优化思路也非常值得我们学习和参考。 Gradle、Buck、Bazel都是以更快的编译速度、更强大的代码优化为目标,我们下面一起来看看它们做了哪些努力。 **2\. 编译速度** 回想一下我们的Android开发生涯,在编译这件事情上面究竟浪费了多少时间和生命。正如前面我所说,编译速度对团队效率非常重要。 关于编译速度,我们最关心的可能还是编译Debug包的速度,尤其是**增量编译**(incremental build)的速度,希望可以做到更加快速的调试。正如下图所示,我们每次代码验证都要经过编译和安装两个步骤。 ![](https://static001.geekbang.org/resource/image/04/56/04a94858fd23dc6d1ccce64f400c2656.png) * **编译时间**。把Java或者Kotlin代码编译为“.class“文件,然后通过dx编译为Dex文件。对于增量编译,我们希望编译尽可能少的代码和资源,最理想情况是只编译变化的部分。但是由于代码之间的依赖,大部分情况这并不可行。这个时候我们只能退而求其次,希望编译更少的模块。[Android Plugin 3.0](https://developer.android.com/studio/build/dependencies)使用Implementation代替Compile,正是为了优化依赖关系。 * **安装时间**。我们要先经过签名校验,校验成功后会有一大堆的文件拷贝工作,例如APK文件、Library文件、Dex文件等。之后我们还需要编译Odex文件,这个过程特别是在Android 5.0和6.0会非常耗时。对于增量编译,最好的优化是直接应用新的代码,无需重新安装新的APK。 对于增量编译,我先来讲讲Gradle的官方方案[Instant Run](https://developer.android.com/studio/run/?hl=zh-cn)。在Android Plugin 2.3之前,它使用的Multidex实现。在Android Plugin 2.3之后,它使用Android 5.0新增的Split APK机制。 如下图所示,资源和Manifest都放在Base APK中, 在Base APK中代码只有Instant Run框架,应用的本身的代码都在Split APK中。 ![](https://static001.geekbang.org/resource/image/11/a5/118f836da894cdc9ff16675293e44aa5.png) Instant Run有三种模式,如果是热交换和温交换,我们都无需重新安装新的Split APK,它们的区别在于是否重启Activity。对于冷交换,我们需要通过`adb install-multiple -r -t`重新安装改变的Split APK,应用也需要重启。 虽然无论哪一种模式,我们都不需要重新安装Base APK。这让Instant Run看起来是不是很不错,但是在大型项目里面,它的性能依然非常糟糕,主要原因是: * **多进程问题**。“The app was restarted since it uses multiple processes”,如果应用存在多进程,热交换和温交换都不能生效。因为大部分应用都会存在多进程的情况,Instant Run的速度也就大打折扣。 * **Split APK安装问题**。虽然Split APK的安装不会生成Odex文件,但是这里依然会有签名校验和文件拷贝(APK安装的乒乓机制)。这个时间需要几秒到几十秒,是不能接受的。 * **javac问题**。在Gradle 4.6之前,如果项目中运用了Annotation Processor。那不好意思,本次修改以及它依赖的模块都需要全量javac,而这个过程是非常慢的,可能会需要几十秒。这个问题直到[Gradle 4.7](https://docs.gradle.org/current/userguide/java_plugin.html#sec:incremental_annotation_processing)才解决,关于这个问题原因的讨论你可以参考这个[Issue](https://github.com/gradle/gradle/issues/1320)。 你还可以看看这一个Issue:“[full rebuild if a class contains a constant](https://github.com/gradle/gradle/issues/2767)”,假设修改的类中包含一个“public static final”的变量,那同样也不好意思,本次修改以及它依赖的模块都需要全量javac。这是为什么呢?因为常量池是会直接把值编译到其他类中,Gradle并不知道有哪些类可能使用了这个常量。 询问Gradle的工作人员,他们出给的解决方案是下面这个: ``` // 原来的常量定义: public static final int MAGIC = 23 // 将常量定义替换成方法: public static int magic() { return 23; } ``` 对于大型项目来说,这肯定是不可行的。正如我在Issue中所写的一样,无论我们是不是真正改到这个常量,Gradle都会无脑的全量javac,这样肯定是不对的。事实上,我们可以通过比对这次代码修改,看看是否有真正改变某一个常量的值。 但是可能用过阿里的[Freeline](https://github.com/alibaba/freeline)或者蘑菇街的[极速编译](https://tech.meili-inc.com/233-233?from=timeline&isappinstalled=0)的同学会有疑问,它们的方案为什么不会遇到Annotation和常量的问题? 事实上,它们的方案在大部分情况比Instant Run更快,那是因为牺牲了正确性。也就是说它们为了追求更快的速度,直接忽略了Annotation和常量改变可能带来错误的编译产物。Instant Run作为官方方案,它优先保证的是100%的正确性。 **当然Google的人也发现了Instant Run的种种问题,在Android Studio 3.5之后,对于Android 8.0以后的设备将会使用新的方案“[Apply Changes](https://androidstudio.googleblog.com/2019/01/android-studio-35-canary-1-available.html)”代替Instant Run。目前我还没找到关于这套方案更多的资料,不过我认为应该是抛弃了Split APK机制**。 一直以来,我心目中都有一套理想的编译方案,这套方案安装的Base APK依然只是一个壳APK,真正的业务代码放到Assets的ClassesN.dex中。 ![](https://static001.geekbang.org/resource/image/f0/e4/f02eeaf46a8a4c5a7f82544ca208ebe4.png) * **无需安装**。依然使用类似Tinker热修复的方法,每次只把修改以及依赖的类插入到pathclassloader的最前方就可以,不熟悉的同学可以参考[《微信Android热补丁实践演进之路》](https://mp.weixin.qq.com/s/-NmkSwZu83HAmzKPawdTqQ)中的Qzone方案。 * **Oatmeal**。为了解决首次运行时Assets中ClassesN.dex的Odex耗时问题,我们可以使用“安装包优化“中讲过的ReDex中的黑科技:Oatmeal。它可以在100毫秒以内生成一个完全解释执行的Odex文件。 * **关闭JIT**。我们通过在AndroidManifest指定[android:vmSafeMode=“true”](https://developer.android.com/guide/topics/manifest/application-element?hl=zh-cn#vmSafeMode),关闭虚拟机的JIT优化,这样也就不会出现Tinker在[Android N混合编译遇到的问题](https://mp.weixin.qq.com/s/h9BHnEkV0RMx0yIW1wpw9g)。 这套方案应该可以完全解决Instant Run当前的各种问题,我也希望对编译优化感兴趣的同学可以自行实现这一套方案,并能开源出来。 对于编译速度的优化,我还有几个建议: * **更换编译机器**。对于实力雄厚的公司,直接更换Mac或者其他更给力的设备作为编译机,这种方式是最简单的。 * **Build Cache**。可以将大部分不常改变的项目拆离出去,并使用[远端Cache](http://docs.gradle.com/enterprise/tutorials/caching/)模式保留编译后的缓存。 * **升级Gradle和SDK Build Tools**。我们应该及时去升级最新的编译工具链,享受Google的最新优化成果。 * **使用Buck**。无论是Buck的exopackage,还是代码的增量编译,Buck都更加高效。但我前面也说过,一个大型项目如果要切换到Buck,其实顾虑还是比较多的。在2014年初微信就接入了Buck,但是因为跟其他项目协作的问题,导致在2015年切换回Gradle方案。相比之下,**可能目前最热的Flutter中[Hot Reload](https://juejin.im/post/5bc80ef7f265da0a857aa924)秒级编译功能会更有吸引力**。 当然最近几个Android Studio版本,Google也做了大量的其他优化,例如使用[AAPT2](https://developer.android.com/studio/command-line/aapt2)替代了AAPT来编译Android资源。AAPT2实现了资源的增量编译,它将资源的编译拆分成Compile和Link两个步骤。前者资源文件以二进制形式编译Flat格式,后者合并所有的文件再打包。 除了AAPT2,Google还引入了d8和R8,下面分别是Google提供的一些测试数据。 ![](https://static001.geekbang.org/resource/image/ed/16/ed5dd1797ea15e58c4bbe5a52008bd16.png) ![](https://static001.geekbang.org/resource/image/cd/2a/cd479426df64c979cda4fe35578c9f2a.png) 那什么是d8和R8呢?除了编译速度的优化,它们还有哪些其他的作用? **3\. 代码优化** 对于Debug包编译,我们更关心速度。但是对于Release包来说,代码的优化更加重要,因为我们会更加在意应用的性能。 下面我就分别讲讲ProGuard、d8、R8和ReDex这四种我们可能会用到的代码优化工具。 **ProGuard** 在微信Release包12分钟的编译过程里,单独ProGuard就需要花费8分钟。尽管ProGuard真的很慢,但是基本每个项目都会使用到它。加入了ProGuard之后,应用的构建过程流程如下: ![](https://static001.geekbang.org/resource/image/b4/f3/b4bddb5e8cf4666b3f38f046998f33f3.png) ProGuard主要有混淆、裁剪、优化这三大功能,它的整个处理流程是: ![](https://static001.geekbang.org/resource/image/03/e4/031014e00f8568c5eac9256782005ee4.png) 其中优化包括内联、修饰符、合并类和方法等30多种,具体介绍与使用方法你可以参考[官方文档](https://www.guardsquare.com/en/products/proguard/manual/usage/optimizations)。 **d8** Android Studio 3.0推出了[d8](https://developer.android.com/studio/command-line/d8),并在3.1正式成为默认工具。它的作用是将“.class”文件编译为Dex文件,取代之前的dx工具。 ![](https://static001.geekbang.org/resource/image/06/c3/0692ef05be5dac0803819274f6c6b3c3.png) d8除了更快的编译速度之外,还有一个优化是减少生成的Dex大小。根据Google的测试结果,大约会有3%~5%的优化。 ![](https://static001.geekbang.org/resource/image/a4/ee/a410c50d547c49c37640632c81ec2dee.png) **R8** R8在Android Studio 3.1中引入,它的志向更加高远,它的目标是取代ProGuard和d8。我们可以直接使用R8把“.class”文件变成Dex。 ![](https://static001.geekbang.org/resource/image/9d/ac/9de1ce2e0d821dabcdabf82844601eac.png) 同时,R8还支持ProGuard中混淆、裁剪、优化这三大功能。由于目前R8依然处于实验阶段,网上的介绍资料并不多,你可以参考下面这些资料: * ProGuard和R8对比:[ProGuard and R8: a comparison of optimizers](https://www.guardsquare.com/en/blog/proguard-and-r8)。 * Jake Wharton大神的博客最近有很多R8相关的文章:[https://jakewharton.com/blog/](https://jakewharton.com/blog/)。 R8的最终目的跟d8一样,一个是加快编译速度,一个是更强大的代码优化。 **ReDex** 如果说R8是未来想取代的ProGuard的工具,那Facebook的内部使用的[ReDex](https://github.com/facebook/redex)其实已经做到了。 Facebook内部的很多项目都已经全部切换到ReDex,不再使用ProGuard了。跟ProGuard不同的是,它直接输入的对象是Dex,而不是“.class”文件,也就是它是直接针对最终产物的优化,所见即所得。 在前面的文章中,我已经不止一次提到ReDex这个项目,因为它里面的功能实在是太强大了,具体可以参考专栏前面的文章[《包体积优化(上):如何减少安装包大小?》](https://time.geekbang.org/column/article/81202)。 * Interdex:类重排和文件重排、Dex分包优化。 * Oatmeal:直接生成的Odex文件。 * StripDebugInfo:去除Dex中的Debug信息。 此外,ReDex中例如[Type Erasure](https://github.com/facebook/redex/tree/master/opt/type-erasure)和去除代码中的[Aceess方法](https://github.com/facebook/redex/tree/master/opt/access-marking)也是非常不错的功能,它们无论对包体积还是应用的运行速度都有帮助,因此我也鼓励你去研究和实践一下它们的用法和效果。但是ReDex的文档也是万年不更新的,而且里面掺杂了一些Facebook内部定制的逻辑,所以它用起来的确非常不方便。目前我主要还是直接研究它的源码,参考它的原理,然后再直接单独实现。 事实上,Buck里面其实也还有很多好用的东西,但是文档里面依然什么都没有提到,所以还是需要“read the source code”。 * Library Merge和Relinker * 多语言拆分 * 分包支持 * ReDex支持 ## 持续交付 Gradle、Buck、Bazel它们代表的都是狭义上的编译,我认为广义的编译应该包括打包构建、Code Review、代码工程管理、代码扫描等流程,也就是业界最近经常提起的持续集成。 ![](https://static001.geekbang.org/resource/image/02/a9/020fc61a0096102fa1b05be2f30b02a9.png) 目前最常用的持续集成工具有Jenkins、GitLab CI、Travis CI等,GitHub也有提供自己的持续集成服务。每个大公司都有自己的持续集成方案,例如腾讯的RDM、阿里的摩天轮、大众点评的[MCI](https://tech.meituan.com/2018/07/12/mci.html)等。 下面我来简单讲一下我对持续集成的一些经验和看法: * **自定义代码检查**。每个公司都会有自己的编码规范,代码检查的目的在于防止不符合规范的代码提交到远程仓库中。比如微信就定义了一套代码规范,并且写了专门的插件来检测。例如日志规范、不能直接使用new Thread、new Handler等,而且违反者将会得到一定的惩罚。自定义代码检测可以通过完全自己实现或者扩展Findbugs插件,例如美团它们就利用Findbugs实现了[Android漏洞扫描工具Code Arbiter](https://tech.meituan.com/2017/08/17/android-code-arbiter.html)。 * **第三方代码检查**。业界比较常用的代码扫描工具有收费的Coverity,以及Facebook开源的[Infer](https://github.com/facebook/infer),例如空指针、多线程问题、资源泄漏等很多问题都可以扫描出来。除了增加检测流程,我最大的体会是需要同时增加人员的培训。我遇到很多开发者为了解决扫描出来的问题,空指针就直接判空、多线程就直接加锁,最后可能会造成更加严重的问题。 * **Code Review**。关于Code Review,集成GitLab、Phabricator或者Gerrit都是不错的选择。我们一定要重视Code Review,这也是给其他人展示我们“伟大”代码的机会。而且我们自己应该是第一个Code Reviewer,在给别人Review之前,自己先以第三者的角度审视一次代码。这样先通过自己这一关的考验,既尊重了别人的时间,也可以为自己树立良好的技术品牌。 持续集成涉及的流程有很多,你需要结合自己团队的现状。如果只是一味地去增加流程,有时候可能适得其反。 ## 总结 在Android 8.0,Google引入了[Dexlayout](https://source.android.com/devices/tech/dalvik/improvements)库实现类和方法的重排,Facebook的Buck也第一时间引入了AAPT2。ReDex、d8、R8其实都是相辅相成,可以看到Google也在摄取社区的知识,但同时我们也会从Google的新技术发展里寻求思路。 我在写今天的内容时还有另外一个体会,Google为了解决Android编译速度的问题,花了大量的力气结果却不尽如人意。我想说如果我们敢于跳出系统的制约,可能才会彻底解决这个问题,正如在Flutter上面就可以完美实现秒级编译。其实做人、做事也是如此,我们经常会陷入局部最优解的困局,或者走进“思维怪圈”,这时如果能跳出路径依赖,从更高的维度重新思考、审视全局,得到的体会可能会完全不一样。 ## 课后作业 在你的工作中,遇到过哪些编译问题?有没有做过具体优化编译速度的工作?对于编译,你还有哪些疑问?欢迎留言跟我和其他同学一起讨论。 对于Android Build System,可以说每年都会有不少的变化,也有很多新的东西出来。所以我们应该保持敏感度,你会发现很多工具都非常有用,例如Desugar、Dexlayout、JVM TI、App Bundle等。 今天的课后作业是,请你观看2018年Google I/O编译工具相关的视频,在留言中写下自己的心得体会。 * [What’s new with the Android build system (Google I/O '18)](http://v.youku.com/v_show/id_XMzYwMDQ3MDk2OA==.html?spm=a2h0k.11417342.soresults.dtitle) * [What’s new in Android development tools](http://v.youku.com/v_show/id_XMzU5ODExNzQzMg==.html?spm=a2h0k.11417342.soresults.dtitle) 欢迎你点击“请朋友读”,把今天的内容分享给好友,邀请他一起学习。最后别忘了在评论区提交今天的作业,我也为认真完成作业的同学准备了丰厚的“学习加油礼包”,期待与你一起切磋进步哦。