背景
AGP8 的变更应该很多人都知道了,移除了Transform API
,所以很多 class 操作类的插件代码都需要改了。
TheRouter
在开发的时候就支持了AGP8
,使用的也是Gradle
提供的标准 API。
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 A
、AAR B
、AAR C
,里面的ksp_cache.txt
就会丢失了,因为app在构建的时候,也会有自己的产物文件,这部分并没有包含aar中的内容。
然后还有一种折中的办法,就是让所有aar提供给app接入方的时候,也同时将这个产物文件一起提供,由接入方手动把产物同步到app的产物文档内,这样在最终打包的时候插件asm就能直接插入了。
TheRouter 目前的方案
还是回到最原始的那个思考。
AsmClassVisitorFactory
可以做到增量编译,并且按需插桩,问题是插桩时还没有遍历完全部类。toTransform()
可以做到完整遍历所有类,并且按需插桩,问题是无法增量编译,类多的时候执行很耗时。
那么能不能把这两个结合一下?
- 用
AsmClassVisitorFactory
去做插桩,用toTransform()
去生成待插桩的记录文件。
但这样依然有2个问题。
- 前面也讲过了
toTransform()
执行时间是在整个构建的最后。所以这就变成了先插桩,再生成记录文件。 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
使用方法都一模一样,只是参数少了个输出路径。
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.