前言
说到路由又是老生长谈了,阿里的 ARouter
、美团的WMRouter
这些老牌知名度很高的路由框架。由于 AGP 8.0 以后不能支持,Github 上也有很多人提了PR,Issues 里边也有很多个开发了支持8.0 的插件。
去年我为了支持 AGP 8.0 以及 KSP, 也写了一个路由框架 LRouter。新框架难免会有很多问题,刚好公司有一个新项目要做,我自己在公司的项目中第一个接入的,踩了半年的坑,也基本上稳定了下来。在这里把遇到的一个很致命的编译问题分享一下解决的思路。
问题
AGP 8.0以后移除了 Transform API。官方文档也给出了替代的方式 AsmClassVisitorFactory
,但是这种方式我以前的文章也有说过,只适合对已知的类做插脏或者转换。像路由框架是需要对整个项目的类进行扫描的,遍历完成之后拿到了类信息,再进行插桩。所以只能自定义 Task
来实现。 这里是自定义Tssk
官方例子。
LRouter
第一个版本就是用自定义Task来实现。自定义Task 会对整个项目的类进行处理包括第三方库最终生成一个classes.jar。这样就对编译速度影响很大。大到什么程度呢,拿我接入到公司的项目来举例,看下图:
这是我只改一行代码的情况下,直接把项目运行起来,要等很久 1 分钟多,项目越大这个时间会越久。
过完年刚来公司还是有些小忙的,没太多时间处理这个问题,我就这样活活的被自己折磨了几个月。
其中有一天,就因为频繁运行项目测试,这个编译慢的问题拖到我晚上10 点才搞完需求。然后10 点下班去骑我的风驰电掣的小电摩,结果电池还被人给偷了 ............................................................。
原因
由于极大的拖慢了编译速度,我开始找原因,发现扫描和插桩的过程其实并不慢,只用了一两秒的时间,真正拖慢编译速度的是 dexBuilderDebug
这个任务,因为插件把所有类包括第三方Lib 全都Copy 到一个 classes.jar 中了,哪怕你只改一行代码最终的这个 classes.jar 都会变,所以每次运行都会全量执行dex。
有时会抛出如下大量警告:
AGPBI: {"kind":"warning","text":"Expected stack map table for method with non-linear control flow.","sources":[{"file":"D:\\Android\\Project\\PicMe\\app\\build\\intermediates\\classes\\devGoogleDebug\\ALL\\classes.jar"}],"tool":"D8"}
第一次尝试解决
知道了原因我也查了相关的资料,对 dexBuilderDebug
相关的优化的文章很少,几乎没有,只有字节和得物的两篇文章有介绍
这两篇文章有同一个特点,就是只介绍没放出来源码。大概思路就是 Hook AGP 的编译流程,因为我们的Android 项目编译的时候 只有 APP 主模块是以目录形式做输入,各个子模块都是以 Jar 包形式做输入的。如果我们子模块有更改,整个子模块编译时输入的Jar 里的类都要重新 dex
引用字节那篇文章的一句话:
jar 输入相比于 目录输入来说增量编译效果非常差,那么可以想到 hook TransformInvocation 中的 input 方法,动态将 project 的 jar 类型输入(JarInput)映射为一个 目录输入(DirectoryInput),那么子模块修改对应代码时,只重新编译目录中被修改的 class 为 dex(而不是原来的整个 jar 内所有 class 重新执行 dex 编译),整体 dex 重新编译的数量将大幅度减少。
这种方案,适合体量非常大的项目,是要入侵到 AGP 的编译流程的。目前只是写一个路由插件,如果要这样搞,成本太高了。只能换其他思路
缩小扫描范围
由于自定义 Task 的时候 forScope() 是声明了ScopedArtifacts.Scope.ALL。
variant.artifacts
.forScope(ScopedArtifacts.Scope.ALL)
.use(taskProvider)
.toTransform(
ScopedArtifact.CLASSES,
LRouterClassTask::allJars,
LRouterClassTask::allDirectories,
LRouterClassTask::output
)
这样会对项目所有的依赖都进行处理包括第三方Lib。使用这个的原因是为了尽可能的减少反射,把待插桩的类放在了 Router 模块下,Router 模块是以第三方Lib 依赖到项目的,只有ScopedArtifacts.Scope.ALL 的时候才会去扫描第三方Jar 包。另一个原因是这样可以统一处理,扫描加插桩一气合成。
先尝试着把范围缩小 forScope改成 ScopedArtifacts.Scope.PROJECT 这样就不对依赖的项目生效,只对当前Project生效。
variant.artifacts
.forScope(ScopedArtifacts.Scope.PROJECT)
.use(taskProvider)
.toTransform(
ScopedArtifact.CLASSES,
LRouterClassTask::allJars,
LRouterClassTask::allDirectories,
LRouterClassTask::output
)
由于只对当前依赖的Project生效,所以写好的插件就不能只在 APP 模块依赖了,所有用到框架功能的 项目都要添加插件。
这样修改另外引出了另一个问题,就是没法统一处理了,每个Project都注册了这个 Task 任务,扫描出来的类信息都是单独的,这就要每个模块都写一份缓存文件,每个Project 执行完 Task 就把扫描到的类信息写到本地缓存起来。
要进行插桩的类,只能放在APP模块了,因为APP 下边的所有子模块是先执行编译流程的,等所有子模块都执行完了,APP 模块才会执行,刚好这个时候子模块扫描出来的信息也已经写到了本地缓存,可以直接读取进行插桩操作。
这条 Issues 下边xiaoyvyv 提供的修改建议。就是以上的思路。
https://github.com/aleyn97/router/issues/6
改完之后,编译速度提升很多,修改代码只会影响当前Project。
更换 AsmClassVisitorFactory
既然加了缓存文件了,那理论上直接使用 AsmClassVisitorFactory 来处理的也是行的通的。因为transformXXXClassesWithAsm 这个Task ,APP 主模块也是最后执行的:
variant.instrumentation.transformClassesWith(
LRouterAsmClassVisitor::class.java,
InstrumentationScope.PROJECT
) {}
新创建LRouterAsmClassVisitor 类, 对所有Project 都进行注册。然后在 createClassVisitor 方法里通过 ClassVisitor 来把类信息写到缓存文件中去。
巧用KSP
当我打开缓存文件看的时候,缓存文件中的信息只有类名和优先级这些信息,也就是说使用transformClassesWith
对子模块下的类进行遍历,只是拿到了这些信息来做了缓存,并没有使用 ASM 来对类进行修改或者替换,那有没有其他方法提前拿到类信息,不通过 transformClassesWith
这样遍历写到缓存中去呢,这样就只用关心主模块下要插桩的类怎么处理。
突然灵光一闪,卧槽,KSP不是就嘛,模板类都是由 KSP 根据注解来生成的,我们所有要扫描的类都是KSP 生成出来的模板类,transformXXXClassesWithAsm
这个Task 也肯定是在 KSP 生成模板类之后执行的。直接拿到所有子模块下 KSP 生成目录里边的模板类不就好了吗。
说干就干,由于之前版本拦截器和初始化相关的注解,都是编译时注解,用ASM在处理 Class 时拿到的类名优先级等信息。首先要改造的就是这里,不能通过 ASM 取信息了,把相关注解换成源码注解,全部通过 KSP 去生成。这样就只用判断类名了。
这个时候,就只用在主模块使用 LRouterAsmClassVisitor
来处理要插桩的类就好了,然后把所有子Project 的 ksp 生成目录当参数传递过去,在进行插桩的时候遍历所有目录通过类名取出需要的信息。
首先把所有 KSP 生成目录用 list 集合传递给 LRouterAsmClassVisitor:
androidComponents.onVariants { variant ->
// ......
val generatedDir = "generated/ksp/" // ksp 生成目录
variant.instrumentation.transformClassesWith(
LRouterAsmClassVisitor::class.java,
InstrumentationScope.PROJECT
) { param ->
param.genDirName.set(generatedDir) // 目录名称参数
val list = project.rootProject.subprojects.plus(project)
.map { it.layout.buildDirectory.dir(generatedDir).get() } // 过滤所有 KSP 生成目录
param.inputFiles.set(list) // 设置所有子模块和主模块的生成目录
}
variant.instrumentation.setAsmFramesComputationMode(FramesComputationMode.COPY_FRAMES)
variant.instrumentation.excludes.addAll(
"androidx/**",
"android/**",
"com/google/**",
)
}
当 LRouterAsmClassVisitor 处理到"com.router.LRouterGenerateImpl" 时取出所有 KSP生成目录的路径,返回 InsertCodeVisitor 进行插桩。
internal const val GENERATE_INJECT = "com.router.LRouterGenerateImpl" // 待插桩类
abstract class LRouterAsmClassVisitor : AsmClassVisitorFactory<ParametersImpl> {
override fun createClassVisitor(
classContext: ClassContext,
nextClassVisitor: ClassVisitor
): ClassVisitor {
if (classContext.currentClassData.className == GENERATE_INJECT) {
val inputFiles = parameters.get().inputFiles.get() //取出所有 KSP 生成目录
val genDirName = parameters.get().genDirName.get()
return InsertCodeVisitor(nextClassVisitor, inputFiles, genDirName)// 插桩操作
}
return nextClassVisitor
}
override fun isInstrumentable(classData: ClassData): Boolean {
return classData.className == "com.router.LRouterGenerateImpl"
}
}
interface ParametersImpl : InstrumentationParameters {
@get:Internal
val genDirName: Property<String>
@get:Internal
val inputFiles: ListProperty<Directory>
}
InsertCodeVisitor 类的代码不贴了,有点多,点链接进去看吧。https://github.com/aleyn97/router/blob/main/plugin/src/main/java/com/aleyn/router/plug/visitor/InsertCodeVisitor.kt
优化结果
贴个优化后的图:
从 1 分钟多减到 8 秒。
最后也推荐下我这个路由 LRouter,基于KSP和AsmClassVisitorFactory 的路由框架。如果你想在Gradle 高版本使用路由可以考虑下 LRouter。
作者:Aleyn
链接:https://juejin.cn/post/7374677514536812571
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。