34

QQ音乐Android编译提速之路

 3 years ago
source link: https://mp.weixin.qq.com/s?__biz=MzI1NjEwMTM4OA%3D%3D&%3Bmid=2651233501&%3Bidx=1&%3Bsn=1248f480c592045c0e5e53c6393df046
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

3ABv22Y.png!mobile

1. 序言

工程编译,是Android应用开发工作中的重要一环。而随着工程代码量膨胀,编译耗时也越来越长,拖慢了开发效率。

这个问题在中大型团队中并不少见。以QQ音乐为例,Android工程代码量达到120万行以上,每修改一行代码,都要等待4分钟以上才能在手机上看到改动效果。

为了应对这个问题,我们自研推出了一款 增量编译 组件。经过一年时间的不断优化,组件已经可以支撑团队内的日常开发工作,有效提升了 本地开发场景下的编译效率

本文将会介绍QQ音乐团队在增量编译组件研发上的探索与实践历程。

2. 问题分析

本地开发过程中,我们会不断重复 修改代码-编译工程-安装APK-运行验证 这一过程。

因此,可以从编译与安装两个纬度来分析编译慢的原因。

首先是编译阶段。

其主要流程是,先收集工程中的所有资源文件进行编译,得到资源包以及资源索引类。随后资源索引类会跟随工程的所有代码文件,一起被编译为字节码文件,字节码文件还需要被进一步编译为Dex文件,这样才能被Android虚拟机所识别。

待资源包和 Dex 文件都准备好后,会被打包压缩到一起,执行签名、对齐等流程,最终完成编译,得到一个APK安装包。

在这个过程中,不论是资源编译还是代码编译,耗时都是与待编译的文件数量成 正比 的。我们在开发过程中,一般只会改动 极少数 的代码文件,然后触发编译。理想的情况是,编译工具应当只编译这些被改动的文件。但是由于代码的依赖关系,这在原生工具下很难实现。

Android Gradle Plugin自3.0版本开始,开始废弃compile关键字,并引入implementation关键字来声明依赖,是希望可以从module的粒度,去加快大型项目的编译速度。不过对于一些并未拆分多module的单一工程项目来说,使用效果并不理想。

再来看安装阶段。

安装包首先需要通过ADB工具传输到手机上,然后系统对其进行签名校验。校验成功后,还需要进行一系列文件解压、拷贝的操作。例如拷贝Dex文件、so文件等。

此外,如果是在系统版本为5.0、6.0的手机上,由于系统采用了AOT机制,安装过程中会进行预编译,将Dex中的字节码变成机器码,以提高应用运行时的效率,这就导致了安装耗时进一步被拉长。

可以看到,安装包体积、手机系统版本,都会影响到安装阶段的耗时。

3. 优化思路

根据上述分析,主要有三类解决方案。

工欲善其事,必先利其器,首先可以尝试对工程的构建工具链进行优化。

常见的方式是升级Android Gradle Plugin、Gradle等工具的版本、调整构建参数等。不过实践后发现,他们带来的优化效果并不理想。

当然,除了 Gradle 构建工具外,也可以考虑使用Facebook的Buck作为构建工具。根据官方介绍, 利用多模块、多任务并行编译的思想,可以大幅度缩短编译耗时。

不过对于大型项目来说,要迁移构建工具,成本是极高的。目前使用的众多插件、周边开发工具链,都是基于Gradle体系的,迁移的话就会失去这些功能的支持;此外,如果工程还涉及到其他团队、项目的协作,构建方案也是无法随意更换的。

另外一种思路是,对工程代码进行优化,尽可能 减少参与编译的代码数量

这里可以做的事情很多,比如梳理业务删除冗余代码、进行多工程拆分、实施组件化(模块化)改造等;但是,由于代码耦合深、开发节奏紧等客观因素的存在,代码优化的难度通常比较大,各个方案的实施周期会比较长。所以并不能在 短期 内, 快速 解决编译缓慢的问题。

那么,能不能提供一个编译工具:在本地开发期间,每次仅编译 被改动过的少量代码 ,而且最好可以 跳过 APK的安装过程,仅推送与加载新改动的代码。这样就可以从编译与安装两个纬度,去大幅缩减编译耗时。

这其实就是 增量编译工具的核心思想 。对于工具的接入方来说,不需要大刀阔斧地升级工具链或者进行工程改造,即可在较低的成本下,快速提高本地开发效率。

截止目前,业界主要有两款方案可以参考。

Instant Run 是Google推出的第一代增量编译方案。不过在大型项目中,它带来的提速效果并不明显,甚至在某些场景下会让构建时间变得更长。

首先,在Gradle 4.6以前,如果项目中使用了注解处理器,那么每次代码修改都要进行全量编译。此外,若是修改的类中,包含有 公有静态常量 ,那么也同样会导致本次修改需要进行全量编译。

Instant Run在使用过程中,有时也会遇到一些兼容性问题,但由于它是集成在Android Studio内部的,对于我们来说是一个黑盒,无法自行定位解决问题,只能被动地反馈问题与等待新版本发布。所以综合来看,这个方案并不合适引入。

在最新的Android Studio中,Instant Run已经被废弃,取而代之的,是Apply Changes方案,它是基于JVMTI技术来实现的。不过仅支持 Android 8.0 或者更高版本的手机,实测在工程中带来的提速效果也不明显。

另一个就是阿里推出的Freeline方案了,它可以充分利用缓存文件,在几秒钟内迅速地对代码的改动进行编译并部署到设备上,提速效果十分明显。不过它同样存在着一些不可忽视的问题。首先是不支持Kotlin,这在Kotlin已经被谷歌官宣为Android开发首选语言的今天,是比较致命的。另外,不支持删除带id的资源,否则可能导致资源编译流程出错。

另外一个潜在的问题是,为了确保编译速度,Freeline是牺牲了一部分正确性的。例如,在改动公有静态常量的时候,只会编译对应的类文件,而引用到该常量的其他类,并不会参与编译的。由于常量内联优化的存在,就可能导致这些类在运行时,使用的仍然是旧的值,进而出现改动不生效的问题。

综合上述,目前业界已有的解决方案,并不能满足我们的需求。所以在2019年初,我们开启了增量编译组件的自研之路。

4. 增量编译的诞生

在2019年6月份,增量编译组件完成了首版开发,开始正式接入QQ音乐工程。

接入后,对于本地开发的提速效果是比较明显的。据团队实际数据统计,进行一次全量编译的耗时约为418秒,而增量编译单次耗时仅需13秒。以天为单位计算,每个人花在工程编译上的总时长,由3.95小时,降低至了1.02小时,效率提升达到 74%

AZNNRnn.png!mobile

增量编译组件完全基于Gradle标准,实现为一个 Gradle插件 ,具备良好的 多平台兼容性 ,而且对于目标工程的 侵入性极低 。使用者只需要接入我们的Gradle插件,即可通过执行特定的Gradle任务,进入增量编译模式。

在功能的支持上,组件支持Java、Kotlin等代码文件以及所有类型资源文件的快速编译。在今年年初,加入了DataBinding的增量支持。而且,为了进一步减少使用成本,我们还在最新版本中提供了配套的Android Studio插件,开发者可以通过可视化的方式,更方便的使用组件功能。

下图描述了组件的 整体原理 ,我们将开发周期分为编译期和运行期。

首次编译(亦可称全量编译),需要完整编译工程,得到原始安装包,耗时与原生的打包任务持平。后续再触发编译,将会进入耗时极短的增量编译模式,组件会负责收集改动过的代码进行编译,得到增量产物,并推送到手机上。

运行期则负责将手机上的增量产物进行动态加载运行。

FfUzQrj.png!mobile

在本文的后续内容中,将介绍几个重点模块的实现。

5. 核心原理

代码编译

(1)获取改动文件并进行编译

首先需要考虑的问题是,如何识别出用户改动了哪些文件?

我们的做法是,在每次编译成功后,收集所有工程文件的最后修改时间,保存为一份文件快照。在下次编译开始时,组件会生成最新的文件快照,与上一次的文件快照进行比对,就可以收集到用户改动过的文件了。

为了能够单独编译这些文件,还需要解决类引用的问题。

在首次完整编译工程时,组件会收集所有生成的class文件,放到缓存目录中。在编译被改动的文件时,会调用原生的javac或者是kotlinc程序,将刚才的缓存目录作为classpath传递进去,就可以解决编译时代码引用的问题了。

JVj2qiU.png!mobile

(2)进行代码依赖分析

上文中,提供classpath可以使编译阶段成功执行,却无法确保运行期的代码逻辑是正确的。举个例子,某个类修改了某个方法的参数列表,那么除了这个类需要被编译外,依赖这个类的 其他类 ,也是需要重新编译的。否则,就会在运行期,出现NoSuchMethodException。

yMjIj22.png!mobile

因此,由于代码之间相互依赖关系的存在, 仅仅收集被用户改动的代码 来编译,是不够的。还可能需要找出它的子依赖集,纳入编译范围。

沿着这个思路,还需要考虑两个问题:

  • 如何得到改动类的变化类型? 修改方法内部实现 等类型的改动,是不会影响到其子依赖集的。在确保编译正确的前提下,为了尽可能地减少参与编译的代码数量,我们需要得到被改动类的变化类型,才能够决定是否需要将其子依赖集重新进行编译。

  • 如何得到改动类的子依赖集?这个很好理解,只有计算出某个类的子依赖集,组件才能知道要编译什么。

想获取这两项信息,都需要对类的内部结构进行分析,提取出类名、类的修饰符、成员变量、方法等数据。我们的做法是,引入ASM工具对class文件进行解析,然后将解析出来的信息,保存到自定义的ResolvedClass数据结构中。

y2MVVnF.png!mobile

接下来的解决方案是这样的:

  1. 在全量编译期间,组件会同步启动一个独立的进程,对所有的class文件进行遍历分析,得到对应的 ResolvedClass 信息,并保存在本地文件中。其中,如果发现某个类引用了另一个类,那么就会把当前类的类名,添加到被引用类的子依赖集列表中(resolvedBy字段)。

  1. 触发增量编译后,组件首先编译改动类,得到新的class文件。然后启动代码依赖分析流程,解析出新的 ResolvedClass ,将其与全量编译期解析出来的旧 ResolvedClass 进行比对,就可以得到这个类的改动类型了。

eM32Ajf.png!mobile

  1. 当发现当前类的改动类型在下表中,组件才会获取其子依赖集,启动 第二轮编译 ,得到子依赖集对应的class文件。

ji2UZ3F.png!mobile

通过上面的方式,我们在确保编译正确的前提下,尽可能地减少了需要编译的代码数量。

随后,增量编译期间生成的所有class文件,会被dx工具进一步地编译为Dex文件,然后通过ADB推送到手机上,等待被动态加载。

资源编译

(1)资源增量

这一块的基本思路,与代码增量是类似的。即先收集被改动的资源,然后进行编译。

原生的资源编译流程主要采用的是aapt,或者是 aapt 2 。

一开始,我们工程使用的仍然是 aapt ,基于它去资源增量的难度相对较大。因为 aapt 工具是不支持 单个资源 编译的。Freeline通过修改 aapt 的源码,实现了单个资源的增量功能。不过他们的这部分方案没有开源,并且改动后仍然不支持带ID资源的删除,所以没有考虑在组件中引入。

再来看看 aapt2 。与 aapt 最大的不同在于,它是天然支持单个资源编译的。其内部把资源的打包分成了 编译( compile )与链接( link ) 两步,在编译阶段,负责将单个或者多个资源编译为二进制文件;链接阶段,则负责合并所有二进制文件再打包。

6jeaE3b.png!mobile

于是,我们首先升级工程的工具链,引入了 aapt2 ,然后组件也基于此重新设计了资源增量方案。

在工程首次编译结束之后,组件会将所有编译好的资源二进制文件都收集到一个缓存目录中。后续改动资源时,会先调用 aapt2 的编译功能,将改动的资源编译成为二进制文件。然后将新的二进制文件拷贝到资源缓存目录中,覆盖掉同名文件。

接着,会针对这个目录,采用 aapt2 的链接功能,打包生成最后的增量资源包,并推送到手机上,等待被动态加载。

通过这样改造后,QQ音乐工程中资源增量编译阶段的耗时,由原来的32秒降低到了12秒,效率得到进一步提升。

fMjmmmV.png!mobile

(2)资源ID固定

资源编译过程中,有一个文件是需要特别关注的:R.java文件。

为了让开发者能够在代码中引用资源,资源编译器会在编译的过程中,为每一个资源分配索引ID,并以公有静态常量的方式保存在 R.java 文件中。开发者只需要在代码中通过R.color.text等形式,即可引用到对应的资源。

而编译器编译源代码时,如果发现某处代码引用了常量(同时使用static和final两个关键字来修饰),且该常量为字面值形式的原始数据类型或字符串时,编译器就会将此处的常量引用 替换常量值

也就是说,代码中类似R.color.text的引用,在class文件中都会被替换成为对应的数字。

MnUbemF.png!mobile

资源编译的过程中,资源是按照名称排序后,按序递增分配索引的。如果新增或者删除资源,会导致其后续资源的索引出现错位。

FNzQB3v.png!mobile

在这种场景下,如果某个类引用到 索引变化了的资源 ,就需要重新参与编译。否则,就会在运行时遇到资源引用错乱的问题。

但是这就会导致 大量的类 需要在增量过程中参与编译,和我们的初衷是相违背的。

所以,需要将R.java中的ID进行固定。简单来说,就是使得两次编译之间,对于同一个资源,分配到的ID是不变的。其实在热修复场景下,也具有相同的诉求。对于补丁包,是有严格的大小要求的。如果我们要对资源进行热修复,不可能把所有用到该资源的代码都重新编译纳入补丁包中下发,所以也需要进行资源ID固定。

相对应的解决方案也是业界比较通用的。若尝试输出 aapt2 命令行工具的帮助文档,可以发现有两个参数:

  • --stable-ids :File containing a list of name to ID mapping.

  • --emit-ids :Emit a file at the given path with a list of name to ID mappings, suitable for use with --stable-ids.

因此,我们可以在编译资源的时候,给 aapt2 注入emit-ids参数,在指定文件中输出资源名称到资源ID之间的映射关系。并在下次启动 aapt2 时,通过stable-ids传入刚才的映射关系,达到资源ID固定的效果。

动态加载

(1)代码注入

编译完成后,可以得到若干个增量Dex包,并推送到手机的特定目录下。

那么在运行期,我们需要做的,是干涉原生的类加载流程,使被改动的代码 优先被加载 ,达到改动生效的目的。

先来看看Android原生的类加载流程。

在应用程序启动后,会采用名为PathClassLoader的类加载器,去加载安装包中的Dex文件。需要加载某个类的时候,系统会从前往后依次遍历Dex数组,直到找到对应的类。

aeeuauN.png!mobile

基于此,增量组件会在应用启动的时候,将增量Dex文件,通过反射手段插入 Dex 数组的最前面。后续需要加载某个类的时候,由于系统机制会从前往后遍历,因此会优先从增量的 Dex 中查找并命中改动后的类。需要说明的是,所有增量的 Dex ,会按照生成的时间, 倒序 插入到 Dex 数组中,如inc_3.dex、inc_2.dex、inc_1.dex,这样就可以确保一个类被多次增量修改后,被加载到的总是其最新实现。

QRbiE3J.png!mobile

类改动不生效问题的处理

在第一个版本发布后,我们收到同事的反馈,在Android 7.0或者更高版本的系统上,会 偶现代码改动不生效 的问题。经过分析,可以确保增量的代码是编译成功的,问题是出现在运行时类加载阶段。

这是由于从Android 7.0开始,虚拟机的代码编译策略,发生了变化。

Dex中的指令,首先需要被翻译成为机器码,才能被执行。随着系统版本的更迭,对于 Dex 字节码的编译策略,也有着不同的表现。

n63QriV.png!mobile

在5.0以下的系统中,使用的是Dalvik虚拟机。在应用运行时,每当遇到一个新类,JIT编译器就会对这个类进行即时编译,经过编译后的代码,会被优化成相当精简的原生型指令码,这样在下次执行到相同逻辑的时候,速度会更快。不过由于编译工作是在应用运行过程中进行的,且没有缓存,这就使得应用启动速度较慢,运行效率受到影响,而且耗电较多。

因此,在Android 5.0开始,Google采用ART虚拟机来替代了Dalvik虚拟机。和Dalvik最大的区别在于, ART 虚拟机采用的是AOT提前编译机制。系统在安装应用的时候,会使用自带的dex2oat工具,把安装包中的所有Dex文件进行一次预编译,生成一个可以在本地机器上运行的oat文件。这样后续应用每次运行时,就不需要执行编译了,应用的启动与运行的效率也得到了极大的提升。但是AOT每次执行的时间太长了,给用户直观感受就是安装极慢。

所以,从Android 7.0开始,采用了Hybrid Mode的 ART 虚拟机,它同时支持Interpreter、JIT、AOT三种模式。他们的交替使用,可以达到安装时间、内存占用、电池消耗和性能之间最好的平衡。

在应用运行时,虚拟机会先使用 Interpreter 去解释与执行代码。如果发现热点函数,会启用 JIT 编译器,并将编译结果存储在本地profile文件中;当Android设备空闲或者是充电时,系统会在后台定期针对profile文件执行AOT编译,得到一份“热代码”;

在下一次应用重启时,系统会将编译好的热代码,一次性地插入到类加载器的缓存ClassTable中。后续类加载的过程中,会先从ClassTable中寻找是否有缓存,有的话则直接返回,跳过后续的类查找流程。

JrUNVre.png!mobile

到这里,我们就可以解释,为什么混合编译会引起 偶现 的增量代码改动 不生效 问题了。

若要加载增量改动过的A类,会分为两种情况:

  1. 热代码中 不包含 A类:这种情况是比较理想的,系统由于在ClassTable中无法命中,就会到增量Dex中查找A类,此时增量代码是可以 生效 的。

  2. 热代码中 包含 A类:系统在类加载过程中,会在ClassTable中优先命中改动前的A类,从而导致 增量不生效 的问题。

b6vqqeb.png!mobile

针对这个问题,Tinker的解决方案是,首先复制原生类加载器的Dex数组,去完全新建一个自定义的类加载器。然后把应用进程引用的所有类加载器,都指向自定义的类加载器,负责后续的所有类加载以及补丁代码注入行为。

因为热代码不会被插入到自定义类加载器的ClassTable缓存中,因此后续的补丁代码加载,就不会受到热代码干扰,可以正常生效了。

iINZN3q.png!mobile

不过,增量编译组件是面向本地开发的debug包,所以,也可以采用更为简单的方案:由组件自动在AndroidManifest.xml中指定 android:vmSafeMode="true" 即可。这个开关会停用AOT编译器。热代码不能生成,也就不会遇到上述问题了。

(2)资源注入

资源的动态加载则相对简单。主要是参考Instant Run,通过反射调用AssetsManager的addAssets方法,将增量资源包加载到内存中来,得到新的Resources对象,然后替换掉ActivityThread等所有持有Resources的地方即可。这也是大部分热修复框架中的基本思路。

6. 结语

回顾增量编译组件的实践之路,其实是对于Android应用编译、热修复、字节码插桩、Gradle等技术的综合运用。对于大型工程说,可以快速低成本的实现本地开发效率的提升。

同时,对于编译速度的优化,我们还有几个建议。首先是建议及时升级最新的编译工具链,沿用官方最新的优化成果。并使用Gradle提供的profile构建分析工具,进行针对性的任务分析,解决脚本中一些不合理的耗时。同时,也建议同步进行模块化改造,进行代码分拆等。这一步持续的时间可能较长,但是后期收益不仅仅是编译效率 的提升,还有业务模块级别的代码复用能力提升。

目前,增量编译组件还有部分特性需要进一步开发。如四大组件增量支持、Module增量支持等,关于这一部分原理,将会在后续文章中做进一步介绍。

同时,我们也正在通过实际开发工作场景中暴露出来的问题,不断去优化组件。

待进一步完善后,将会执行组件开源计划。我们期望在组件开源后,可以让其他工程能够做到无缝集成,无需考虑细节实现,即可轻松提升开发效率。

QQ音乐招聘Android/ios客户端开发,点击左下方“查看原文”投递简历~

也可将简历发送至邮箱:[email protected]


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK