Freeline:Gradle工程上如何进行增量编译?

Posted 阿里云云栖号

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Freeline:Gradle工程上如何进行增量编译?相关的知识,希望对你有一定的参考价值。

前言


Freeline最早诞生之初主要是为了支持蚂蚁聚宝的应用架构(mPaaS,插件化架构)的增量编译。


蚂蚁聚宝的android开发团队使用Windows/Linux/Mac的均有,在高配mbp上,改一次代码并编译-安装-运行,大概需要1min+。在非SSD的Windows上,耗时则大于5min。完整地编译整个工程并安装,mbp上需要大于5min,而Windows上,甚至可以达到20min+。编译耗时严重影响了整个团队的开发效率,这也催发了Freeline原型的诞生。


之前在云栖社区发布的Freeline - Android平台上的秒级编译方案主要讲的是Freeline底层的技术原理,不过完稿时间较早(16年2月份),主要都是针对mPaaS架构的内容,与在Github上开源的版本有较多不同,这里主要讲下Freeline是如何支持Gradle工程的增量编译的。


在具体展开介绍之前,先来看下Freeline的开发历程以及社区中几个常见的加速构建方案的对比。


Freeline发展


  • 2015年底,聚宝内部诞生IncrementalBuilder

  • 2016.02 正式命名Freeline,对内发布,支持mPaaS框架

  • 2016.05 阿里内部开源,支持Gradle

  • 2016.08 正式对外发布


开源后主要还是在提升兼容性与持续开发新功能的阶段,靠用户的自发推广,慢慢地积累了1000+ Star,目前也已经有不少App Store排行前列的应用选择接入Freeline来改善日常开发的体验。


Freeline除了持续提高兼容性之外,也陆续支持了社区中呼声较高的retrolambda以及注解的增量编译,社区中也有第三方开发的Android Studio插件,与日常开发流程更加无缝贴合。


加速构建方案对比


先来对比一下社区中常见的几款加速编译的方案:


Instant-Run


  • Pros


  • Google官方支持的增量编译方案,随着Android Studio的迭代持续优化

  • 相对来说更加稳定,零配置,基本无侵入性影响

  • 几秒内可以完成编译,速度非常快


  • Cons


  • 对于可以修改的地方有局限性,具体可以参考官方文档

  • 除了资源修改之外,修改Java文件会重启整个应用,从Launcher Activity重新进入,如果是在开发一个层级较深的UI页面的话,使用起来不方便

  • 增量过的代码不支持debug

  • 对于复杂的工程结构支持程度不高

  • 不支持Kotlin


Buck/okbuck


  • Pros


  • Facebook出品的构建工具,支持多种语言/平台的构建

  • okbuck是一个帮助gradle工程快速集成buck的工具,目前转入uber进行维护

  • 多线程并发编译,充分利用缓存,近似增量编译

  • 目前支持了retrolambda与注解(?)


  • Cons


  • 对于有历史的大型工程接入成本较高,需要较高的时间成本

  • 构建过程与gradle不同,所以第一次接入可能会存在不少的问题需要解决

  • 安装apk的时间耗时较久

  • 不支持Kotlin

  • 不支持Windows


JRebel for Android


  • Pros


  • 在Instant Run之前就已经存在的Android平台上的增量编译解决方案,zeroturnround有大量JVM上热部署的实践积累

  • 零配置,只需安装Android Studio插件,立刻可以运行

  • 相比Instant Run支持的范围广,参考链接

  • 支持lambda与部分流行注解库

  • 字节码层面的动态加载,理论上支持几乎所有基于JVM语言,包括Kotlin、Groovy等


  • Cons


  • 收费,价格较高,可以参考链接

  • 只有收费版才能debug,有专门的debug工具

  • crash后需要重新全量编译,单次全量编译、安装的速度非常慢


Freeline


  • Pros


  • 支持大多数场景的增量编译

  • 支持retrolambda与注解

  • 支持so动态替换

  • 支持Windows/Linux/macOS

  • App crash后,仍然可以通过增量编译来修复

  • 大多数情况下增量编译可以在10s内完成


  • Cons


  • 初次接入可能存在一定的问题,需要稍微花点时间来解决

  • 在简单的工程上,与其他构建方案相比,没有明显的优势

  • 不支持删除带id的资源,会报错

  • 不支持Kotlin


LayoutCast也是一个常用的方案,不过对多module的工程支持不足,算是一个增量编译的工具原型,通常都需要改造一下才能应用起来,因此就不加入上面的比较了。


Freeline使用


以下是命令行版本,以Linux开发环境为基础,Win下替换相关命令即可。


  • 在根目录的build.gradle中添加classpath 'com.antfortune.freeline:gradle:${latest-version}'

  • 在主工程(application工程)的build.gradle中添加apply plugin: 'com.antfortune.freeline'

  • ./gradlew initFreeline -Pmirror:初始化Freeline相关依赖

  • 日常开发:

  • python freeline.py:Freeline会自动切换全量与增量编译模式

  • python freeline.py -f:强制进行全量编译


当然,也可以直接安装第三方插件,在Android Studio里的plugins中,搜索freeline并安装即可,就可以使用快捷键来迅速进行编译开发啦。


Freeline原理


在解析Freeline如何支持Gradle工程的增量编译前,先来回顾一下Freeline的原理。Freeline本质上是一个热补丁方案,将修过过的*.java和资源文件分别打成dex和pack,然后通过socket传输到手机上,在运行期动态加载生效。具体可以阅读Freeline - Android平台上的秒级编译方案。


类似的热补丁方案的开源实现有Nuwa,以及未开源的QQ空间的超级补丁包。蚂蚁聚宝在线上也采用类似的方案来实现热补丁,以及A/B test。


Gradle是如何构建Android工程的?


每个在Android Studio中新建的Android工程,在根目录下都会有个build.gradle文件,定义了buildscript,如下:


buildscript {

    repositories {

        jcenter()

    }

    dependencies {

        classpath 'com.android.tools.build:gradle:2.2.0'


        // NOTE: Do not place your application dependencies here; they belong

        // in the individual module build.gradle files

    }

}


其中,com.android.tools.build:gradle:2.2.0就是Google官方提供的Gradle plugin,专门用来处理Android工程的构建流程,插件里声明了许多我们经常可以在Gradle Console中看到的task。对于Gradle Task来说,他通常都会有input和output,并且每个task前后都会有依赖。整个编译流程就像工厂流水线一样,从代码源文件开始,逐渐装配,最终生成“产品”apk。我们常见的Gradle编译任务:assemble,其英文本意就是工业上装配的意思。


Android Gradle Plugin本身也是Android开源代码的一部分,可以在线浏览源代码[需自带梯子],目前最新的版本为2.2.0,在线浏览的链接:https://android.googlesource.com/platform/tools/base/+/gradle_2.2.0


了解到以上这些定义之后,我们就可以知道要对Android的构建流程或者其产物做修改,其实就是要去hook这些构建任务,来修改他们的input或者output,从而达到我们想要的目的。


Freeline全量编译流程


Freeline定制了自己的全量和增量的编译流程。当Freeline监测到build.gradle或者AndroidManifest.xml变化了,会自动进入全量编译。


下图是Freeline的全量编译流程图:




全量编译流程拆解:


  • generate-file-stat:生成当前工程java文件和资源文件的修改时间与文件大小的缓存,便于后面进行对比监测文件是否修改

  • read-project-info:根据build.gradle的配置,预先生成项目描述文件,缓存在~/.freeline/cache

  • gradle-full-build-with-freeline:执行gradle命令对工程进行编译

  • clean-all-cache:清除之前freeline编译遗留的所有缓存文件

  • install-apk:安装生成的apk到设备上

  • build-base-res:使用FreelineAapt打出基础资源包

  • generate-pro-info:生成工程的依赖信息并缓存

  • append-file-stat:检查是否有新增的module,如果有的话将新增的module的文件状态添加到generate-file-stat生成的缓存文件中


如何绕过verify校验?


Freeline在代码增量上采用了DexPathList植入dex的方案,已有不少文章有过相关介绍。如何绕过校验防止出现运行期crash有两种方案,一种是编译期植入代码,另一种是hook绕过。第二种方案的实现可以参考这篇文章QFix探索之路——手Q热补丁轻量级方案,Github上也有开源的实现QFix。Freeline目前采用的是第一种方案,在编译期植入另外一个dex的类。


上面已经讲到Gradle的构建流程是由一个个的task按照依赖顺序进行执行的,因此我们只要能够找到相应的task入口去hook所有javac编译生成的class文件与jar文件,并对其做出相应的修改,就可以做到运行期绕过校验。


在Android Gradle Plugin 1.5.0以前,根据是否开启multiDex,插入的task会有所变化,如图所示:


Freeline:Gradle工程上如何进行增量编译?


在1.5版本以后,因为引入了Transform API的概念,所以task也有了较大的变化。不仅如此,minSdkVersion是否是5.0以前的,也会影响构建流程的task,原因是Google允许开发者在开发时,通过productFlavor设置最低sdk版本为21,以此来减少编译时间(主要是减少merge dex消耗的时间),具体可以参考这个链接:https://developer.android.com/studio/build/multidex.html#dev-build


因而,在1.5版本以后,Freeline会如图这样影响构建流程:


Freeline:Gradle工程上如何进行增量编译?



注意,以上流程为不开启混淆的情况。Freeline目前只支持debug buildType,并且不支持混淆。


对task进行hook之后,我们植入的hackClassesBeforeDex会对每个拿到的class或者jar通过ASM做代码注入。ASM是一个通用的Java字节码操作与分析框架。它可以直接以二进制的形式,直接修改现用的class文件。


Freeline在每个类(不继承Application)的构造函数注入了如下一段代码,其中ClassVerifier这个类来自一个独立的dex。


Freeline:Gradle工程上如何进行增量编译?


通过ASM的API,可以很简单地实现类的修改:


Freeline:Gradle工程上如何进行增量编译?


题外话,其实利用ASM还可以做非常多的额外的工作,包括各种编译期的代码生成,ASM的原理也让它不需要生成新的java文件再重新编译,而是直接修改已经存在的class文件。


当然,也有很多人会问不熟悉字节码的话,是不是学习的曲线会很大?其实不然,ASM已经提供给你现成的工具jar包,你可以利用这个工具,直接从class文件dump出ASM的代码,官方也提供了相应的问题说明:http://asm.ow2.org/doc/faq.html#Q10


举个简单的例子:


Freeline:Gradle工程上如何进行增量编译?


通过命令行工具,我们可以将生成上述Java代码对应的ASM生成代码。


Freeline:Gradle工程上如何进行增量编译?


依赖查找


Freeline的增量编译过程中,需要添加完整的依赖路径,这里的依赖就包括了Jar依赖以及资源路径依赖。同样,我们从构建任务入手。


Jar依赖


上文我们提到了Freeline会去hook编译流程,植入代码,实际上在那个步骤中我们就会拿到所有的Jar文件,只要将他们都存入List中,在编译流程结束后存入文件缓存即可,可以轻松地解决Jar依赖查找的问题。


资源依赖


通过查找源码,我们会发现在每个module的构建任务中,会有一个mergeResources的任务。实际上Android在每个module的编译流程中,会将module的资源与module依赖的aar的资源,合并到一起,并打出新的aar或者最终的apk。因此,我们也可以通过hook这个mergeResources的任务,拿到所有的资源依赖路径。


这里也有一种特殊情况需要处理,如果module没有添加任何依赖,那么这个module是不会存在mergeResources这个任务的。但是这个module同时也有可能存在一些编译期生成的资源,比如RenderScript会在编译期生成raw资源,存在build/generated/res/rs这个路径,要注意对这种case进行处理,以免后面出现编译错误。


Freeline全量资源编译


Gradle的构建流程中,会将主module的资源跟所有aar的资源包合并到一个目录中(build/intermediates/res/merged),这个目录就是最终aapt打资源包时所使用的目录。不仅如此,在合并资源的过程中,各种values的xml会被合并到一份values.xml中,这些xml文件中的一些不规范使用,比如两个中夹杂了字母文字等,都会被过滤掉。而这些含有不规范使用的xml文件,用原生的aapt其实是无法编译通过的!


Freeline在全量构建时,处理打出apk之外,还会先打一个基线资源包。为什么不能直接复用Gradle构建流程中生成的资源包,而需要额外再打一个呢?原因是刚才也有提到的,合并所有资源目录后产生的合并目录的目录结构以及文件跟我们工程里原有的资源目录是不一致的,尤其是values文件,合并后的资源目录只有一个values.xml,而我们工程目录里通常会有color.xml、strings.xml、styles.xml等文件。如果要在增量编译的流程里对这些文件进行合并的话,实际上是比较耗时,而且不一定准确的,所以我们选择了在构建流程之外,自己再打一个资源包作为基线资源包,并在此基线资源包的基础上进行增量资源(在5.0+的设备上,资源是以目录的形式存在的)。


额外打一个完整的资源包就还有一个问题是需要解决的,resource id的变化如何解决?


其实aapt是支持通过传入一个public.xml的文件,来固定所有已经产生的id的,如下图:


Freeline:Gradle工程上如何进行增量编译?


我们需要做的就是在Gradle构建过程里生成一份这样的文件即可。幸运的是,aapt本身就提供了参数来实现这个需求。-P specify where to output public resource definitions,从参数描述上可知,我们只需在打包参数里加入-P <public-xml-path>,就可以得到我们想要的文件了。我们可以通过Freeline Gradle Plugin,在构建流程中植入这个参数。


project.android.aaptOptions.additionalParameters("-P", publicKeeperGenPath)`


有了可以固定resource id的public.xml文件以及我们在构建流程中hook得到的所有资源目录,我们就能够构造aapt的打包参数,打出基线资源包啦。


一个需要注意的地方,aapt打包参数中需要加入--no-version-vectors,以免自动生成vector xml的拷贝,否则将导致在5.0以下的机型增量资源时出现crash。


因为Freeline单独打了一个全量的基线资源包的关系,所以第一次进行资源增量的时候,会增加一个较为耗时的传输全量资源包的过程,对于那些有非常多资源的工程(10M~20M+),这个传输过程可消耗10s+。这也从侧面反映了,Freeline使用增量资源包对整个编译过程的加速作用。


Freeline增量编译流程


Freeline:Gradle工程上如何进行增量编译?


如何实现新增资源id的增量编译?


对于单module的工程,其实非常好办,直接按照自己的资源目录来打资源包或者增量资源包即可,将生成的新的R.java,跟其他有改动的java代码一起打成dex,传输到手机上,即可实现增加资源id的增量编译。


但是多module的工程如何解决这个问题呢?多个module意味着会有多个不同package的R.java。前面我们有提到,Freeline在运行时做资源替换的时候,实际上是替换成了我们自己的资源目录,然后通过覆盖旧资源的形式,来实现增量的资源更新。这就意味着,我们不会在增量编译的流程中,进行多次资源的编译,而是类似我们打资源基线包的方式,一次编译打出增量的资源包。这样的方式可以直接打到我们的目的,而且不会浪费太多编译时间在多次资源打包中。


但这就存在一个问题,如果子module新增id了怎么办?因为只是基于主工程打一次资源包的话,新增的id都会进入到主工程的R.java中。


为了解决这个问题,Freeline用了一个比较tricky的方法。在编译增量资源包时,如果发现同时存在多个module的资源变更的话,Freeline会将有变更的module的R.java单独拷贝出来,并对文件做修改,让其继承主module的R.java,再将新增的R.java文件连同变更的java文件一起,打出dex,一起传输到设备上,这样子就能达到我们支持新增资源id的目的的。


如图,以下是主module的R.java,新增的资源id会进入主module的R.java,同时主module的R.java的final修饰也在编译期被去掉了。


Freeline:Gradle工程上如何进行增量编译?


而子module的R.java会在编译期被动态修改,继承主module的R.java,如图:


Freeline:Gradle工程上如何进行增量编译?


APT增量编译


Freeline开源后被问及最多的问题是:什么时候能够支持ButterKnife/AndroidAnnotation呢?


使用各类注解库的时候,通常都会需要依赖android-apt这个Gradle插件。android-apt会在编译期对javac编译过程加入相关的APT参数,使得在Gradle的构建过程中能够动态生成代码并加入编译流程。因此,Freeline需要做的就是在全量编译流程中,去获取到APT参数,然后加入到javac的增量编译过程中即可。


android-apt的源码开放在bitbucket上:https://bitbucket.org/hvisser/android-apt 。核心代码不过百行,主要的处理逻辑在于hook编译流程以及APT参数的拼接。


Freeline:Gradle工程上如何进行增量编译?


根据android-apt的逻辑,Freeline可以从javac编译任务中提取相关的APT参数,并保存到配置文件中。然后在增量编译的过程中,在javac过程里植入相应的参数即可。




注:在Android Gradle 2.2+开始,Android官方的Gradle插件终于提供了APT支持了。具体可以参考这篇博客。Freeline目前只支持了android-apt插件,后续也会加入对官方APT插件的支持。


retrolambda的增量编译


跟解决APT的增量编译一样,我们也首先来翻下retrolambda的Gradle插件源码。retrolambda的原理是将JDK8编译出来的代码,翻译到低版本的Java字节码,使得开发者可以使用lambda表达式写出能够在Android设备上运行的代码。实际上我们不需要理解具体的工作原理,只需要弄清楚其Gradle插件的执行流程即可。


跟踪一下插件代码的执行流程,我们发现最后进入了RetrolambdaExec.groovy这个类,实际上还是构造了一个可执行命令,并在javac编译流程结束后执行,如图。




因此,我们要做的其实也非常简单,在Freeline的增量编译流程中插入一个retrolambda的任务即可,模仿其构造参数的方法,实现一个python版本的简易插件,具体代码不再展开。


注:Freeline目前还不支持启用jack进行编译。


TODO


Freeline本质上是一个hack方案,所以还是会存在各种潜在的兼容性问题。所以,Freeline接下来还是会持续解决这些兼容性问题。


除此之外,我们还会对社区中流行的各种开发解决方案提供增量编译的支持,包括databinding以及插件化架构工程等,以及陆续完善Freeline相关的各类中文文档。


从目前收到的各个开发团队的反馈来看,Freeline也已经日趋稳定,显著地提高了Android工程师们的开发效率~


最后,欢迎感兴趣的团队接入使用,如果你也喜欢Freeline的话,可以给我们的项目加个star:https://github.com/alibaba/freeline

以上是关于Freeline:Gradle工程上如何进行增量编译?的主要内容,如果未能解决你的问题,请参考以下文章

Android工程运用阿里的freeline快速编译

Android 秒级编译 Freeline

Freeline - Android平台上的秒级编译方案

Android Freeline加速编译App方案 使用和总结

Freeline 让AndroidStudio快的飞起来

Freeline 让AndroidStudio快的飞起来