QQ音乐Android编译提速之路

介绍QQ音乐团队在增量编译组件研发上的探索与实践。java

1. 序言

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

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

为了应对这个问题,咱们自研推出了一款增量编译组件。通过一年时间的不断优化,组件已经能够支撑团队内的平常开发工做,有效提高了本地开发场景下的编译效率缓存

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

2. 问题分析

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

所以,能够从编译与安装两个纬度来分析编译慢的缘由。app

首先是编译阶段。框架

其主要流程是,先收集工程中的全部资源文件进行编译,获得资源包以及资源索引类。随后资源索引类会跟随工程的全部代码文件,一块儿被编译为字节码文件,字节码文件还须要被进一步编译为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数据结构中。

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

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

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

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

经过上面的方式,咱们在确保编译正确的前提下,尽量地减小了须要编译的代码数量。

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

资源编译

(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文件中都会被替换成为对应的数字。

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

在这种场景下,若是某个类引用到索引变化了的资源,就须要从新参与编译。不然,就会在运行时遇到资源引用错乱的问题。

可是这就会致使大量的类须要在增量过程当中参与编译,和咱们的初衷是相违背的。

因此,须要将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数组,直到找到对应的类。

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

类改动不生效问题的处理

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

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

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

在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中寻找是否有缓存,有的话则直接返回,跳事后续的类查找流程。

到这里,咱们就能够解释,为何混合编译会引发偶现的增量代码改动不生效问题了。

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

  1. 热代码中不包含A类:这种状况是比较理想的,系统因为在ClassTable中没法命中,就会到增量Dex中查找A类,此时增量代码是能够生效的。
  2. 热代码中包含A类:系统在类加载过程当中,会在ClassTable中优先命中改动前的A类,从而致使增量不生效的问题。

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

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

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

(2)资源注入

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

6. 结语

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

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

目前组件已经接入QQ音乐、全民K歌等团队中应用,并已在公司范围内进行开源。增量编译组件还有部分特性须要进一步开发。如四大组件增量支持、Module增量支持等。同时,咱们也正在经过实际开发工做场景中暴露出来的问题,不断去优化组件。

待进一步完善后,将会执行组件外部开源计划。咱们指望在开源后,能够帮助更多有须要的团队,可以作到无缝集成,无需考虑细节实现,便可轻松提高开发效率。

QQ音乐招聘Android/iOS客户端开发,点击 这里 投递简历~
也可将简历发送至邮箱:tmezp@tencent.com