查看原文
其他

AGP 8.0 路由框架新思路

Aleyn 鸿洋
2024-08-24

本文作者


作者:Aleyn

链接:

https://juejin.cn/post/7374677514536812571

本文由作者授权发布。


前言

说到路由又是老生长谈了,阿里的 ARouter、美团的WMRouter 这些老牌知名度很高的路由框架。由于 AGP 8.0 以后不能支持,Github 上也有很多人提了PR,Issues 里边也有很多个开发了支持8.0 的插件。
去年我为了支持 AGP 8.0 以及 KSP, 也写了一个路由框架 LRouter。新框架难免会有很多问题,刚好公司有一个新项目要做,我自己在公司的项目中第一个接入的,踩了半年的坑,也基本上稳定了下来。在这里把遇到的一个很致命的编译问题分享一下解决的思路。
https://github.com/aleyn97/router

1问题


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 点下班去骑我的风驰电掣的小电摩,结果电池还被人给偷了 ............................................................。

2原因


由于极大的拖慢了编译速度,我开始找原因,发现扫描和插桩的过程其实并不慢,只用了一两秒的时间,真正拖慢编译速度的是 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"}


3第一次尝试解决

知道了原因我也查了相关的资料,对 dexBuilderDebug 相关的优化的文章很少,几乎没有,只有字节和得物的两篇文章有介绍
  1. 字节dexBuilder优化

    https://juejin.cn/post/6854573211548385294?searchId=20240530153818D983C10C7C2F3A451665#heading-23


  2. 得物优化

    https://mp.weixin.qq.com/mp/wappoc_appmsgcaptcha?poc_token=HOVnXWajz5k5DMUq95D-ak5rbekO-4QzxgurvrV3&target_url=https%3A%2F%2Fmp.weixin.qq.com%2Fs%2F414nz4T-_KyH42xo_mdj-w%3F


这两篇文章有同一个特点,就是只介绍没放出来源码。大概思路就是 Hook AGP 的编译流程,因为我们的Android 项目编译的时候 只有 APP 主模块是以目录形式做输入,各个子模块都是以 Jar 包形式做输入的。如果我们子模块有更改,整个子模块编译时输入的Jar 里的类都要重新 dex。
引用字节那篇文章的一句话:
jar 输入相比于 目录输入来说增量编译效果非常差,那么可以想到 hook TransformInvocation 中的 input 方法,动态将 project 的 jar 类型输入(JarInput)映射为一个 目录输入(DirectoryInput),那么子模块修改对应代码时,只重新编译目录中被修改的 class 为 dex(而不是原来的整个 jar 内所有 class 重新执行 dex 编译),整体 dex 重新编译的数量将大幅度减少。

这种方案,适合体量非常大的项目,是要入侵到 AGP 的编译流程的。目前只是写一个路由插件,如果要这样搞,成本太高了。只能换其他思路。

4缩小扫描范围

由于自定义 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。

5更换 AsmClassVisitorFactory

既然加了缓存文件了,那理论上直接使用 AsmClassVisitorFactory 来处理的也是行的通的。因为transformXXXClassesWithAsm 这个Task ,APP 主模块也是最后执行的:
variant.instrumentation.transformClassesWith(  
LRouterAsmClassVisitor::class.java,  
InstrumentationScope.PROJECT  
) {}

新创建LRouterAsmClassVisitor 类, 对所有Project 都进行注册。然后在 createClassVisitor 方法里通过 ClassVisitor 来把类信息写到缓存文件中去。

6巧用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


7优化结果


贴个优化后的图:
从 1 分钟多减到 8 秒。
最后也推荐下我这个路由 LRouter,基于KSP和AsmClassVisitorFactory 的路由框架。如果你想在Gradle 高版本使用路由可以考虑下 LRouter。
编码不易,寻找新的思路更不易。还望给个 Star 支持下。


最后推荐一下我做的网站,玩Android: wanandroid.com ,包含详尽的知识体系、好用的工具,还有本公众号文章合集,欢迎体验和收藏!


推荐阅读

大图预览追求极致,对PhotoView进行一波优化
OpenHarmony源码系列:鸿蒙页面背后的机制,打通 JS View 与C++世界
优雅实现网络请求:协程+Flow+Retrofit+OkHttp



扫一扫 关注我的公众号

如果你想要跟大家分享你的文章,欢迎投稿~


┏(^0^)┛明天见!

继续滑动看下一个
鸿洋
向上滑动看下一个

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存