AGP Transform Optimization
date
Feb 13, 2022
slug
android-gradle-transform-optimization
status
Published
tags
gradle
Transform
summary
Android 编译优化之Transform 原理剖析与深度优化
type
Post
TL;DR: 本文主要介绍 android build 中 transform 阶段的开箱即用的增量编译优化方案,相比于原生的 Transform,这套方案主要是通过"尽可能"在增量中编译最少的代码、处理最小范围的变动,来达到增量编译优化的目的。
概述
Android Transform 是 AGP (Android Gradle Plugin) 中方便开发者在 class -> dex 之间进行一些修改的一个官方工具。你可以在这个阶段做一些 class 字节码的修改、代码注入、资源处理等。总之,只要想象力够丰富,随便你造。
无奈的是,AGP 给 Transform 赋予了极大的自由度,自由度带来的负面影响就是噩梦般的编译速度。以公司的项目为例,在本地增量编译中,每一次编译就会有平均 53.6s (27.8 + 25.8) 的时间浪费在 transfrom 上。
Transform 基本介绍
在聊 Transform 优化之前,我们给一些不了解 transform 机制的同学简单介绍下 transform 是如何工作的。如果你对 transform 已经很熟悉了,请直接跳到下一节。
这是 Transform 的架构图 👆
AGP 在完成代码、资源的编译任务之后,会将整个工程里的所有代码&资源都搜集到一起,然后回调业务方自定义的 transform,当然,android 本身也有一些 transform 例如 desugar(在自定义 transform 之前)、dex、shink 等。整个下来是个链式的结构。
你可以通过很简单的代码注册一个你自己的 transform。
android.registerTransform(new CustomTransform())
在你的 transform 中,你可以拿到对应的 input
class TestTransform: Transform() {
override fun transform(transformInvocation: TransformInvocation) {
val input = transformInvocation.getInputs() // 拿到本次的 inputs
}
}
TransformInput 中,我们可以分为两种
- JarInput:表示编译中的所有的 jar 文件,一般是各种二方库、三方库、子 module 的 jar,JarInput 可以分为四种状态 Changed/Added/Removed/Not Changed。
- DirectoryInput:表示编译中所有的文件夹,一般是 app module 中的 class,以及其它的资源等,directory 本身没有状态,但是它有一个
changedFiles
的字段,里面是个 map,这个 map 表示了文件和状态的映射。
每个 transform 最后都会被包装成一个 gradle task,task 的输入是上一个 Transform 的输出(如果是首个 transform 它的输入是工程的文件),输出可以通过
transformInvocation.outputProvider
来获取输出文件夹,你只需要将变换后的文件写入到 output 文件夹,下个 transform 就会把这个输出作为输入继续执行它的 transform 逻辑。这样一整个链路串下来,最终再由 dexTransform 将 class 转换为 android 可识别的 dex 文件,打包到 apk 中。存在的问题
Output 命名问题
我们在上节中提到,你可以通过
transformInvocation.outputProvider
来获取输出的目录。但是如果你打开 output 目录,你会发现,output 中都是 0/1/2/3/4/5 这样的文件。我们翻阅了 transform 的源码,发现它对 output 的命名方案就是按照 index 来排。
if (format == Format.DIRECTORY) {
return Integer.toString(index);
}
return Integer.toString(index) + DOT_JAR;
这个 index 是 agp 内部维护的一个顺序递增的 index,它的 index 分配取决于 getContentLocation 的时机。
怎么理解这句话呢?意思是你的 output fileName 完全取决于你调用
getContentLocation
的顺序。如果有某一次 transform 有 a.jar b.jar,分别被分配到了 1.jar 2.jar 作为 output。那么如果在下一次遍历,中间插了一个 c.jar,那么 a.jar 还是对应 1.jar,c.jar 对应 2.jar,b.jar 则被分配到了 3.jar。这样带来的问题是显而易见的。
首先,这样的设计让 buildCache 几乎成了不可能的一件事情,一旦顺序出现了错乱,会导致 AGP 的 fileCache 无法命中缓存。
再者,index 作为 output 命名的方式让排查问题也陷入了灾难,比如如果在 dexBuilder 过程中出现了重复类,你的排查过程会非常恶心。
首先你需要打开 output 目录下的 __content__.json (这个文件记录了 name 和 index 之间的关系)
通过 name 去查对应的 index,然后找到重复的 jar 文件。
显然这种命名方式不是一个科学的方式。
我们改起来也很简单
if (format == Format.DIRECTORY) {
return getRealName(name);
} else {
return getRealName(name) + DOT_JAR;
}
private String getRealName(String name) {
if (!name.contains("/")) return name.replace(".jar", "");
else return name.substring(name.lastIndexOf("/") + 1).replace(".jar", "");
}
我们直接按照 transform 的名称来命名 output 即可。
切仓重编问题
我们在测试使用 二进制/源码转换 这类的组件化管理工具时,发现在切仓的场景下,所有的 transform 都会走全量编译。
切仓可以分为两种情况,一种是 AAR -> 源码,另一种是 源码-> AAR,其实这两种本质上是一样的,前者对于 transform 来说,是从形如
com.fengguang.a:1.0.0.jar
变成了 module-A.jar
(由 classes.jar 变形而来)。后者则刚好相反。也就是说,对于 transform 来讲,等于删除一个 jar & 新增一个 jar。并且这俩个 jar 隶属于同一个模块。
但是为什么删除一个 jar,新增一个 jar 就会导致 transform 全量编译呢?
我们翻阅了源码后发现,对于
OriginalStream
来说,只要有被删除的 jar,就会导致全量编译。这里我们介绍一下
OriginalStream
和 IntermediateStream
/**
* Version of TransformStream handling input that is not generated by transforms.
*/
public class OriginalStream extends TransformStream;
/**
* Version of TransformStream handling outputs of transforms.
*/
class IntermediateStream extends TransformStream
这两个都继承于
TransformStream
,它贯穿于整个 transform 链路中,你的 input & output 都需要依赖于这个 stream。简单来讲,你可以认为它代表了被处理的文件。OriginalStream
: 传给第一个 transform 的是 OriginalStream,它没有被其它 transform 处理过,是 AGP 生成出来的。
IntermediateStream
: 它的 input 是其它的 transform 的 output,处理的是二手文件。
这里要注意一点的是,并不是所有的 OriginalStream 都会被第一个 transform 处理(图中的 TransformA), 因为第一个 Transform 的 scope 不一定是全部的文件,所以有的文件可能到了第二个 transform 才被处理(见图中的 transform-B)。
回到我们刚刚聊的切仓场景,我们提到,对于 OriginalStream,只要它的输入中有被删除的 jar,就会导致必须要重新编译,并且如果第一个 transform 重新编译,也会导致后面的 transform 重新编译。
👆 这是
OriginalTransform
对于 removedJar 的判断(这里返回 false 会导致重新编译)。可以看到 AGP 在实现中直接放弃了对于
OriginalTransform
的 removeJar 判断。只要有被删除的 jar 就会重新编译。为什么要这样做呢?我们不妨再回想下上个章节的 Output 命名问题,原始的 Output 命名方式是按照自然数 index 来命名的,如果原来有 com.a / com.b / com.c 三个 input jar,对应 1.jar / 2.jar / 3.jar,那么当我们删除 com.b 后,com.a 不变,com.c 对应到了 2.jar。
这时候,如果 transform 想删除 b.jar 对应的 2.jar,它可能就会把 com.c 转换后的 2.jar 给删了,这样就会导致很严重的错乱。有同学可能会问,为什么 c.jar 会被对应到 2.jar?这是因为你不能保证 transform 给你的 jarInputs 的顺序是不是固定的,比如它可能给你
- a.jar NotChanged
- b.jar Removed
- c.jar NotChanged
也可以给你
- a.jar NotChanged
- c.jar NotChanged
- b.jar Removed
这时候如果按照后者的顺序来遍历,通过
getContentLocation
的顺序命名法,c.jar 拿到的就是 2.jar,这时候你要是想删 b.jar 对应的 2.jar,你可能就把 c.jar 已经 transform 好的 jar 包给删掉了。这时候,如果我们按照上一章节的 name 命名方式,每一个 input 都会对应一个对应名称的 output,就不会出现这个问题了。比如 a.jar 对应的 output 是 a-out.jar,b.jar -> b-out.jar,c.jar -> c-out.jar(这里用 -out 是为了方便解释,实际上的命名方式参考上一节)。
那么如果这时候 b.jar 被删了,即便 c.jar 被先遍历,它的输出还是 c-out.jar,b-out.jar 没人动它。这时候再遍历到被删掉的 b.jar,我们就可以将对应的 b-out.jar 删掉。
当然,因为 transform 本身是直接放弃了删除文件的判断的,我们需要将这份代码换成我们的判断。
首先我们要判断被删除的 input jar 是不是在上一轮中被 transform 过的,因为如果上一轮中没有处理过这个 jar,output 中也不会有这个 jar,
我们通过把上一轮的 transform input 存起来
在下一次增量编译中如果有被删除的 jar,就通过 file 来反查它在上一轮的 name,然后再构造一个
REMOVED
的 input 传给 transform。其实不光是切仓的场景会导致有 jar 被删除,还有依赖升级的场景,比如你将 okhttp 从 1.0.0 升级到 1.0.1,那么会有新增一个
com.squareup.okhttp3:okhttp:1.0.1.jar
,删除一个 com.squareup.okhttp3:okhttp:1.0.0.jar
。这个优化也同样适用于这个场景。应用了这个优化之后,可以杜绝绝大部分的重新编译,Jar2Dir 优化
我们在一开始讲到,在 aar 化后,子仓对于 app 来说,要么就是坐标依赖的 jar
com.fengguang.a:1.0.0.jar
,要么就是源码依赖的 classes.jar
的变形产物 module-A.jar
,横竖都是个 jar。这样会带来一个问题,即便你只改了某个子仓里的一行代码,你这个子仓对于 transform 来讲就变动了一整个 jar。
比如你改了
:tools
仓库里的一行代码,AGP 给 transform 输入的就是 module-tools.jar(Changed)
。之后的每一个 transform 都需要去处理这个 jar,因为 transform 并不知道这个 jar 里面的哪个类发生了变动,所以就没办法做最细粒度的更新,只能全部处理一遍。如果是个比较小的子仓可能还好,但是如果是类似 :aweme:im 这样的子仓,里面有上万个类。处理一遍的时间是很糟糕的。
那有没有办法解决这个问题呢?
我们在 Transform 基本介绍中提到,transform 有两种输入形式
- JarInput
- DirectoryInput
其中 DirectoryInput 的最小粒度是
.class
。我们只需要将原来的 JarInput 变换成 DirectoryInput,就可以实现对 JarInput 的类级别粒度增量 transform。每一次我们都会将变动的 jar 解压到某个文件夹
- 对于新增的 jar,我们返回一个 DirectoryInput,里面所有的 class 的状态都是 ADDED
- 对于删除的 jar,我们返回一个 DirectoryInput,里面所有的 class 状态都是 REMOVED
- 对于改动的 jar,我们返回一个 DirectoryInput,将解压后的文件和原来的文件进行对比,返回发生了变动的 class。
整个解压、对比的过程都是通过并发来完成,将时间控制在毫秒级别。
并且完成了完整的 diff 逻辑测试
250k 的 jar,diff 可以控制在 50ms 内。
效果
目前成功落地公司的项目
平均从 2.6min -> 1.6min 。除去改造了俩个增量 transfrom 的收益 20s,transform 优化带来的收益大概在 40s。
写在最后
其实 Transform 的优化并不是简单地通过一个两个插件就可以解决。本次优化也是站在前人的肩膀上添砖加瓦。增量编译本身的优化还是需要依赖于 ByteX这样的工具,以及每个开发者对于编译速度的追求。Transform 本身只是一个框架,它赋予了我们很大的编码自由度,但是同时又丧失了对于编译速度的掌控。Google 也不能覆盖所有开发的场景,所以针对性地做一些优化是必然的。
通过 Jar2Dir,配合切仓、output 重命名这几个方案,可以 cover 大部分场景,保证每一次都只编译『最小范围的代码』。