当前位置: 首页 > 科技观察

Android编译不得不说的那些事

时间:2023-03-20 01:16:34 科技观察

作为一名Android工程师,我们每天都要经历无数次的编译。小项目半分钟或1、2分钟就能编译完成,大项目每次编译可能要喝一杯咖啡。也许你会对具体数字有更好的理解。我在微信团队的时候,完全编译Debug包需要5分钟,编译Release包需要15分钟以上。如果每次编译可以减少1分钟,那么微信整个Android团队可以节省1200分钟(团队40人×每天30次编译×1分钟)。因此,优化编译速度对于提高整个团队的开发效率来说非常重要。那么我们应该如何优化编译速度呢?微信、谷歌、Facebook等国内外企业都做了哪些努力?除了编译速度,关于编译你还需要了解哪些知识呢?虽然我们每天都在编译,那么编译到底是什么?你可以简单地将编译理解为将高级语言转换为机器或虚拟机可以识别的低级语言的过程。对于Android来说,这个过程就是将Java或者Kotlin转换成Android虚拟机可以运行的Dalvik字节码的过程。整个编译过程涉及词法分析、语法分析、语义检查和代码优化等步骤。对底层编译原理感兴趣的同学,可以挑战一下编译原理的三部经典名著:龙书、虎书、鲸书。但是今天我们的重点不在于底层编译原理,而是讨论Android编译需要解决的问题,目前遇到了哪些挑战,国内外各大厂商都提供了哪些解决方案。Android编译的基础知识,不管是微信编译优化还是Tinker项目,都涉及到很多编译相关的知识,所以对Android编译做了很多研究,经验丰富。Android的编译构建过程主要包括三个部分:代码、资源、NativeLibrary。整个过程可以参考官方文档的构建流程图。Gradle是Android官方的编译工具,也是GitHub上的一个开源项目。从Gradle的更新日志可以看出,目前的项目更新非常频繁,基本上每两个月就会有一个新版本。对于Gradle,我感觉最痛苦的是GradlePlugin的编写,主要是Gradle没有这方面的完整文档,所以一般只能靠源码或者断点调试的方式。最近我公司准备用Gradle做一个渠道打包工具,对项目的打包构建过程有了深刻的了解。但是编译太重要了,每个公司的情况都不一样,所以必须强行造出自己的“轮子”。已经开源的项目包括Facebook的Buck和谷歌的Bazel。为什么要自己“发明轮子”?主要有以下几个原因:统一的编译工具。Facebook和谷歌都有专门的团队负责编译。他们希望所有内部项目都使用同一套构建工具,包括Android、Java、iOS、Go、C++等,所有项目都将受益于编译工具的统一优化;代码组织和管理架构。Facebook和Google的代码管理有一个很特别的特点,就是整个公司的所有项目都放在同一个仓库里。所以整个存储库很大,所以他们也不使用Git。目前Google使用的是Piper,Facebook是基于HG修改的,也是分布式文件系统;性能的极致追求。Buck和Bazel的性能确实比Gradle好,包括他们的各种编译优化。但是它们或多或少都有定制化的味道,比如对Maven、JCenter等外部依赖的支持不是很好。“程序员最讨厌写文档,别人不写文档”,所以他们的文档比较小,如果要做二次定制开发会很痛苦。如果要将编译工具切换为Buck和Bazel,需要下很大的决心,还需要考虑与其他上下游项目的协作。当然,即使我们不直接使用它们,它们内部的优化思路也是非常值得我们学习和借鉴的。Gradle、Buck、Bazel都以更快的编译速度和更强大的代码优化为目标。让我们来看看他们做了哪些努力。编译速度回想一下我们的Android开发生涯,有多少时间和生命浪费在了编译上。正如我之前所说,编译速度对团队效率非常重要。关于编译速度,我们可能最关心的是编译Debug包的速度,尤其是增量构建(incrementalbuild)的速度。我们希望实现更快的调试。如下图所示,我们每次验证代码,都要经过编译和安装两个步骤。这里,我们从编译时间和安装时间两个纬度来看Android的编译速度。编译时间。将Java或Kotlin代码编译成“.class”文件,再通过dx编译成Dex文件。对于增量编译,我们希望编译尽可能少的代码和资源,理想情况下只编译更改。但是由于代码之间的依赖关系,这在大多数情况下是不可行的。这个时候只能退而求其次了,希望少编译一些模块。AndroidPlugin3.0及以后版本使用Implementation而不是Compile,只是为了优化依赖;安装时间。我们必须先通过签名验证。验证成功后,会进行大量的文件拷贝工作,比如APK文件、Library文件、Dex文件等,然后我们需要编译Odex文件,这个过程会非常耗时,尤其是在Android5.0上和6.0。对于增量编译,最好的优化是直接应用新代码而不重新安装新的APK。增量编译,先说说Gradle的官方程序InstantRun。在AndroidPlugin2.3之前,它使用Multidex实现。AndroidPlugin2.3之后,使用了Android5.0新的SplitAPK机制。如下图,资源和Manifest放在BaseAPK中。BaseAPK中的代码只是InstantRun框架,应用本身的代码在SplitAPK中。InstantRun具有三种模式。如果是热插拔和温插拔,我们不需要重新安装新的SplitAPK。它们的区别在于是否重启Activity。对于冷插拔,我们需要通过adbinstall-multiple-r-t重新安装更改后的SplitAPK,并且需要重启应用。尽管在任何一种模式下,我们都不需要重新安装BaseAPK。这让InstantRun看起来不错,但是在大型项目中,它的性能还是很差的,主要原因是:多进程问题。“Theappwasrestartedsinceitusesmultipleprocesses”,如果应用中有多个进程,热插拔和温插拔都不会生效。因为大部分应用都会有多个进程,InstantRun的速度会大大降低。拆分APK安装问题。SplitAPK的安装虽然不会生成Odex文件,但还是会有签名校验文件副本(APK安装的乒乓机制)。这个时间需要几秒到几十秒,这是不可接受的。问题。Gradle4.6之前,如果项目中使用了AnnotationProcessor。抱歉,这个修改和它所依赖的模块需要fulljavac,而且这个过程很慢,可能需要几十秒。直到Gradle4.7才解决这个问题。你可以参考这个Issue来讨论这个问题的原因。也可以看看这个Issue:“fullrebuildifaclasscontainsaconstant”,假设修改后的类包含一个“publicstaticfinal”变量,也是囧,这个修改和它依赖的模块Fulljavac是必须的。为什么是这样?因为常量池会直接把值编译到其他类中,Gradle不知道哪些类可能会用到这个常量。问问Gradle的工作人员,他们给出的解决方案是这样的://原常量定义:publicstaticfinalintMAGIC=23//将常量定义替换为方法:publicstaticintmagic(){return23;}对于大型项目,这肯定是行不通的.正如我在Issue中所写,无论我们是否真的更改这个常量,Gradle都会无脑地使用fulljavac,这肯定是错误的。其实我们可以对比这段代码修改,看一个常量的值是否有真正的变化。不过可能用过阿里的Freeline或者蘑菇街的快编译的同学可能会有疑问。为什么他们的方案没有遇到Annotation和constants的问题呢?事实上,他们的解决方案在大多数情况下都比InstantRun更快,所以这是因为牺牲了正确性。也就是说,为了追求更快的速度,他们直接忽略了Annotation和可能导致错误编译产物的不断变化。作为官方的解决方案,InstantRun的首要任务是确保100%的正确性。当然,Google的人也发现了InstantRun的各种问题。AndroidStudio3.5之后,对于Android8.0之后的设备,将使用新的解决方案“ApplyChanges”,而不是InstantRun。我还没有找到关于这个解决方案的更多信息,但我认为应该放弃SplitAPK机制。我心中一直有一个理想的编译方案。本方案安装的BaseAPK仍然只是一个shellAPK,真正的业务代码放在Assets的ClassesN.dex中。其架构图如下。无需安装。还是采用类似Tinker的hotfix的方法,每次将修改的类和依赖的类插入到pathclassloader的最前面即可。不熟悉的同学可以参考《微信 Android 热补丁实践演进之路》中的QQ空间解决方案;麦片。为了解决第一次运行Assets中ClassesN.dex的Odex耗时问题,我们可以使用《安装包优化》中提到的ReDex中的黑科技:Oatmeal。它可以在100毫秒内生成一个完全解释的Odex文件;关闭JIT。我们通过在AndroidManifest中添加android:vmSafeMode="true"来关闭虚拟机的JIT优化,这样就不会出现Tinker在AndroidN混合编译中遇到的问题。对于编译速度的优化,我还有几点建议:更换编译机。对于有实力的公司来说,直接替换Mac或其他更强大的设备作为编译器是最简单的方法;构建缓存。大部分不经常改动的工程都可以detach,使用remoteCache方式保留编译好的缓存;升级Gradle和SDK构建工具。及时升级最新编译工具链,享受谷歌最新优化成果;使用巴克。无论是Buck的exopackage,还是代码的增量编译,Buck都更加高效。但是我之前说过,如果一个大型项目想要转用Buck,还是有很多顾虑的。微信在2014年初接入了Buck,但由于其他项目出现问题,2015年又切换回了Gradle方案。相比之下,时下最流行的Flutter中的HotReload二级编译功能可能更具吸引力。当然,Google在最近版本的AndroidStudio中也做了很多其他的优化,比如使用AAPT2代替AAPT来编译Android资源。AAPT2实现了资源的增量编译,将资源的编译拆分为两个步骤:Compile和Link。前者资源文件以二进制形式编译成Flat格式,后者将所有文件组合起来,然后打包。除了AAPT2,谷歌还推出了d8和R8。下面是谷歌提供的一些测试数据,如下图所示。那么d8和R8是什么?除了编译速度的优化,它们还有哪些功能呢?可以参考下面的介绍:AndroidD8和R8代码优化对于Debug包的编译,我们更关心的是速度。但对于Release包来说,代码优化更为重要,因为我们会更加关注应用的性能。接下来说说我们可能用到的四种代码优化工具ProGuard、d8、R8和ReDex。在微信Release包中ProGuard的12分钟编译过程中,单独ProGuard需要8分钟。虽然ProGuard确实很慢,但几乎每个项目都在使用它。加入ProGuard后,应用的构建过程如下:ProGuard主要有混淆、裁剪、优化三个功能。它的整个处理流程如下:优化包括内联、修饰符、合并类和方法等30多种,具体介绍和使用可以参考官方文档。D8AndroidStudio3.0引入了d8,在3.1正式成为默认工具。它的作用是将“.class”文件编译成Dex文件,取代之前的dx工具。除了d8编译速度更快之外,另一个优化是减少生成的Dex的大小。根据谷歌的测试结果,大约会有3%到5%的优化。在AndroidStudio3.1中引入的R8R8更加雄心勃勃,旨在取代ProGuard和d8。我们可以直接使用R8将“.class”文件转换成Dex。同时,R8还支持ProGuard中的混淆、裁剪和优化三大功能。由于R8还在实验阶段,网上的介绍资料不多。您可以参考以下资料:ProGuardandR8comparison:ProGuardandR8:acomparisonofoptimizers。JakeWharton的博客最近有很多R8相关的文章:https://jakewharton.com/blog/。R8的终极目标和d8一样,一是加快编译速度,二是更强大的代码优化。如果说ReDex说R8是ProGuard未来想要取代的工具,那么Facebook内部的ReDex已经做到了。Facebook内部的很多项目已经全部切换到ReDex,不再使用ProGuard。与ProGuard不同的是它直接输入Dex而不是“.class”文件,即直接针对最终产品进行优化,所见即所得。在上一篇文章中,我不止一次提到过ReDex这个项目,因为里面的功能太强大了。详情请参考专栏前面的文章《包体积优化(上):如何减少安装包大小?》。Interdex:类重排和文件重排,Dex分包优化;Oatmeal:直接生成Odex文件;StripDebugInfo:去除Dex中的Debug信息。另外,TypeErasure等ReDex和去除代码的Aceess方法也是很好的功能。它们对应用程序的包大小和运行速度都有帮助,因此我也鼓励您学习和实践它们的用法和效果。不过ReDex文档万年不更新,而且还夹杂了一些Facebook内部的自定义逻辑,使用起来确实很不方便。目前我主要是直接研究它的源码,参考它的原理,然后直接实现。其实Buck里面其实有很多有用的东西,但是文档里面什么都没有提到,所以还是要“读源码”。LibraryMerge和Relinker支持多语言拆分和分包ReDex支持Gradle、Buck和Bazel的持续交付。它们都是狭义的编译。我觉得广义上的编译应该包括打包构建、CodeReview、代码工程管理、代码扫描等过程,也就是最近业界经常提到的持续集成。目前最常用的持续集成工具有Jenkins、GitLabCI、TravisCI等,GitHub也提供了自己的持续集成服务。每个大公司都有自己的持续集成解决方案,比如腾讯的RDM,阿里的摩天轮,大众点评的MCI等等。我简单说一下我对持续集成的一些经验和看法:自定义代码检查。每个公司都会有自己的编码标准,代码检查的目的是防止不合规的代码被提交到远程仓库。比如微信定义了一套代码规范,写了专门的插件检测。比如log规范,newThread,newHandler等不能直接使用,违者必罚。自定义代码检测可以自己实现,也可以扩展Findbugs插件实现。例如,美团点评使用Findbugs实现了Android漏洞扫描工具CodeArbiter;第三方代码检查。业界比较常用的代码扫描工具有付费的Coverity和Facebook开源的Infer,可以扫描出空指针、多线程问题、资源泄露等诸多问题。除了增加测试流程,我最大的体会是需要同时增加人员培训。我遇到过很多开发者,为了解决扫描到的问题,直接将空指针判断为null,直接锁死多线程,最后可能会导致更严重的问题;代码审查。关于CodeReview,集成GitLab、Phabricator或Gerrit是不错的选择。我们一定要重视CodeReview,这也是向别人展示我们“伟大”代码的机会。我们自己应该是??第一个代码审查员。在审查别人之前,我们应该先从第三方的角度审查代码。这样,先通过自己水平的考验,既尊重了别人的时间,也为自己树立了良好的技术品牌。持续集成涉及的流程很多,需要结合自己团队的现状。如果一味地增加进程,有时可能会适得其反。综上所述,在Android8.0中,Google引入了Dexlayout库来实现类和方法的重排,Facebook的Buck也首次引入了AAPT2。ReDex、d8和R8实际上是相辅相成的。可见谷歌也在向社区吸取知识,但同时我们也会向谷歌的新技术开发寻求思路。在写今天的内容时,我还有另外一种体会。谷歌为解决Android编译速度问题付出了很多努力,但结果并不尽如人意。我想说,如果我们敢于跳出系统的束缚,这个问题可能就彻底解决了,就像二级编译在Flutter上可以完美实现一样。其实,做人做事也是如此。我们常常陷入局部最优解的困境,或者进入“思维圈”。将完全不同。