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 上。
notion image

Transform 基本介绍

在聊 Transform 优化之前,我们给一些不了解 transform 机制的同学简单介绍下 transform 是如何工作的。如果你对 transform 已经很熟悉了,请直接跳到下一节。
notion image
这是 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 中,我们可以分为两种
  1. JarInput:表示编译中的所有的 jar 文件,一般是各种二方库、三方库、子 module 的 jar,JarInput 可以分为四种状态 Changed/Added/Removed/Not Changed。
  1. DirectoryInput:表示编译中所有的文件夹,一般是 app module 中的 class,以及其它的资源等,directory 本身没有状态,但是它有一个 changedFiles 的字段,里面是个 map,这个 map 表示了文件和状态的映射。
notion image
每个 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 这样的文件。
notion image
我们翻阅了 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。
notion image
这样带来的问题是显而易见的。
首先,这样的设计让 buildCache 几乎成了不可能的一件事情,一旦顺序出现了错乱,会导致 AGP 的 fileCache 无法命中缓存。
再者,index 作为 output 命名的方式让排查问题也陷入了灾难,比如如果在 dexBuilder 过程中出现了重复类,你的排查过程会非常恶心。
首先你需要打开 output 目录下的 __content__.json (这个文件记录了 name 和 index 之间的关系)
notion image
通过 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 即可。
notion image

切仓重编问题

我们在测试使用 二进制/源码转换 这类的组件化管理工具时,发现在切仓的场景下,所有的 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,就会导致全量编译。
这里我们介绍一下 OriginalStreamIntermediateStream
/**
 * 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,处理的是二手文件。
notion image
💡
这里要注意一点的是,并不是所有的 OriginalStream 都会被第一个 transform 处理(图中的 TransformA), 因为第一个 Transform 的 scope 不一定是全部的文件,所以有的文件可能到了第二个 transform 才被处理(见图中的 transform-B)。
回到我们刚刚聊的切仓场景,我们提到,对于 OriginalStream,只要它的输入中有被删除的 jar,就会导致必须要重新编译,并且如果第一个 transform 重新编译,也会导致后面的 transform 重新编译。
notion image
👆 这是 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。
 
notion image
这时候,如果 transform 想删除 b.jar 对应的 2.jar,它可能就会把 com.c 转换后的 2.jar 给删了,这样就会导致很严重的错乱。有同学可能会问,为什么 c.jar 会被对应到 2.jar?这是因为你不能保证 transform 给你的 jarInputs 的顺序是不是固定的,比如它可能给你
  1. a.jar NotChanged
  1. b.jar Removed
  1. c.jar NotChanged
也可以给你
  1. a.jar NotChanged
  1. c.jar NotChanged
  1. 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 存起来
notion image
在下一次增量编译中如果有被删除的 jar,就通过 file 来反查它在上一轮的 name,然后再构造一个 REMOVED 的 input 传给 transform。
notion image
其实不光是切仓的场景会导致有 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。
notion image
每一次我们都会将变动的 jar 解压到某个文件夹
  • 对于新增的 jar,我们返回一个 DirectoryInput,里面所有的 class 的状态都是 ADDED
  • 对于删除的 jar,我们返回一个 DirectoryInput,里面所有的 class 状态都是 REMOVED
  • 对于改动的 jar,我们返回一个 DirectoryInput,将解压后的文件和原来的文件进行对比,返回发生了变动的 class。
整个解压、对比的过程都是通过并发来完成,将时间控制在毫秒级别。
并且优先通过 zip entry 中的 crc32 对比来节省 diff 的时间。
notion image
并且完成了完整的 diff 逻辑测试
notion image
250k 的 jar,diff 可以控制在 50ms 内。

效果

目前成功落地公司的项目
平均从 2.6min -> 1.6min 。除去改造了俩个增量 transfrom 的收益 20s,transform 优化带来的收益大概在 40s。
notion image

写在最后

其实 Transform 的优化并不是简单地通过一个两个插件就可以解决。本次优化也是站在前人的肩膀上添砖加瓦。增量编译本身的优化还是需要依赖于 ByteX这样的工具,以及每个开发者对于编译速度的追求。Transform 本身只是一个框架,它赋予了我们很大的编码自由度,但是同时又丧失了对于编译速度的掌控。Google 也不能覆盖所有开发的场景,所以针对性地做一些优化是必然的。
通过 Jar2Dir,配合切仓、output 重命名这几个方案,可以 cover 大部分场景,保证每一次都只编译『最小范围的代码』。

© Guang Feng 2022 - 2024