写一个依赖注入
Android: 手写一个简易的依赖注入框架
通过本文可以了解到
- 几种依赖注入的方式
- 如何自己做一个 Gradle Plugin 实现 Transform
- 如何操作 Java 字节码
什么是依赖注入
通俗的讲,就是衣来伸手,饭来张口
举个具体的例子,我每天吃的饭不固定:
|
|
但是这样不爽啊,我还要考虑每天吃什么,这让我感到很焦虑,于是我就想依赖注入,让一个懂我口味的大厨来给我做:
|
|
这让我感觉非常的轻松,当然我还可以告诉大厨,我今天只想吃肉的
这样,大厨就帮我注入了我要吃饭这个依赖,大厨只要保证炒出来是下面这样的一个 Food 接口:
|
|
后来我更懒了。我穿衣服也想别人帮我找好衣服,因此我希望有一个全能的生活助理,可以帮我做饭,也可以帮我找衣服,就想这样:
|
|
这简直太舒服了!类似的,我们使用依赖注入的目的也就是为了不去操心很多事情的具体实现细节。
这里的助理就是依赖注入框架(IOC容器),他帮我自动注入了我想要食物,我想找衣服穿的依赖,而不需要我自己去做吃的,找衣服。
接下来我就想做一个这样的依赖注入框架,可以实现提供一个接口,返回一个序列,我可以选择随便拿几个用,也可以选择自己再筛选一下
现有方案
举个例子:假设我们存在两个接口,他们分别有多个实现,我们需要获取我们需要的实现
接口 Interface1,实现类有 Impl1, Impl2 Impl3
接口 Interface2, 实现类有 Impl4, Impl5
有以下几种方案:
静态保存所有实现类
可以选择在工具类中保存全部的实现类,然后可以对列表进行遍历筛选出我们需要的
|
|
但是需要在尽可能早的时候去初始化这个类,这样之后才可以从这个类中获取我们想要的内容,当有新的实现的时候,就把所有的实现都加到这里面,这样的方案看似简单清晰,但是所有类都没有懒加载,而且这个类还需要尽早实例化,因此会导致启动速度严重变慢。
反射
前面提到了静态保存这些类会导致没有懒加载,解决的办法其实也是很简单的,只要保存 class name,然后反射构造就好了:
|
|
这算是实现了我们想要的目标了吧?当然还可以做一些优化,比如 class name 不使用硬编码,获取全部实例用 Sequence 实现懒加载… 但是!他有这些缺点:
- 牺牲了 IDE 静态检查的特性,获取到的是 Any 类型,需要强转,即便没有实现这个接口,你依旧可以把这个实现类放到 map 中
有些人可能会说:不能实现我干嘛要放过去?其实放过去你可能会记得,但是当你觉得这个方法没有地方用到,然后调试了一下程序也顺利编译,于是删除无用的接口的时候,你可能不会知道这个方法会在不久的将来埋下隐患:一旦在某个地方想查找这个接口的实现了,然后用到了这个实现类,然而它并没有实现,直接爆出 NoSuchMethod
- 用了反射,性能会差。
- 需要手动注册和删除,这是不可靠的,人还是很容易忘事的。
apt
注解处理生成代码,我们可以通过 javapoet 或者 kotlinpoet 去处理注解和生成代码,但是这个过程是发生在编译前的,对于编译隔离的环境下,处理起来就非常棘手了
目前现有的比较完善的库有 dragger,以及 Jetpack 组件中的 hilt,这里如果感兴趣可以去参照 Google 官方的文档:使用 Hilt 实现依赖项注入,这个库是基于 dragger 来做的,因为 dragger 用起来是比较复杂的,hilt 对它进行了针对 Android 的场景化处理。
当然如果针对动态模块,其实 hilt 也有解决方案,但是看起来并不优雅:在多模块应用中使用 Hilt,使用会稍微麻烦。
对于 Kotlin 的注解处理,我们一般使用 kapt,但是 kapt 事实上是非常耗时的,这里我介绍一下 kapt 的工作流程:
生成JavaStub的时候,我们其实需要担心一个问题,kotlin有一些它专属的关键字啊,比如说inline fun,比如说data class 还有什么 reified… 这些东西 Java 其实都是没有的,那么 kotlin 如何去处理这些类信息呢?如果你反编译过 kotlin 的类的字节码的时候,你会发现它有个metadata 注解,这就是这些信息存放的地方,如果要解析这些信息,其实在翻阅 kotlin 的源码之后发现,我们可以使用 kotlinx-metadata.
在 kapt 解析完这些类信息之后,才会进行真正的代码生成,解析这一步其实是非常耗时的,不信我们可以去看看我们的线上项目,生成JavaStub消耗了非常长的一段时间
当然,官方也考虑到了这些,做了一个东西叫做 KSP:google/ksp: Kotlin Symbol Processing API (github.com),目前也有一些库在慢慢转变为使用 ksp,但目前尚处于 beta 阶段,还没有正式发布,ksp 的底层是 kcp(Kotlin Complier Plugin),我们也可以翻 Kotlin 源代码就可以发现什么东西用到了 KCP
总结一下,apt 有这些缺点:
- 处理编译隔离比较麻烦
- 生成代码并参与编译,对编译速度有较大影响(尤其是 Kotlin kapt,会比 Java APT 要慢许多)
Transform
同样是生成代码。不过这个生成的是字节码,字节码生成的之后不需要额外处理源文件,性能会比较高, 首先我们需要首先了解 Android 的构建流程:
其中 *.java, *kt 这些文件会编译成 *.class 文件,然后 Transform 就是在生成 class/jar 之后,编译为 dex 文件之前,这个过程是由 Gradle 来接管的,因此我们需要来处理 Gradle 的构建流程:即自定义 Gradle Plugin
写一个 Gradle Plugin
写一个 HelloWorld
-
创建一个 module,module 名字无所谓,插件的话,要确定一个名字,比如我创建的这个叫做 cat-inject
-
在 resources/META-INF/gradle-plugins 中创建配置文件一个叫 cat-inject.properties
-
里面写入你的实现类:
1
implementation-class=com.zsqw123.inject.plugin.InjectPlugin
-
实现 Plugin:
需要依赖 Plugin<Project>, 之后 apply 方法会在依赖导入成功以及 gradle build 的时候执行,然后 registerTransform 即可使指定的类型(AppExtension,LibraryExtension)使用此 Transform
1 2 3 4 5 6 7 8 9 10 11 12
class InjectPlugin : Plugin<Project> { override fun apply(target: Project) { val androidAppExtension = target.extensions.findByType(AppExtension::class.java) val androidLibExtension = target.extensions.findByType(LibraryExtension::class.java) if (androidAppExtension != null || androidLibExtension != null) { val injectTransform = InjectTransform() androidAppExtension?.registerTransform(injectTransform) androidLibExtension?.registerTransform(injectTransform) } println("CatInject Plugin Loaded!") } }
-
Transform 我单独抽了一个 BaseTransform,这样暴露出真正的 transform 逻辑给子类
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
override fun getName(): String = TRANSFORM_NAME // 这里可以选择输入的类型:Class,Resource,Dex override fun getInputTypes(): MutableSet<QualifiedContent.ContentType> = TransformManager.CONTENT_CLASS // 作用范围:当前 Project,子模块,第三方依赖 override fun getScopes(): MutableSet<in QualifiedContent.Scope> = TransformManager.SCOPE_FULL_PROJECT // 是否支持增量处理 override fun isIncremental(): Boolean = true override fun transform(transformInvocation: TransformInvocation) { val outputProvider = transformInvocation.outputProvider if (!isIncremental) { outputProvider.deleteAll() } transformInvocation.inputs.forEach { input -> input.jarInputs.forEach { jarInput -> // ... processJar(file) } input.directoryInputs.forEach { dirInput -> // ... processDirectory(file) } } onTransformed() } // 处理 jar 包 protected open fun processJar(outputJarFile: File) = Unit // 处理源码文件 protected open fun processDirectory(outputDirFile: File) = Unit // 处理完之后进行的操作 protected open fun onTransformed() = Unit
-
处理 jar 包和源码文件会扫描两遍,第一遍会扫描所有被 CatInject 注解的接口,第二遍会扫描所有实现了需要被注入的接口的类,最后将两次结果进行 map,并进行写入字节码
修改字节码
这里用到了 ASM 进行字节码修改,需要获取到输入输出流,传递给 ClassReader 和 ClassWriter 进行解析和输出,Class 的输入输出流可以通过上面的 Transform 中得到。大致的流程是这样的:
|
|
我们只需要定义自己的 ClassVisitor 即可
对于扫描注解,下方的示例可以扫描有指定注解的接口加入到指定的集合中:
|
|
对于修改类的话,也可以通过指定的方法名以及 descriptor 来确定要修改的方法并进行 visitCode
|
|
中间的具体实现细节的话,可以通过 ASM Bytecode Viewer 来照猫画虎
同时也可以对照着学一学字节码:
ByteCode | ASM |
---|---|
最初的实现
其实我一开始想的是通过反射去创建 Instance,只不过只需要加一个注解即可实现自动查找,自动注入,避免了人工添加实例的过程,我选择织入字节码的位置是这个 class 的 init 方法
|
|
目前的实现
目前的实现, 这样真正避免了反射,同时也达到了懒加载的要求
|
|
具体织入逻辑:CatInject/ASMCodeGen.kt
Transform 被弃用
在 AGP 7.0 正式发布后,从 AGP 1.3 一直存在的 Transform API 被标记为废弃了,但注释中并没有说明用哪个 API 来替代,发现了这个问题时就在想,连 AGP 中最稳定的 Transform API 都被废弃了,以后是不让用字节码插桩了吗?
但其实不是,主要废弃的原因不光是 AGP 需要 Transform,Java 也需要,所以需要由 Gradle 来提供统一的 Transform API,这个东西就是 Transform Action,有兴趣的同学可以去看一下,因为我抽象了一层 BaseTransform,所以在之后有时间的时候我会尝试替换成 Transform Action
Git
plugin:zsqw123/CatInject
sample:zsqw123/CatInjectSample