Android Gradle Plugin 8.0 路由框架新思路

近日,财政部网站发布的《财政部关于下达2024年汽车以旧换新补贴中央财政预拨资金预算的通知》(下称《通知》)显示,财政部日前下达2024年财政贴息和奖补资金64.4亿元,用于2024年汽车以旧换新中央财政补贴资金预拨。
/   作者简介   /
本篇文章转自Aleyn的博客,文章主要分享了Android 开发中路由编译问题的优化过程,相信会对大家有所帮助!
原文地址:
https://juejin.cn/post/7374677514536812571
/   前言   /
说到路由又是老生长谈了,阿里的 ARouter、美团的WMRouter 这些老牌知名度很高的路由框架。由于 AGP 8.0 以后不能支持,Github 上也有很多人提了PR,Issues 里边也有很多个开发了支持8.0 的插件。
去年我为了支持 AGP 8.0 以及 KSP, 也写了一个路由框架 LRouter。新框架难免会有很多问题,刚好公司有一个新项目要做,我自己在公司的项目中第一个接入的,踩了半年的坑,也基本上稳定了下来。在这里把遇到的一个很致命的编译问题分享一下解决的思路。
/   正文   /
问题
AGP 8.0以后移除了 Transform API。官方文档也给出了替代的方式 AsmClassVisitorFactory,但是这种方式我以前的文章也有说过,只适合对已知的类做插脏或者转换。像路由框架是需要对整个项目的类进行扫描的,遍历完成之后拿到了类信息,再进行插桩。所以只能自定义 Task 来实现。这里是自定义Tssk官方例子。地址:
https://github.com/android/gradle-recipes/blob/agp-8.4/transformAllClasses/build-logic/plugins/src/main/kotlin/ModifyClassesTask.kt
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 相关的优化的文章很少,几乎没有,只有字节和得物的两篇文章有介绍。
  • 字节dexBuilder优化
  • 得物优化
这两篇文章有同一个特点,就是只介绍没放出来源码。大概思路就是 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 提供的修改建议。就是以上的思路。
改完之后,编译速度提升很多,修改代码只会影响当前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 类的代码不贴了,有点多,点链接进去看吧。优化结果 贴个优化后的图:
图片
从 1 分钟多减到 8 秒。最后也推荐下我这个路由 LRouter,基于KSP和AsmClassVisitorFactory 的路由框架。如果你想在Gradle 高版本使用路由可以考虑下 LRouter。编码不易,寻找新的思路更不易。还望给个 Star 支持下。

原创文章,作者:guozi,如若转载,请注明出处:https://www.sudun.com/ask/90308.html

(0)
guozi's avatarguozi
上一篇 2024年6月5日 下午3:45
下一篇 2024年6月5日 下午3:49

相关推荐

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注