使用Dagger SPI 查找非必要组件依赖项

许多 Android 开发人员使用 Dagger 或其"包装器"Hilt 进行依赖注入. 但使用Dagger SPI的人并不多. 这种机制为我们提供了访问依赖图谱的权限, 允许我们添加自己的图检查以及更多. 在本文中, 我将以查找未使用的组件依赖为例, 讨论如何使用 Dagger SPI. 读完本文后, 你就能找到它们了. 或者, 如果你愿意, 也可以编写自己的依赖图谱检查. 或者随心所欲.

依赖关系

首先, 让我们来看看什么是组件依赖以及如何使用它们. 假设我们有一个名为 FeatureComponent 的组件. 它负责存储并向服务器发送有关当前设备的数据. 因此, 我们需要依赖关系, 以便

  • 存储数据, 例如在数据库中;
  • 获取当前设备的信息;
  • 向服务器发送数据, 因此我们需要一个网络客户端.

我们可以通过使用子组件层次结构(SubComponent hierarchy)或使用组件依赖(Component Dependencies)为我们的组件提供这些依赖. 我们今天感兴趣的是第二种方法.

从 Dagger 的角度来看, 这可以通过两种方法来实现: 通过一个通用接口或通过一组接口. 我不会猜测哪种方法在哪种情况下更好. 我只想介绍这两种方法, 以便更好地了解情况.

方法 1. 通用接口

我们创建一个通用接口, 在其中描述当前组件可能需要的所有依赖关系.

kotlin 复制代码
interface FeatureComponentDependencies {

    fun provideDeviceInfoManager(): DeviceInfoManager
    fun provideDatabaseManager(): DatabaseManager
    fun provideNetworkManager(): NetworkManager
}

接下来, 我们只需在组件注解的依赖关系字段中指定该接口即可.

kotlin 复制代码
@Component(
   dependencies = [
       FeatureComponentDependencies::class
   ]
)
internal interface FeatureComponent

然后, 有人会专门为我们的组件创建一个该接口的实现, 并在创建过程中将该实现注入到 Component.BuilderComponent.Factory 中.

现在, 让我们来看看第二种方法.

方法 2. 接口集

对于每一种依赖关系, 我们都要创建一个单独的接口: 一个是设备信息接口--CoreDeviceDependencies, 一个是数据库接口--CoreDatabaseDependencies, 还有一个是网络操作接口--CoreNetworkDependencies.

kotlin 复制代码
interface CoreDeviceDependencies {

    fun provideDeviceInfoManager(): DeviceInfoManager
}

interface CoreDatabaseDependencies {

    fun provideDatabaseManager(): DatabaseManager
}

interface CoreNetworkDependencies {

    fun provideNetworkManager(): NetworkManager
}

接下来, 我们在组件注解的依赖关系字段中指定我们的接口.

kotlin 复制代码
@Component(
   dependencies = [
       CoreDeviceDependencies::class,
       CoreDatabaseDependencies::class,
       CoreNetworkDependencies::class,
   ]
)
internal interface SomeComponent

这些接口的实现不是专门为我们的组件创建的, 而是为所有潜在功能的组件创建的. 然后, 当我们创建组件时, 只需将这些实现注入其中即可.

一切看起来都很好, 但随着时间的推移, 情况会发生变化...

未使用的依赖关系

我们的组件也是如此. 假设对于 FeatureComponent, 我们决定不向服务器发送设备信息. 现在我们只收集和存储信息. 我们删除了负责发送的代码, 但却忘了删除FeatureComponentCoreNetworkDependencies的声明. 显然, 这对于一个只做了三个动作的功能来说很奇怪, 但在实际应用中, 功能通常要大得多, 因此很容易忘记删除某些组件依赖.

除了FeatureComponent会变得有点杂乱之外, 在多模块应用中, 这还会导致模块之间残留不必要的依赖关系. 这会影响

  • 冷构建时间. 由于不必要的依赖关系, 可能会在模块之间形成瓶颈.
  • 热构建时间. 在我们的案例中, 我们不再需要CoreNetworkDependencies, 但由于它们的存在, 我们的功能模块将被迫连接到网络模块. 如果网络模块发生变化, 我们的模块也会被迫重建, 尽管它并不需要这样做.

一般来说, 留下什么都不做的代码并不像程序员的作风. 很明显, 必须找到它们以便日后删除. 遗憾的是, Dagger 做不到这一点. 但 Dagger 有 Dagger SPI, 它为我们提供了一个构建图, 可以轻松实现搜索不必要的组件依赖.

搜索

我想说明的是, 我们将使用 kapt 进行搜索, 因为带有 KSP 的 Dagger 版本仍处于 alpha 阶段, 存在一些错误. 老实说, 使用 KSP 时, 我们的 Dagger SPI 插件不会有太大变化.

另外, 我们将对第二种方法进行搜索, 这是一组接口. 整个问题在于, 在第二种方法中搜索未使用的依赖关系的代码包含了第一种方法中的代码. 我不想再重复一遍了.

Dagger SPI

那么 Dagger SPI 是如何工作的呢?

其实很简单. 首先, 我们创建一个新的库模块, 并添加 kotlin("jvm") 代替插件 id("com.android.library") .

我们将 Dagger, Dagger SPI 和 AutoService 添加到依赖关系中. 具体步骤如下:

scss 复制代码
plugins {
   kotlin("jvm")
   kotlin("kapt")
}

dependencies {
   implementation("com.google.dagger:dagger:2.44")
   implementation("com.google.dagger:dagger-spi:2.44")
   compileOnly("com.google.auto.service:auto-service:1.1.1")
   kapt("com.google.auto.service:auto-service:1.1.1")
}

BindingGraphPlugin

在新模块中, 我们创建一个新类, 在本例中就是 DaggerUnusedValidator. 我们从 BindingGraphPlugin 继承该类, 并添加 @AutoService(BindingGraphPlugin::class) 注解. 该注解将允许 Dagger SPI 查找其所有插件.

kotlin 复制代码
@AutoService(BindingGraphPlugin::class)
class DaggerUnusedValidator : BindingGraphPlugin {

   override fun visitGraph(
       bindingGraph: BindingGraph,
       diagnosticReporter: DiagnosticReporter
   ) {
   }
}

visitGraph 方法中有两个参数: bindingGraphdiagnosticReporter. 第一个参数包含依赖图谱, 第二个参数负责方便地记录图元素的问题.

值得注意的是, visitGraph 方法将针对每个组件和模块进行调用, 甚至可能调用多次, 因为代码生成过程中可能会出现多轮调用. 分别调用 visitGraph 方法是有道理的, 因为从 Dagger 的角度来看, 每个组件和模块都代表了独立的依赖图谱, 我们随后会将它们相互连接起来.

剩下的工作就是将我们的模块与使用 Dagger 的每个模块连接起来.

arduino 复制代码
"kapt"(project(":kapt_validate_dagger_deps"))

现在, 在构建项目时, 生成 Dagger 的代码后, visitGraph 方法将获得完成的依赖图谱.

但到目前为止, 我们的方法什么也没做. 是时候解决这个问题了!

查找组件

我们首先要做的当然是获取组件.

这很容易做到. BindingGraph 已经有了一个特殊的 rootComponentNode 方法. 但是, 正如我前面提到的, visitGraph 方法除了可以接收"真正的"组件外, 还可以接收模块. 为了区分它们, 让我们使用 isRealComponent 标志.

kotlin 复制代码
val currentComponent = bindingGraph.rootComponentNode()
if (!currentComponent.isRealComponent) {
    return
}

isRealComponent 这个名称清楚地表明, 该标记只对"真实"组件有效. 这正是我们需要的.

我们有了所需的组件, 现在需要以某种方式从中提取组件依赖.

尝试提取依赖关系

不幸的是, Dagger SPI 返回给我们的是一个已经组装好的图形. 这意味着其中不可能有任何未使用的组件依赖. 因此, 我们必须花费一些精力来提取它们.

但是! 我们可以通过componentPath().currentComponent()方法获取组件的TypeElement.

kapt 代码生成而言, TypeElement 类似于Class. 最后, 类在代码生成阶段并不存在, 而是在运行时才出现. (这样, 我们就可以访问代码生成器本身的运行时类了).

因此, 我们的计划很简单: 获取组件的 TypeElement, 从中获取组件注解, 然后访问该注解的依赖关系字段. 听起来很简单. 它是这样工作的:

ini 复制代码
val currentComponent = componentNode.componentPath().currentComponent()
val dependencies = currentComponent.getAnnotation(Component::class.java).dependencies

但没那么幸运! 这段代码无法运行. 在尝试访问依赖关系字段时, 我们会收到一个 MirroredTypeException 异常.

MirroredTypeException问题

出现这个错误的原因是在 Component 注解的依赖关系字段中指定了 Class[] 类型. 正如我前面提到的, 类在代码生成过程中并不存在. 如果 Component 注解中包含一个包含类名的字符串数组, 而不是 Class 数组, 就不会出现这个问题. 如果你对这一现象感兴趣, 可以在 本文 中阅读更多相关内容.

你可能会说"但 Dagger 以某种方式做到了!". 没错. Dagger 能够成功, 说明有一种方法可以规避这个问题. 不过, 这个方法有点...怪异.

这个计划没那么简单, 我们把它分成两个阶段:

阶段 1. 查找依赖关系的方法

  1. 通过 annotationMirrors 获取所有注释.
  2. 查找其中的组件注解
  3. 找到其中的依赖关系方法.

阶段 2. 获取依赖关系类型

  1. AnnotationValue 的形式从该方法中获取组件依赖列表.
  2. AnnotationValue 中获取原始类的 DeclaredType 值.

这听起来比调用一个方法要复杂得多. 但不用太害怕: 整个计划只需要几十行代码.

让我们从计划的第一阶段开始.

第一阶段. 查找依赖关系方法

请记住, 并不是所有的组件都需要有组件依赖. 因此, 如果某些组件没有声明依赖方法也没关系. 我们应该立即考虑到我们可能会收到 null 的可能性.

然后, 我们将按计划进行: 通过 annotationMirrors (1) 获取所有注解, 在其中找到 Component 注释 (2), 然后找到依赖方法 (3), 并尝试从中获取 value (4).

kotlin 复制代码
private fun getDependenciesMethod(
   componentNode: BindingGraph.ComponentNode
): AnnotationValue? {
   val component = componentNode.componentPath().currentComponent()
   val annotationMirrors = component.annotationMirrors // (1)
   val componentAnnotationMirror = annotationMirrors // (2)
       .first { it.annotationType.toString() == Component::class.java.name }
   val dependenciesMethod = componentAnnotationMirror.elementValues.entries // (3)
       .firstOrNull { it.key.simpleName.toString() == Component::dependencies.name }
   return dependenciesMethod?.value // (4)
}

酷! 我们得到了依赖关系字段的值. 现在我们进入计划的第二阶段.

第二阶段. 获取依赖关系类型

我们可以肯定, 代码中依赖关系字段的值是一个类数组, 用 annotationMirror 的语言来说就是 List<AnnotationValue> . 因此, 我们可以大胆地将其转换为 (1) 并提取AnnotationValue值 (2), 我们也知道其类型. 因此, 我们可以非常大胆地将这些值再次转换为 DeclaredType (3).

另外, 我们不要忘记, 一个组件可能没有任何组件依赖(Component Dependencies). 因此, 不要忘记添加 null 检查.

kotlin 复制代码
private fun getComponentDependencies(
   componentNode: BindingGraph.ComponentNode
): List<DeclaredType> {
   val dependenciesMethod = getDependenciesMethod(componentNode)

   return if (dependenciesMethod != null) {
       (dependenciesMethod.value as List<AnnotationValue>) // (1)
           .map { it.value } // (2)
           .map { it as DeclaredType } // (3)
   } else {
       emptyList()
   }
}

最后, 我们得到了组件依赖的列表. MirroredTypeException的问题已经解决!

让我们继续, 现在我们需要以方便的格式提取所有依赖项.

获取所有类型

为此, 我们将依次处理每个组件依赖项. 我们将找到组件依赖的所有方法 (1), 并获取它们返回值的类型 (2).

kotlin 复制代码
private fun getMethodsReturnTypes(declaredType: DeclaredType): List<String> {
    val methods = declaredType.asElement().enclosedElements // (1)
        .filter { it.kind == ElementKind.METHOD }
        .map { it as ExecutableElement }
    val returnTypes = methods // (2)
        .map { it.returnType.toString() }
    return returnTypes
}

现在, 让我们把它们放在一起. 我们将找到组件依赖项的类型, 并将它们与组件依赖项及其提供的类型(依赖项)列表组合成一个字典.

kotlin 复制代码
private fun getDependencies(
    componentNode: BindingGraph.ComponentNode
): Map<String, List<String>> {
    val dependenciesTypes = getComponentDependencies(componentNode)
    val dependenciesMap = dependenciesTypes
        .associateWith { getMethodsReturnTypes(it) }
        .mapKeys { it.key.toString() }
    return dependenciesMap
}

最困难的部分结束了. 我们有了一个可以提供 Component 中指定的组件依赖的依赖项列表. 接下来我们要做的就是获取组件所需的依赖项列表.

提取所需的依赖项列表

这非常简单. 通过绑定方法, Dagger SPI 为我们提供了一个单独的已用依赖项列表.

不过, 它会以自己的格式返回. 但我们需要提取它们, 因为我们需要类型名称.

scss 复制代码
val bindings = bindingGraph.bindings()
    .map { contextBinding -> contextBinding.key().type().toString() }

太棒了! 我们有了所需的依赖项列表. 剩下的工作就是将它与特定组件依赖项提供给我们的内容进行比较.

执行搜索

让我们浏览一下组件依赖项列表, 看看它提供的依赖项中有多少没有被使用. 如果全部都是, 那么我们就可以大胆地宣布该依赖组件为不必要的. 因为我们可以肯定, 它的所有方法都没有用, 这意味着它是未使用的, 可以删除.

ini 复制代码
dependencies.forEach { (dependency, dependencyMethods) ->
    val unusedMethods = dependencyMethods.subtract(bindings)
    if (unusedMethods.size == dependencyMethods.size) {
        diagnosticReporter.reportComponent(
            Diagnostic.Kind.ERROR,
            currentComponent,
            "Dependency ${dependency} is unused"
        )
    }
}

就是这样. 让我们尝试构建项目, 在代码生成阶段, Dagger 会给出预期的错误.

csharp 复制代码
SomeComponent.java:8: error: 
[ru.cian.validate.dagger.deps.unused.DaggerUnusedValidator] 
Dependency CoreNetworkDependencies is unused

如果你对完整代码感兴趣, 可在 Gist 中找到

就我们的项目而言, 原来有许多被遗忘的组件依赖项. 通过删除所有未使用的依赖关系, 我们减少了模块间不必要的链接. 依赖分析 Gradle 插件](github.com/autonomousa...) 为此提供了帮助, 对此我们深表感谢.

大部分未使用的组件依赖都出现在 3 年或 3 年以上的旧功能中, 而新功能几乎没有未使用的组件依赖.

SPI 还能做什么?

你可能会认为, 寻找未使用的组件依赖(Component Dependencies)当然很好玩, 很有趣, 很有用, 而且总体而言还很高尚, 但为了一个功能而拖入 Dagger SPI 就显得矫枉过正了.

这很公平. 因此, 我冒昧地介绍了 Dagger SPI 的其他用途. 我脑海中浮现出以下选项:

分析
  • 依赖图谱的可视化和分析. 大多数 Dagger 图表可视化工具都使用了 Dagger SPI. 依赖图谱的可视化令人赏心悦目, 但更有用的可能是将依赖图谱保存到单独文件的功能. 这样就可以使用外部脚本对图形进行更复杂的分析.
  • Dagger 健康度量. 你可以收集有关 Dagger 在项目中使用情况的数据. 例如, 某个组件的所有依赖项的数量. 如果依赖过多, 就有理由创建重构任务.
验证
  • 使用限定符. 可以要求开发人员对 Int 或 String 等基本类型使用 Qualifier. 这将避免出现本想获得一个字符串, 结果却得到了完全不同的字符串的问题.
  • 禁止使用. 可以禁止直接向 Dagger 提供容易导致内存泄漏的类, 如 Activity, Fragment, View 等.
  • 查找不必要的提供. 你可以找到所有提供无人需要的依赖关系的提供方法, 然后删除它们.
日志改进
  • 对于初学者来说, Dagger 并不总能产生易于理解的错误. 如果发现错误, 你可以在日志中添加解释, 详细说明如何在项目中具体修复这些错误.

目前就这些, 但我认为还有更多的可能性有待探索.

好啦, 今天的内容就分享到这里啦!

一家之言, 欢迎拍砖!

Happy coding! Stay GOLDEN!

相关推荐
QING6187 分钟前
Kotlin 类型转换与超类 Any 详解
android·kotlin·app
QING61836 分钟前
一文带你了解 Kotlin infix 函数的基本用法和使用场景
android·kotlin·app
张风捷特烈1 小时前
平面上的三维空间#04 | 万物之母 - 三角形
android·flutter·canvas
恋猫de小郭2 小时前
Android Studio Cloud 正式上线,不只是 Android,随时随地改 bug
android·前端·flutter
匹马夕阳7 小时前
(十八)安卓开发中的后端接口调用详讲解
android
Pigwantofly9 小时前
鸿蒙ArkTS实战:从零打造智能表达式计算器(附状态管理+路由传参核心实现)
android·华为·harmonyos
Gracker10 小时前
Android Weekly #202514
android
binderIPC10 小时前
Android之JNI详解
android
林志辉linzh10 小时前
安卓AssetManager【一】- 资源的查找过程
android·resources·assetmanger·安卓资源管理·aapt·androidfw·assetmanger2
_一条咸鱼_12 小时前
大厂Android面试秘籍:Activity 权限管理模块(七)
android·面试·android jetpack