QQ 音乐 Android 编译提速之路,腾讯 T2 大牛亲自讲解
首先是编译阶段。
其主要流程是,先收集工程中的所有资源文件进行编译,得到资源包以及资源索引类。随后资源索引类会跟随工程的所有代码文件,一起被编译为字节码文件,字节码文件还需要被进一步编译为 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%。
增量编译组件完全基于 Gradle 标准,实现为一个 Gradle 插件,具备良好的多平台兼容性,而且对于目标工程的侵入性极低。使用者只需要接入我们的 Gradle 插件,即可通过执行特定的 Gradle 任务,进入增量编译模式。
在功能的支持上,组件支持 Java、Kotlin 等代码文件以及所有类型资源文件的快速编译。在今年年初,加入了 DataBinding 的增量支持。而且,为了进一步减少使用成本,我们还在最新版本中提供了配套的 Android Studio 插件,开发者可以通过可视化的方式,更方便的使用组件功能。
下图描述了组件的整体原理,我们将开发周期分为编译期和运行期。
首次编译(亦可称全量编译),需要完整编译工程,得到原始安装包,耗时与原生的打包任务持平。后续再触发编译,将会进入耗时极短的增量编译模式,组件会负责收集改动过的代码进行编译,得到增量产物,并推送到手机上。
运行期则负责将手机上的增量产物进行动态加载运行。
在本文的后续内容中,将介绍几个重点模块的实现。
[](
)5. 核心原理
======================================================================
[](
)代码编译
[](
)(1)获取改动文件并进行编译
首先需要考虑的问题是,如何识别出用户改动了哪些文件?
我们的做法是,在每次编译成功后,收集所有工程文件的最后修改时间,保存为一份文件快照。在下次编译开始时,组件会生成最新的文件快照,与上一次的文件快照进行比对,就可以收集到用户改动过的文件了。
为了能够单独编译这些文件,还需要解决类引用的问题。
在首次完整编译工程时,组件会收集所有生成的 class 文件,放到缓存目录中。在编译被改动的文件时,会调用原生的 javac 或者是 kotlinc 程序,将刚才的缓存目录作为 classpath 传递进去,就可以解决编译时代码引用的问题了。
[](
)(2)进行代码依赖分析
上文中,提供 classpath 可以使编译阶段成功执行,却无法确保运行期的代码逻辑是正确的。举个例子,某个类修改了某个方法的参数列表,那么除了这个类需要被编译外,依赖这个类的其他类,也是需要重新编译的。否则,就会在运行期,出现 NoSuchMethodException。
因此,由于代码之间相互依赖关系的存在,仅仅收集被用户改动的代码来编译,是不够的。还可能需要找出它的子依赖集,纳入编译范围。
沿着这个思路,还需要考虑两个问题:
如何得到改动类的变化类型? 修改方法内部实现等类型的改动,是不会影响到其子依赖集的。在确保编译正确的前提下,为了尽可能地减少参与编译的代码数量,我们需要得到被改动类的变化类型,才能够决定是否需要将其子依赖集重新进行编译。
如何得到改动类的子依赖集? 这个很好理解,只有计算出某个类的子依赖集,组件才能知道要编译什么。
想获取这两项信息,都需要对类的内部结构进行分析,提取出类名、类的修饰符、成员变量、方法等数据。我们的做法是,引入 ASM 工具对 class 文件进行解析,然后将解析出来的信息,保存到自定义的 ResolvedClass 数据结构中。
接下来的解决方案是这样的:
在全量编译期间,组件会同步启动一个独立的进程,对所有的 class 文件进行遍历分析,得到对应的 ResolvedClass 信息,并保存在本地文件中。其中,如果发现某个类引用了另一个类,那么就会把当前类的类名,添加到被引用类的子依赖集列表中(resolvedBy 字段)。
触发增量编译后,组件首先编译改动类,得到新的 class 文件。然后启动代码依赖分析流程,解析出新的 ResolvedClass,将其与全量编译期解析出来的旧 ResolvedClass 进行比对,就可以得到这个类的改动类型了。
当发现当前类的改动类型在下表中,组件才会获取其子依赖集,启动第二轮编译,得到子依赖集对应的 class 文件。
通过上面的方式,我们在确保编译正确的前提下,尽可能地减少了需要编译的代码数量。
随后,增量编译期间生成的所有 class 文件,会被 dx 工具进一步地编译为 Dex 文件,然后通过 ADB 推送到手机上,等待被动态加载。
[](
https://blog.csdn.net/u012165769/article/details/109540632)资源编译
[](
)(1)资源增量
这一块的基本思路,与代码增量是类似的。即先收集被改动的资源,然后进行编译。
原生的资源编译流程主要采用的是 aapt,或者是 aapt2 。
一开始,我们工程使用的仍然是 aapt,基于它去资源增量的难度相对较大。因为 aapt 工具是不支持单个资源编译的。Freeline 通过修改 aapt 的源码,实现了单个资源的增量功能。不过他们的这部分方案没有开源,并且改动后仍然不支持带 ID 资源的删除,所以没有考虑在组件中引入。
再来看看 aapt2。与 aapt 最大的不同在于,它是天然支持单个资源编译的。其内部把资源的打包分成了 编译(compile)与链接(link) 两步,在编译阶段,负责将单个或者多个资源编译为二进制文件;链接阶段,则负责合并所有二进制文件再打包。
于是,我们首先升级工程的工具链,引入了 aapt2,然后组件也基于此重新设计了资源增量方案。
在工程首次编译结束之后,组件会将所有编译好的资源二进制文件都收集到一个缓存目录中。后续改动资源时,会先调用 aapt2 的编译功能,将改动的资源编译成为二进制文件。然后将新的二进制文件拷贝到资源缓存目录中,覆盖掉同名文件。
接着,会针对这个目录,采用 aapt2 的链接功能,打包生成最后的增量资源包,并推送到手机上,等待被动态加载。
通过这样改造后,QQ 音乐工程中资源增量编译阶段的耗时,由原来的 32 秒降低到了 12 秒,效率得到进一步提升。
[](
)(2)资源 ID 固定
资源编译过程中,有一个文件是需要特别关注的:R.java 文件。
为了让开发者能够在代码中引用资源,资源编译器会在编译的过程中,为每一个资源分配索引 ID,并以公有静态常量的方式保存在 R.java 文件中。开发者只需要在代码中通过 R.color.text 等形式,即可引用到对应的资源。
而编译器编译源代码时,如果发现某处代码引用了常量(同时使用 static 和 final 两个关键字来修饰),且该常量为字面值形式的原始数据类型或字符串时,编译器就会将此处的常量引用替换为常量值。
也就是说,代码中类似 R.color.text 的引用,在 class 文件中都会被替换成为对应的数字。
评论