有赞 Android 编译进阶之路 —— 增量编译提效方案Savitar

前言

在前段时间的有赞移动沙龙中给大家分享了有赞移动 Android 团队对于编译提效的实践,会上很多小伙伴对这部分十分感兴趣,但由于时间关系没有能进行一些细节上的交流,所以会后我们整理了两篇文章分享给大家。关于第一部分全量编译提效可以阅读我们小伙伴分享的文章,今天给大家带来第二部分:增量编译提效方案Savitar。

背景

编译慢一直都是成熟 Android 团队难以回避的问题。有赞零售 Android 团队随着业务的发展,项目也到了一个比较大的规模:整个工程有 25 个业务模块,拥有 45W+ 行源代码(Java + Kotlin)以及多个构建 Flavor。小伙伴在进行需求开发时,平均的增量编译构建时间达到了两分钟,再加上一些 Gradle 配置与APK安装过程,基本上验证一行代码的修改需要近三分钟(MacBook Pro 13-inch, 2016, i5-8G),这样的情况大大降低了团队的开发效率。为了解决这个问题,我们进行很多的探索与尝试,由此就诞生了 Savitar。

方案探索

在 Savitar 诞生之前,我们曾尝试在社区中寻求解决方案,希望通过接入某一个框架,达到在对工程结构不进行大面积改造的前提下,把增量编译运行时间降低到 30 秒左右的目标,并且使用者不需要进行复杂的配置或者改变自己的开发习惯。带着这个目标调研了很多方案,其中就有 BUCK、Freeline、InstantRun 等知名框架。

调研结果

通过调研之后,了解了每个框架能够解决的问题和一些不满足我们需求的地方:

BUCK 自身有强大的构建系统,通过增量构建缓存机制,可以有效提升编译的速度,但是其使用和配置过于复杂,对于工程的入侵比较大,且对于一些 Databinding、 Kotlin 等 Android 的特性支持还有欠缺。

Freeline 以其极快的部署速度出名,但对我们来说致命缺点是不支持 Kotlin。

InstantRun 是 Google 推荐的加速方式,拥有最全面的支持性,但由于我们是多进程的工程,并且 InstantRun 在编译时的一些准备 Task 也会消耗一些时间,在实践过程中发现加速并不明显。

由于篇幅关系在这里就不细致展开对每一个框架的解析,有兴趣的同学可以通过每个框架的官网进行了解。最后这几种方案都没有采用,决定自己探索开发解决方案。但是调研的过程并非全无收获,从几个方案中我们发现针对于增量编译加速场景,大家都是遵循 按需编译,动态加载 的原则,将编译与安装的过程进行细致拆分,把编译量降低到最小,再通过去除 APK 耗时的安装过程,从而提升整个增量编译安装运行的速度。我们也朝着这个方向,并结合我们的实际场景最终完成了 Savitar 方案。

方案实现

Savitar 是有赞 Android 团队增量编译提效方案,它能够有效减少模块修改编译时间,包含配套 IDE 插件,使用方便。

类别 支持内容

代码

Java、Kotlin

资源

layout、values、assets、images

扩展

GUI界面

其他

调试、多分支管理(基于 Git)

下面会从 Savitar 的设计与每个部分实现展开,描述我们是如何一步一步完成 Savitar 并解决 Android 增量编译问题。

结构设计

structure1

如图所示,Savitar 整体分成四个部分

  • GUI 插件部分:面向使用者的 GUI 界面,内部包含了可运行 Jar(以下简称 Runner)的自动更新、各种检查任务、编译脚本调用执行
  • Runner 部分:一个 Jar 包,包含 Savitar 核心逻辑代码,完成修改获取、脚本生成、编译执行等任务
  • 工程支持部分:一个 Gradle 插件,完成对工程信息的获取和产物加载代码的插入
  • 外部依赖部分:完成整个流程所需要的外部依赖程序

下面是整体运行的流程图,描述了从代码修改到完成修改产物加载运行的过程

flow1

  • 获取改动信息:获取代码和资源修改,是整个过程的前提
  • 获取工程信息:获取当前工程的依赖信息,目录信息和 Git 信息,为后续编译做准备
  • 编译生成产物:进行代码、资源编译,生成 Dex 产物和 Apk 产物
  • 重启加载产物:完成对编译产物的加载运行,完成整个加速过程

下面将从各个子流程出发,详细介绍内部实现

改动获取

改动获取是 Savitar 最基础但是十分重要的部分,是后续过程生成正确产物的前提。

在实现的过程中,需要考虑以下几个问题:

  • 如何正确获取本地修改文件的信息
  • 如何支持多 Flavor
  • 如何支持多分支切换

本地改动获取

Git 是现在广泛使用的代码版本管理工具,在 Git 诸多能力中,就包含改动检测。于是我们一开始决定使用 Git 获取文件改动信息。我们的需求是获取修改文件的路径,这个可以通过一个简单的 Git 命令获取到

$ git diff --name-only ${上次成功构建的commitId} HEAD

其中上次成功构建的commitId会在成功执行 Gradle 编译命令后记录,作为一个 Git 改动比较的基线,如果后面从远端拉取了一些代码到本地,就可以通过这个基线得出改动的文件信息。当这个 commitId 为空时,可以获取到当前分支本地改动的信息。

但是 Git 获取改动存在一个问题,当本地有没有添加到版本管理的新增文件时,通过 git diff 命令无法获取到新增文件的信息,并且在对于本地正在修改的文件,Git 命令始终会返回这些文件,就算是这些文件已经包含在上次全量编译产物中。所以 git diff 的结果并不是最佳的改动范围结果,于是我们继续寻找更好的方案。后来选择了社区中成熟的文件修改监控工具 —— Watchman,它可以对某个文件夹下的文件改动监控,并支持使用命令获取修改的文件的路径信息,这个能力满足对于文件修改获取的要求。Watchman 可以通过下面的方式获取改动文件信息

// 监控一个文件夹
$ watchman watch ${文件夹}
// 获取改动文件
$ watchman -j > ${diff信息保存文件} <<-EOT
["query", "${文件夹}", {
    "since": "${上次修改时间}",
    "expression": ["exists"],
    "fields": ["name"]
}]
EOT  

修改信息会以 JSON 格式保存在${diff信息保存文件}中。

支持多分支切换

Watchman 似乎可以替代 Git 完成改动获取的工作,但在实践中我们又发现了新的问题:在多分支切换的情况下面,从 A 分支切换到 B 分支,然后再从 B 分支切换回来,没有修改一行代码,但 Watchman 会产生 A,B 之间差异文件的改动记录,此时 Watchman 的 diff 集合是不准确的,但 Git 就可以得出正确的修改记录,于是,结合两个工具的优势,得出获取改动的逻辑流程:

flow2

通过上面的流程,可以准确获取到本地修改文件的信息。

Flavor 过滤改动文件

当得到了本地修改的文件之后,是否就可以直接以这些文件进行下一步呢?答案是:NO!因为得到的修改信息有些可能是当前不需要的,例如我们客户端存在 Pad 和 Phone 的 Flavor,在运行 Phone 的时候 Pad 的下面的修改是不需要的,所以在上面流程的最后,还需要添加一个过滤 Flavor 的流程,最终的流程如下:

flow3

由此就实现了改动的获取,获取到本地的改动之后,还会进行不同文件类型的信息分类存储,为后面不同文件的编译做好准备。

工程信息获取

获取改动信息之后,需要完成这些改动文件的产物生成过程。本地的改动中会包含 Java、Kotlin 源代码改动信息,还有 Xml,图片等资源的改动信息,这些文件生成产物的方式是不一样的,各自使用的工具以及需要的依赖也不同,所以,在真正编译之前,还需要获取到编译过程中各种依赖信息和工程信息。

需要获取的信息:

  • 编译依赖信息:包括全量编译产物目录、上个修改编译产物目录、三方库依赖等
  • 工程信息:包括各个模块包名、当前 Flavor、sourceSet、工程路径等

编译依赖获取

以 Java 文件编译为例子,在进行一个 Java 编译时,需要为这个编译过程提供当前 Java 文件中所引入的所有依赖配置,不管是本地的 Java 文件还是来自于三方库中的 .class。

对于本地的 Java 文件,只需要将工程下面所有的模块下面的 build 目录收集起来,传递到编译的 classpath 中即可。

对于三方库依赖,可以在工程目录下 .idea/libraries 文件夹中获取到当前工程所有依赖的三方库信息

pic1

下面是android.arch.core:common:1.1.0的例子,依赖的信息会以 Xml 的形式存储,包含 Jar 或者 AAR 的地址信息。

<component name="libraryTable">  
  <library name="Gradle: android.arch.core:common:1.1.0@jar">
    <CLASSES>
      <root url="jar://$USER_HOME$/.gradle/caches/modules-2/files-2.1/android.arch.core/common/1.1.0/8007981f7d7540d89cd18471b8e5dcd2b4f99167/common-1.1.0.jar!/" />
    </CLASSES>
    <JAVADOC />
    <SOURCES>
      <root url="jar://$USER_HOME$/.gradle/caches/modules-2/files-2.1/android.arch.core/common/1.1.0/f211e8f994b67f7ae2a1bc06e4f7b974ec72ee50/common-1.1.0-sources.jar!/" />
    </SOURCES>
  </library>
</component>  

从 Xml 中把需要的信息解析出来,这样就可以获取到所有的三方依赖了,再把 Jar 地址信息传递到编译的 classpath 中,完成对于三方库的依赖链接。

工程信息获取

下面是对工程信息的抽象类图,里面包含所有需要获取的工程信息,这些信息是帮助完成编译、产物加载甚至是前面修改获取的必要信息。

class1

  • MakeParam:信息集合保存类
  • ProjectParam:保存主工程信息,包括所有需要的路径、主包名、启动的 Activity、资源 ID 固定之后保存文件路径、Android SDK 编译版本等
  • ModuleParam:每个子模块的信息,包括 packageId、当前 Flavor、sourceSet 等
  • SourceSetsParam:存储每个 Flavor 下 source 的信息

有了这些依赖和工程信息,产物编译的前期准备就完成了。

编译实现

这是 Savitar 中最关键的部分,会使用前面的依赖信息完成对改动文件的编译产物生成。

编译对象:

  • 源代码文件:Java、Kotlin
  • 资源文件:Xml(布局、String、Drawable等)、图片

源代码编译

对于 Java 和 Kotlin 源代码的编译,需要使用到 javac 和 kotlinc 两个工具。两个工具的使用调用方式是类似的

# 执行kotlinc/javac 命令
sh kotlinc{or javac} \  
-classpath \
${projectPath}/build/intermediates/classes/${Flavor}/debug:\ # build产物依赖
${android_home}/platforms/android-${version}/android.jar:\ # Android SDK 以及其他三方库Jar
-d ${产物输出目录} \
@${kotlin修改文件集合.ch} \
@${java修改文件集合.ch} \

Savitar 整个编译流程、产物打包、推送加载都是通过 Shell 脚本完成,脚本由通过 Runner 动态生成,下面是生成脚本代码的逻辑

flow4

Runner 生成脚本的原则是按需生成,只在检测到存在相应的修改记录之后才会生成对应的代码,并且所有依赖也是在运行时生成,避免出现在依赖改变之后因脚本没有更新导致编译失败的情况。

在源代码编译流程中,值得注意的是 Java 与 Kotlin 之间的编译顺序。存在两种文件修改时,需要先编译 Kotlin 再编译 Java,如果顺序不对,可能会导致 Java 编译失败。例如存在 A.kt 与 B.java 文件存在依赖引用,如果先编译 B.java 文件,就会出现 B.java 文件对于 A.kt 类依赖找不到的错误。这是为什么呢?其实是新老语言的兼容性不同,Kotlin 支持使用 Java 源代码作为编译依赖,但是反过来就不行,但是如果先把 A.kt 类编译成 .class 文件,那么 B.java 文件就可以正常使用 .class 作为编译依赖完成编译了。

资源编译

完成了源代码编译之后,就到了资源编译。在介绍资源编译之前,需要稍微讲解一下资源 ID 固定。

接触过热修复或者做过类似内容的同学知道,对于资源文件的热修复,必须保持修复资源(非新增)与原有资源的 ID 一致,且新增资源的 ID 必须不能与已有资源 ID 重复,否则就会出现资源引用混乱的问题。为了保证资源编译过后能够与原有资源 ID 保持一致,必须提前把前面编译的资源的 ID 保存固定下来,然后在后续的资源编译中使用。资源 ID 固定可以通过在 Gradle 处理资源的Task中添加--emit-ids参数并且指定一个 ID 保存文件完成

processResourcesTask.aaptOptions.additionalParameters("--emit-ids", idRecordPath)  

这个rocessResourcesTask可以通过获取名字为process${variantName}Resources的 Task 获取到。完成了资源 ID 固定之后,就可以开始资源编译了。

对于非 values 资源,基于 AAPT2 的 link 模式,将资源编译后的 .flat 文件替换之前的 .flat 文件,再使用 link 命令完成打包即可。

// 资源编译
aapt2 compile ${资源文件全路径} -o ${资源文件编译产物输出目录}  
// 资源APK生成
aapt2 link ${.flat资源文件路径} -o ${目标apk路径} --manifest AndroidManifest.xml  

对于 values 资源,因为之前全量编译的产物是合并过的,所以不能使用单个模块的修改 .flat 替换合并过的 .flat,对于这种场景目前是会以 offline 模式重新执行一次处理资源的 Gradle Task。

flow5

关于 AAPT2 的详细使用,可以参考 Android 官网上的文档 AAPT2

由此,就完成了 Savitar 中的编译部分,相比使用 Android Stuio 直接编译运行,Savitar 的编译量更小,速度更快。

产物加载

这个部分会使用到热修复的原理来完成对于产物的加载,不是很了解的同学可以先学习关于 Android 代码和资源热修复的原理。

目前社区中有很多很成熟的热修复框架,例如 Tinker、Sophix 等。一开始我们也在考虑是否需要把产物和现在使用的热修复框架结合在一起(工程中使用的是 Tinker)。后来发现,其实在 Savitar 中,对于产物加载的要求没有这么高,例如不需要像 Tinker 进行 dex 的差分操作,只需要简单地把产物加载运行起来即可。所以这个方面只是参考了 Tinker 的 Loader 部分产物加载原理,然后简化了一些流程,做了一个最简可用版产物加载工具。

flow6

上面是加载流程图,整个流程其实并不复杂,但是能够满足对产物加载的需求。其中可能有人会疑问为什么需要在加载之后把产物删除掉,这个不是下次启动就没有了么?这么做主要是为了能够有途径回到没有产物的状态,要不然每次都需要手动去删除产物文件才能回到初始状态,这样操作会比较麻烦。

使用体验

前面的部分详细描述了 Savitar 具体实现,其中包含了很多的复杂流程和内容,但是对于使用者来说其实不需要关心这些,为了方便使用,我们为 Savitar 开发了一款 IDE 插件,只需要一键触发就可以完成整个编译打包流程,具体介绍如下:

Android Studio -> Preference... -> Plugin -> Install plugin from disk -> 选择本地 Savitar.jar (目前为内部使用,未上传到 Jetbrains 插件中心),安装完成后重启 IDE,然后在 Android Studio 中工具栏就会出现 Savitar 的图标(红框部分)

Savitar

点击图标后,可以在 Savitar Window 看到工具编译、打包、推送整个运行过程,包含错误信息,如下图:

Savitar Run

对一些问题的回答

如何 Kotlinx 支持

import kotlinx.android.synthetic.main.activity_main.*

class MainActivity : AppCompatActivity() {  
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        buttonCpu.setOnClickListener {
            // 点击事件
        }
    }
}

使用过 Kotlin 的 Android 同学对于上面的代码肯定不陌生,利用 Kotlinx 特性,可以在 .kt 代码中使用 Xml 中定义过组件Id直接获取 View 实例进行操作,极大减少 UI 开发成本。

但是上面代码中的 import 并不是一个普通的形式,这样的语法如果直接使用标准 kotlinc 进行编译,会出现找不到 import 错误。

import kotlinx.android.synthetic.mian.activity_main.*  

这个时候需要借助到 Kotlin 编译器插件,在 Kotlin 编译时传入 Kotlinx 对应插件的 Jar 地址和参数,就可以完成包含 Kotlinx 语法的文件编译。

sh kotlinc  
-Xplugin=lib/android-extensions-compiler.jar
-P plugin:org.jetbrains.kotlin.android:package=${package_name}
-P plugin:org.jetbrains.kotlin.android:variant='${flovar};${resource_package}'

文档参考 Kotlin 编译器插件

为什么使用 Shell 脚本实现

Shell 脚本可以直接在 Mac 系统下面执行,在 Shell 脚本里面可以方便地调用编译过程中所需要的命令,并且调试运行也非常方便。

Kotlinc 环境变量问题

在使用 Android Studio 开发过程中,Kotlin 编译所需的依赖包都是由 IDE 自动管理,但是 Savitar 是使用 Shell 实现,这样的情况下面就需要关心这个编译工具的问题了。我们将获取 Kotlin 编译依赖的逻辑放在 Savitar 运行环境检测逻辑中,在检测到没有依赖包的情况下会自动从内网服务器下载对应版本的库,完成 Kotlin 代码编译。

结果与展望

Savitar实践成果

使用了 Savitar 之后,我们的增量编译速度得到了很大的提升。增量编译时间从原来平均 110s 降低到 15s,提速 8 倍。

Kotlinx Plugin

从 2019 年 Q3 开始到目前为止,Savitar 在有赞内部使用超过 10,000 次,累计节省约 260 个小时编译时间。随着编译时间的减少,Android 同学的开发体验也越来越好了,妈妈再也不用担心我因为编译慢而加班了~

未来计划

在未来,我们团队在不断改进和完善 Savitar 的同时,还会增加动态生成代码、SO 库等特性的支持,并逐渐往通用化方向进行架构设计,旨在支持所有的 Android 工程,最终开源,为 Android 编译难题贡献一份力量,并期待后续更多的开发者可以参与其中,一起共建。

结语

关于 Android 全量和增量加速方案的分享到此就告一段落了,但是我们对于开发效率提升的追求永不停止。再次感谢大家对我们移动技术沙龙支持,我们未来会产出更多关于移动技术方面的分享,希望大家持续关注。

欢迎关注我们的公众号