为 TheRouter 的 AGP8 编译加个速



对本文有任何问题,可加我的个人微信询问:kymjs666

背景

AGP8 的变更应该很多人都知道了,移除了Transform API,所以很多 class 操作类的插件代码都需要改了。

TheRouter在开发的时候就支持了AGP8,使用的也是Gradle提供的标准 API。

详细可见官方示例仓库:https://github.com/android/gradle-recipes/blob/agp-8.7/transformAllClasses/build-logic/plugins/src/main/kotlin/CustomPlugin.kt

project.plugins.withType(AppPlugin::class.java) {
    // Queries for the extension set by the Android Application plugin.
    // This is the second of two entry points into the Android Gradle plugin
    val androidComponents =
        project.extensions.getByType(ApplicationAndroidComponentsExtension::class.java)
    // Registers a callback to be called, when a new variant is configured
    androidComponents.onVariants { variant ->
        val taskProvider = project.tasks.register<ModifyClassesTask>("${variant.name}ModifyClasses")

        // Register modify classes task
        variant.artifacts.forScope(ScopedArtifacts.Scope.PROJECT)
            .use(taskProvider)
            .toTransform(
                ScopedArtifact.CLASSES,
                ModifyClassesTask::allJars,
                ModifyClassesTask::allDirectories,
                ModifyClassesTask::output
            )
    }
}



问题

这里使用的是toTransform()这个方法替代老版本的 API,这么做以后有个问题,就是编译速度会非常非常慢,尤其是工程里代码量越大,编译速度越慢,原因我们先看看这个方法的定义。

/**
 * Transform the current version of the [type] artifact into a new version. The order in which
 * the transforms are applied is directly set by the order of this method call. First come,
 * first served, last one provides the final version of the artifacts.
 *
 * @param type the [ScopedArtifact] to transform.
 * @param inputJars lambda that returns a [ListProperty] or [RegularFile] that will be used to
 * set all incoming files for this artifact type.
 * @param inputDirectories lambda that returns a [ListProperty] or [Directory] that will be used
 * to set all incoming directories for this artifact type.
 * @param into lambda that returns the [Property] used by the [Task] to save the transformed
 * element. The [Property] value will be automatically set by the Android Gradle Plugin and its
 * location should not be considered part of the API and can change in the future.
 */
fun toTransform(
    type: ScopedArtifact,
    inputJars: (T) -> ListProperty<RegularFile>,
    inputDirectories: (T) -> ListProperty<Directory>,
    into: (T) -> RegularFileProperty)

重点在这一句:last one provides the final version of the artifacts。这个方法是整个构建最后一步执行,会提供一个最终的输出,并且输出类型是一个 RegularFileProperty 他只能输出一个单独的 File。

这也就意味着你需要把 前期构建的所有产物,包括全部的 jar 依赖、源码编译后的 class 依赖,都在这个Task中聚合到一个产物内返回,这就是耗时的原因。

并且由于需要将所有的 jar 和 class 聚合到一个 jar 内,所以也没办法使用增量编译,这就又进一步拖慢了编译速度。

而对应的老版本操作是,拿到所有的 jar 和 class,需要字节码操作的就做操作,不需要的直接复制一遍到输出路径就可以。很显然,新版本使用的方案还不如旧版本,把所有的class聚合成一个新的jar是很耗时的,而且没办法做增量操作。



解决思路1

既然耗时间的原因是将全部的 class 聚合成一个 jar,这一步太慢。那么我们的想法肯定是怎么避免这样做,同时又能在编译期改变 class 内容。

在老版本Transform API的替代类还提供了一个 AsmClassVisitorFactory,是对一个指定class做字节码操作,而我们的TheRouter路由恰好是只需要改TheRouterServiceProvideInjecter这一个类就足够了,那这样就可以大幅降低编译时间了。

但是问题是在于, AsmClassVisitorFactory 是对所有类遍历的过程中执行的,而路由的代码需要所有类遍历一遍以后才知道应该要把哪些类放到 TheRouterServiceProvideInjecter中。如果使用AsmClassVisitorFactory,就有可能要记录的类还没有遍历完,就已经轮到要ASM插桩处理的TheRouterServiceProvideInjecter类了。

比如这个图中的流程,假设红色箭头是当前构建处理类的顺序,整体的逻辑就是处理A类的时候,记录是否需要插桩,再B,再C。但是到TheRouterServiceProvideInjecter的时候,就要开始读取记录,开始插桩了,实际上如果E、F也是需要插桩的话,此时就会被漏掉。

图示

所以现在的问题又变成了如何让 ASM 处理的时候就知道要处理哪些类。



解决思路2

之前在TheRouter的反馈群里,有人给出了这篇文章,里面提到了一个很好的方案:https://juejin.cn/post/7374677514536812571

在编译的时候,由于KSP/KAPT这种编译期工具是优先于Transform过程的,并且我们所有需要插入的代码类都是由 KSP/KAPT 生成的,可以在这个时候将所有需要记住的类写入的一个构建文件中,直到Transform的时候直接去读这个文件。
但是他有个很大的弊端,公司大了以后项目会变成各个业务线开发一个独立模块,最终打包的时候只依赖各个业务线的aar打包。而 KSP 如果是在独立模块中,那么在 aar 生成的时候就已经执行完了,这时候如果是给到主工程aar再去打包,就会丢失这个aar内需要写入到文件中的产物。

大概的逻辑如下图:

图示

AAR AAAR BAAR C,里面的ksp_cache.txt就会丢失了,因为app在构建的时候,也会有自己的产物文件,这部分并没有包含aar中的内容。
然后还有一种折中的办法,就是让所有aar提供给app接入方的时候,也同时将这个产物文件一起提供,由接入方手动把产物同步到app的产物文档内,这样在最终打包的时候插件asm就能直接插入了。



TheRouter 目前的方案

还是回到最原始的那个思考。

  • AsmClassVisitorFactory 可以做到增量编译,并且按需插桩,问题是插桩时还没有遍历完全部类。
  • toTransform() 可以做到完整遍历所有类,并且按需插桩,问题是无法增量编译,类多的时候执行很耗时。

那么能不能把这两个结合一下?

  • AsmClassVisitorFactory去做插桩,用toTransform()去生成待插桩的记录文件。

但这样依然有2个问题。

  1. 前面也讲过了toTransform()执行时间是在整个构建的最后。所以这就变成了先插桩,再生成记录文件。
  2. toTransform()如果要想遍历所有类,依然要经历那个聚合所有class的动作,也就意味着依然没办法增量编译,所以并没有解决耗时问题。

第一个问题好解决,第一次遍历没法用,那就先记录,下次编译的时候再用这次的类记录缓存就行了。
第二个问题再想一下,toTransform()耗时的原因是需要把所有类聚合,而我们用到他的目的是为了在构建的最后执行我们自己的一个方法,那有没有可以在最后执行,又不需要聚合所有类的方法呢?还真有! toTransform()方法所在类里面,还有一个方法,toGet()它只有两个输入,但是没有输出,并且也是在构建结尾执行。

/**
 * Set the final version of the [type] artifact to the input fields of the [Task] [T].
 * Those input fields should be annotated with [org.gradle.api.tasks.InputFiles] for Gradle to
 * property set the task dependency.
 *
 * @param type the [ScopedArtifact] to obtain the final value of.
 * @param inputJars lambda that returns a [ListProperty] or [RegularFile] that will be used to
 * set all incoming files for this artifact type.
 * @param inputDirectories lambda that returns a [ListProperty] or [Directory] that will be used
 * to set all incoming directories for this artifact type.
 */
fun toGet(
    type: ScopedArtifact,
    inputJars: (T) -> ListProperty<RegularFile>,
    inputDirectories: (T) -> ListProperty<Directory>)

从注释来看,toGet() 完全就是 toTransform() 的平替,而且非常适合我们现在的场景。再看一下官方的demo使用方法都一模一样,只是参数少了个输出路径。

https://github.com/android/gradle-recipes/blob/agp-8.7/getScopedArtifacts/build-logic/plugins/src/main/kotlin/CustomPlugin.kt

variant.artifacts
    .forScope(ScopedArtifacts.Scope.PROJECT)
    .use(taskProvider)
    .toGet(
        ScopedArtifact.CLASSES,
        CheckClassesTask::projectJars,
        CheckClassesTask::projectDirectories,
    )



再优化一下

现在还剩的一个问题就是:编译的记录文件是下次才生效的,但作为 SDK 这样的表现肯定是不合适的,因为会产生不确定性,你不知道实际使用 SDK 的用户会怎么用,万一他用了旧产物打的包直接发布了,那就会造成问题。

所以在TheRouter中,做了一个额外的内存缓存,会把本次编译使用的类记录,与本次构建新生成的类记录缓存,在编译结束时做一次对比,如果两次结果不一致,直接抛异常,表示本次构建的产物是有问题的,不可用,再次编译以后就正常了。
这样就能既享受了增量编译,快速构建,同时对产物结果有了保障。

比如微信群里面一个使用者的截图: 图示



再优化首次的中断

目前还有一个问题,Gradle 构建此时的 AsmClassVisitorFactory 并不会每次都执行(如果输入没有变会直接跳过),单纯从构建来看这是合理的,但是上面的方案就出问题了。因此需要解决增量编译时缓存的正确性,否则会有两个结果,要么每次编译经常性的提醒有模块增删请重新编译,要么就是编译后的插桩类丢失。

因此我们再次将前面的模式整合一下,每次全新构建时,使用toTransform,同时将此时需要插桩的类记录下来。再次编译时,只走AsmClassVisitorFactory,对增量变化的类做记录,这样就彻底解决了类记录缺失、增量编译、缓存一致性的全部问题。

详细实现可以直接去看TheRouter的源码,能查看更多细节实现以及TheRouter对构建内存占用的优化,以及文件读写的优化。



说实话,其实这样的方案也是一个折中后的取舍。按理说如果 toTransform 加一个重载,第三个参数同时支持既能定义成File又能定义成Dir,如何在File的重载里面调用Dir的产物做一次聚合,达到的效果是一样的,并且也能更简单的解决问题。



2024.11.26 更新:
@5peak2me 的提醒下发现,原来从 AGP8 开始,就提供了上面的疑惑的方法,只不过 API 还是 internal 的,应该还是没确定具体的实现方案,可能要到 9 才能对外公开出来了,留个记录,后续观察吧。
详情见 TheRouter issue #216: https://github.com/HuolalaTech/hll-wp-therouter-android/issues/216.