QCon 演讲火热征集中,快来分享技术实践与洞见! 了解详情
写点什么

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

  • 2020-03-15
  • 本文字数:6888 字

    阅读完需:约 23 分钟

有赞 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 等知名框架。

3.1 调研结果

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


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 增量编译问题。

4.1 结构设计


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


  • GUI 插件部分:面向使用者的 GUI 界面,内部包含了可运行 Jar(以下简称 Runner)的自动更新、各种检查任务、编译脚本调用执行

  • Runner 部分:一个 Jar 包,包含 Savitar 核心逻辑代码,完成修改获取、脚本生成、编译执行等任务

  • 工程支持部分:一个 Gradle 插件,完成对工程信息的获取和产物加载代码的插入

  • 外部依赖部分:完成整个流程所需要的外部依赖程序


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



  • 获取改动信息:获取代码和资源修改,是整个过程的前提

  • 获取工程信息:获取当前工程的依赖信息,目录信息和 Git 信息,为后续编译做准备

  • 编译生成产物:进行代码、资源编译,生成 Dex 产物和 Apk 产物

  • 重启加载产物:完成对编译产物的加载运行,完成整个加速过程


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

4.2 改动获取

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


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


  • 如何正确获取本地修改文件的信息

  • 如何支持多 Flavor

  • 如何支持多分支切换

4.2.1 本地改动获取

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信息保存文件}中。

4.2.2 支持多分支切换

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



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

4.2.3 Flavor 过滤改动文件

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



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

4.3 工程信息获取

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


需要获取的信息:


  • 编译依赖信息:包括全量编译产物目录、上个修改编译产物目录、三方库依赖等

  • 工程信息:包括各个模块包名、当前 Flavor、sourceSet、工程路径等

4.3.1 编译依赖获取

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


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


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



下面是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 中,完成对于三方库的依赖链接。

4.3.2 工程信息获取

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



  • MakeParam:信息集合保存类

  • ProjectParam:保存主工程信息,包括所有需要的路径、主包名、启动的 Activity、资源 ID 固定之后保存文件路径、Android SDK 编译版本等

  • ModuleParam:每个子模块的信息,包括 packageId、当前 Flavor、sourceSet 等

  • SourceSetsParam:存储每个 Flavor 下 source 的信息


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

4.4 编译实现

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


编译对象:


  • 源代码文件:Java、Kotlin

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

4.4.1 源代码编译

对于 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 动态生成,下面是生成脚本代码的逻辑:



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


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

4.4.2 资源编译

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


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


processResourcesTask.aaptOptions.additionalParameters("--emit-ids", idRecordPath)
复制代码


这个rocessResourcesTask可以通过获取名字为 process${variantName}Resources的 Task 获取到。


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。



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


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

4.5 产物加载

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


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



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

五、使用体验

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


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



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


六、对一些问题的回答

6.1 如何 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 编译器插件

6.2 为什么使用 Shell 脚本实现

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

6.3 Kotlinc 环境变量问题

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

七、结果与展望

7.1 Savitar 实践成果

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



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

7.2 未来计划

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

结语

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


2020-03-15 20:201354

评论

发布
暂无评论
发现更多内容

领域驱动设计(DDD):DDD落地问题和一些解决方法

付威

对线面试官 - 绝无仅有真实线上问题排查面试题突击篇

派大星

Java 面试题

DevSecOps 中的漏洞管理(下)

禅道项目管理

DevOps 漏洞

火山引擎DataLeap的数据血缘用例与设计概述

字节跳动数据平台

大数据 企业号9月PK榜

文盘Rust -- 给程序加个日志 | 京东云技术团队

京东科技开发者

京东云 企业号9月PK榜

DPText-DETR: 基于动态点query的场景文本检测,更高更快更鲁棒 | 京东探索研究院

京东科技开发者

京东云 企业号9月PK榜

数据通信网络之OSPFv3基础

timerring

数据通信网络

冰火两重天——GTLC有感

IT民工大叔

个人成长 GTLC 技术领导力

谷沁清益生菌清口含片,守护口腔健康的第一道防线

联营汇聚

9月23-24日·上海线下·CSM认证周末班【提前报名特惠】“全球金牌课程”CST导师亲授

ShineScrum

极光笔记 | 推送服务数据中心选择:合规性与传输效率的双重考量

极光JIGUANG

【Y 码力】WAL 与性能

YMatrix 超融合数据库

性能提升 WAL 超融合数据库 故障恢复 YMatrix

“价值交付课程”11月4-5日 · CSPO认证周末班【提前报名特惠】CST导师亲授

ShineScrum

室内LED全彩显示屏P3和P5有什么区别

Dylan

LED 全彩LED显示屏 led显示屏厂家 户内led显示屏

Spring 条件注解没生效?咋回事

江南一点雨

Java spring

玖章算术叶正盛将揭示为什么PostgreSQL不如MySQL流行?|3306π

NineData

数据库 postgresql 开源 叶正盛 NineData

数据库深分页介绍及优化方案 | 京东云技术团队

京东科技开发者

京东云 企业号9月PK榜

当红语言模型利器:深度解析向量数据库技术及其应用

Baihai IDP

人工智能 AI 向量数据库 白海科技 大语言模型

采用Excel作为可视化设计器的开源规则引擎 NopRule

canonical

低代码 规则引擎 可视化开发 可逆计算 Nop平台

基于 Flink CDC 高效构建入湖通道

Apache Flink

大数据 flink 实时计算

ARTS week4

Z.

ARTS 打卡计划 #ARTS 左耳朵耗子

mac电脑数据转换 EasyDataTransform激活最新版

胖墩儿不胖y

数据转换 Mac软件 数据处理软件

区块链项目:白皮书+PPT海报设计,热度视频/MG动画,出海包装/宣发,经济模型设计

区块链软件开发推广运营

数字藏品开发 dapp开发 区块链开发 链游开发 NFT开发

持续部署:提高敏捷加速软件交付(内含教程)

SEAL安全

ci 持续部署 CD 软件交付 企业号9月PK榜

探索GreatADM:如何快速定义监控

GreatSQL

WiFi Scanner for Mac(Wifi无线网络扫描管理软件) v3.1完美激活版

mac

苹果mac Windows软件 wifi scanner 无线网络扫描管理软件

离散性行业介绍及与MES系统的好处

万界星空科技

MES系统 产品资讯

macos轻量级思维导图软件 ClickCharts最新激活版

mac大玩家j

思维导图 Mac软件 思维导图软件

解锁社交媒体的未来:SocialFi 的承诺

区块链软件开发推广运营

交易所开发 数字藏品开发 合约交易所开发 NFT开发 区块链开发DAPP开发

3天上手Ascend C编程丨通过Ascend C编程范式实现一个算子实例

华为云开发者联盟

人工智能 开发 华为云 华为云开发者联盟 企业号9月PK榜

这一次,大模型颠覆广告行业!

Openlab_cosmoplat

人工智能 大模型

有赞 Android 编译进阶之路 —— 增量编译提效方案Savitar_文化 & 方法_明天_InfoQ精选文章