Koin vs. Hilt——最流行的 Android DI 框架全方位对比

原文载于本人的公众号------Kotlin 维修车间,但这里针对一些内容做了矫正,并补充了一些时效性的信息。

依赖注入(Dependency Injection,DI)是软件开发中解耦的重要工具,通常在我们使用组合的时候,不希望某个类的内部实现与另一个具体实现类强相关,下面有一个非常简单的例子:

kotlin 复制代码
interface A
class B : Aclass C : A
class D {        
    private val instance: A = B()
}

在上面的代码中,class D 以组合的方式依赖 interface A,如果我们不使用 DI,就像代码中所示的那样在 class D 内直接调用了 B 的构造函数,这就造成了 D 与 B 的强耦合,如果在未来我们希望用 C 来代替 B,就需要前往工程中所有 B 初始化的位置来替换它。而如果使用 DI(这里以 Koin 来举例),代码将会是下面这样:

kotlin 复制代码
startKoin {    
    module {        
        factory<A> { B() }    
    }
}

class D {        
    private val instance: A by inject()
}

这样 D 不再依赖 B 的具体实现,使用 DI 我们可以在整个 project 范围内提供 B 对象,当我们需要将 B 替换为 C 的时候也只需要修改依赖注入的地方,而不需要在 IDE 里使用 find usage 找到所有 B 的构造函数调用处进行逐行修改。

Android 生态现状

在国内的技术环境中 DI 不像海外那样受到普遍欢迎,这与国内不少工程常常基于老旧代码以及业务上追求超快速迭代的开发方式有关,比如开发排期很短,或者是本周开发的新功能甚至可能会在两周后下线或大面积修改,这使得很多开发者追求一种尽量怎么快怎么来的粗暴方式,而没有时间进行代码架构方面的设计(或者说架构也面临着频繁更改的局面)。但实际上对于新代码/核心业务架构来说,如果使用好 DI 可以帮助我们更好的隔离业务代码,这让我们在后续的开发过程中极大的提高效率,而不需要在业务需求变更的时候修改大量代码文件。

在 Android 的主流依赖注入框架中有两大流派,第一个就是 Hilt,它是官方 Jetpack 库中的一员,具有极高的认可度。它由 Dagger 发展而来,最主要的技术路线是基于注解以及编译期生成代码(KAPT/KSP)。第二个流派则是 Koin,它不同于以往 Dagger 和 Hilt 基于注解实现的思想,而是采用 Kotlin 的语法构建 DSL API(Koin 也有基于注解的 API,这会在后文讨论)。本文篇幅有限,不会详细介绍这两个框架的使用细节,但会对比在实现同样需求的时候二者之间的差异。

API 风格

Hilt 基于注解实现,针对每个需要被注入的属性,Hilt 都会基于 KAPT/KSP 在编译期间查找它的注入源头,并生成一对一的注入方法,例如我这里有一个 Activity:

Kotlin 复制代码
@AndroidEntryPointclass FilmDetailActivity : AppCompatActivity() {

    @Inject
    internal lateinit var picasso: Picasso
    
    private val filmDetailViewModel by viewModels<FilmDetailViewModel>()
    
    @Inject        
    internal lateinit var similarMoviesAdapter: SimilarMoviesAdapter
    
    //......

这里我们有两个需要被注入的属性,我们在 build 之后就会看到 Hilt 生成了很多代码文件:

当然这里生成的不仅是 Activity 相关的类,还有它对应的 ViewModel 的。如果我们想看 picasso 是如何被注入的,可以找到如下代码:

kotlin 复制代码
@Injected
FieldSignature("biz.filmeroo.premier.detail.FilmDetailActivity.picasso")
public static void injectPicasso(FilmDetailActivity instance, Picasso picasso) {
    instance.picasso = picasso;
}

总之针对特定的属性,Hilt 都会生成一对一的注入方法,上面生成的代码只挑了有针对性的几行来展示,但实际上生成的实现注入的代码并不少。以 picasso 这个属性为例,它在项目中由一个被 @Singleton 注解的方法提供,Hilt 可以在编译期间找到 picasso 类型匹配的注入提供者。

Koin 基于 Kotlin DSL 实现,实际上就是利用了 Kotin 的语法特点实现了一套用户友好的 API,没有使用 KAPT/KSP 等技术,因此它的绝大部分动作都发生在运行时。简单的 demo 如下所示:

kotlin 复制代码
@OptIn(ExperimentalSerializationApi::class)
internal val mainModule = module {
    single {
        Json {
            explicitNulls = false
            ignoreUnknownKeys = true
            isLenient = true
            prettyPrint = true
            coerceInputValues = true
            allowTrailingComma = true
            allowStructuredMapKeys = true                
        }        
    }
}

internal val GlobalKoinApplicationConfig: KoinAppDeclaration = {
    modules(mainModule)
}

这里我们希望全局提供一个 kotlinx.serialization 的 Json 对象,只需将其注入的方式和具体的对象初始化放到 module 中就可以了,如果我们想在别的地方拿到它:

kotlin 复制代码
object KtorService : KoinComponent {
    private val json: Json by inject()
// ......

只需要让需要从 Koin 获取对象的类实现 KoinComponent 接口,并调用 inject()/get() 函数即可。Hilt 和 Koin 的两种不同的设计思路带来了广泛的影响,在这一小节我们只谈论两个问题:

    1. 学习曲线陡峭与否。
    1. 是否容易追踪被注入的对象来源。

对于没有接触过依赖注入框架的新手来说,Hilt 的学习曲线无疑是更陡峭的,这个问题在国内的 Android 社区中广泛存在。首先是国内 Android 领域依赖注入的普及度不高,其次从 Dagger 时代开始依赖注入框架的实现方式就一直基于注解,所以社区之中长久以来普遍有类似的反馈。对于新手来说,需要记忆太多不同的注解,但这些注解在使用方式上又没有很大区别。此外,Hilt 在编译期间生成的代码完成了依赖注入的大部分功能,但这个过程对使用者来说是隐藏的,因为生成的代码侵入度很低(这在代码架构层面其实是个优势),所以使用者对依赖注入如何完成,从注入对象的源头取到对象再 set 给待注入的属性的完整流程没有直观的认识。此外,基于注解实现的另一个劣势是,所有待注入的属性的访问权限永远不能使用 private,这在代码架构层面带来了副作用,会暴露一些原本并不想被他人访问的属性。

Koin 的学习难度相对较低,在注入对象和从 Koin 中取出被注入的对象都是在调用函数,虽然使用者不知道 Koin 内部做了什么事情,但是大家知道只要在固定的地方输入,就能从固定的地方拿到输出,这与 Kotlin 本身的编程逻辑更贴合。

在追踪被注入对象方面,在早年还没有 IDE 查找工具的年代。如果项目非常庞大,Hilt 会难以追踪被注入对象的来源,通常只能使用 IDE find usage 这样的功能找到属性的 write 处(通常是生成的代码),再从复杂的生成代码中逐层往上找(使用 Qualifier 的情况另当别论,可以搜索相关注解的使用点)。但对于 Koin 来说情况会更好,因为所有的注入对象都有唯一的入口------module 对象,并且同一个 KoinApplication 中可以加入多个 module 对象。针对不同的业务,使用者可以定义各自的 module,这样在固定范围内查找被注入对象的来源时直接前往对应的 module,根据类型就能找到想要的答案。针对 Qualifier 的情况,Koin 可以将 String 作为 Qualifier,这在通过 IDE 查找上与 Hilt 不分上下。

但如今 Android Studio 在 2025 年的版本已经官方支持 Hilt 注入对象来源查询导航,只要是使用了类似 @Inject 这样的注解的属性旁边,都会有一个箭头,一键就能导航至注入的源头,非常方便。而 Koin 官方也提供了拥有类似功能的 Android Studio 和 Intellij IDEA 插件:Koin Dependency Injection,它在追踪注入来源这一块的功能和刚才提到的 Android Studio 对 Hilt 的支持非常相似。在使用这两个 IDE 辅助工具的情况下,双方在追踪注入对象来源这方面算是打了个平手。但这两个工具在少数场景下未必可靠,我有朋友的公司的项目使用 Bazel 构建,而不是 Gradle,针对这种项目 Hilt 的导航功能键就会时不时抽风。可见这些 IDE 工具主要还是针对当下最主流的 Android 开发技术选型,虽然这适用于绝大部分项目和公司,但针对某些大厂的项目(大厂自定义构建系统的案例很多,知名的构建工具有 Google 的 Bazel 和 Meta 的 Buck)来说就需要更谨慎的考虑。

以上两点仅仅是 API 风格带来的直接影响,其他更深远的影响在后面的小节中都有一一讨论。

类型安全

在上文中我们已经展示了 Hilt 依赖注入的原理,针对每个待注入的对象都有专门负责注入的生成方法。它可以在编译期保证类型安全,且当一个待注入的属性存在多个可能注入的源头的时候也能够在编译期发现问题所在。这也是我与同事在讨论 Hilt 和 Koin 的优劣的时候他更倾向于 Hilt 的最直接原因。

Koin 基于运行时机制,当一个期待被注入的属性尝试使用 get() 或 inject() 函数获取对象的时候,如果项目中没有它对应的注入源,或存在多个互相冲突的注入源,Koin DSL 在编译期间都无法发现问题,必须要在运行时才能发现。除了编写能覆盖所有使用 Koin 的业务代码的 unit tests 之外,Koin 还提供了一个单元测试工具可以验证 Koin modules 的注入依赖 graph 是否正确:

kotlin 复制代码
class NiaAppModuleCheck {
    @Test
    fun checkKoinModule() {
        // Verify Koin configuration
        niaAppModule.verify()        
    }
}

只需要用每个 Koin module 调用一次 verify() 函数即可。但请注意,这个验证工具只在 JVM 环境下可用,如果要编写多平台单元测试的话需要注意只能将其放在 jvmTest 或 androidTest 路径下。

以上针对 Koin 的类型安全讨论针对只使用 Koin DSL API 的情况下,实际上 Koin 还提供了一套 Annotations API。Koin Annotations 利用 KSP 和注解帮助开发者生成 DSL 代码,这会让 Koin 获得编译期强类型校验的能力,但代价是会让 Koin 的 API 风格向 Hilt 的风格靠拢。而且还需要注意的是 Koin 只针对 module 定义的 API 提供 annotations 平替,例如 single {}、factory {} 等函数,但对于获取注入依赖的 API 例如 inject()、get(),则没有对应的注解,这也意味着 Koin 的编译期类型校验仍然聚焦于 module 内的依赖关系,如果我在一个 KoinComponent 下 get 一个 module 中不存在的类型的对象,这类错误仍然需要到运行时才会被发现。

安装包 size 及运行时性能影响

在安装包 size 影响上,我们可以简单比较一下二者的 aar 文件大小。2.56.1 版本的 hilt-android 的 size 为 80kb,而 4.0.3 版本的 koin-android 的 size 则为 271kb。这种比较只是一种非常粗浅的相对比较,在实际项目中,针对 Compose,Navigation,ViewModel 等 Jetpack 组件二者都有提供相应的扩展包,但数量上 Koin 更多,所以如果囊括所有的扩展包,Koin 仍然占劣势。此外,针对真实项目,是否 build release 包?是否开启混淆?是否开启 shrinking?等都对最后的包大小有影响。但总体来说由于 Koin 基于运行时的特性,包体积确实相对更大几倍。

但除此之外,如果一个 app 使用 Hilt 的地方越多,size 的影响就会越大,因为针对每个需要被注入的属性,Hilt 都会生成新的代码文件,这在上文已经有所体现;但 Koin 则没有这方面的担忧(但这也是相对的,毕竟在代码中调用 Koin 的 API 不能说完全没有影响)。如果项目的规模较小,Hilt 的在包体积方面占优势,但随着项目规模的增大,Hilt 对包体积的影响会随之增长,但 Koin 的影响是相对固定的。针对实际项目来说哪个框架带来的实际 size 数值增长更大取决于项目对 DI 的使用规模。

在运行时性能方面,Koin 遇到的情况与 Hilt 在编译期遇到的情况类似,理论上来说随着注入对象的增加,Koin 的性能开销也会越大。我们可以看一下 Koin 在内部是如何存储 inject 的对象的,我们把 get() 函数作为引子来查看 Koin 内部存储对象的数据结构,顺着 get() 函数我们可以找到函数最终调用了 resolveFromContext 函数,它的源码如下:

kotlin 复制代码
private fun <T> resolveFromContext(instanceContext: ResolutionContext): T {        return resolveFromInjectedParameters(instanceContext)
    ?: resolveFromRegistry(instanceContext)
    ?: resolveFromStackedParameters(instanceContext)
    ?: resolveFromScopeSource(instanceContext)
    ?: resolveFromParentScopes(instanceContext)
    ?: throwNoDefinitionFound(instanceContext)
}

它按照不同的优先级根据不同的方式从 Koin 内部取对象。resolveFromInjectedParameters 内部的实现我们可以看到对象存储在一个 MutableList 里面:

kotlin 复制代码
@Suppress("UNCHECKED_CAST")
@KoinDslMarkeropen class ParametersHolder(
    @PublishedApi
    internal val _values: MutableList<Any?> = mutableListOf(),
    // TODO by default useIndexedValues to null, to keep compatibility with both indexed params & set params
    val useIndexedValues: Boolean? = null,) {
 //......

而如果从 reolveFromRegistry 里面取对象,对象存储在一个经过重新实现的特殊 HashMap 里面:

kotlin 复制代码
@Suppress("UNCHECKED_CAST")
@OptIn(KoinInternalApi::class)class InstanceRegistry(val _koin: Koin) {
    private val _instances = safeHashMap<IndexKey, InstanceFactory<*>>()
    val instances: Map<IndexKey, InstanceFactory<*>>
        get() = _instances
// ......

对象存储在 _instances 里面,它是一个重新实现过的具有线程同步能力的 HashMap,实际的实现方式并不复杂,它使用 Kotlin 的 MutableMap(实际实现是 LinkedHashMap)来存储对象,而在各种存取方法外侧加入了锁进行同步。之所以不能用 Java 的 ConcurrentHashMap 是为了能支持 Kotlin Multiplatform,但它的实现精度远没法和经过多年打磨的 ConcurrentHashMap 相比,例如它没有分段锁的实现,性能会低于 ConcurrentHashMap。

剩下几个函数的实现这里就不分析了,但随着注入对象的规模增大,get 或 inject 对象的调用成本不会有质的变化,理论时间复杂度都是 O(1)。但从实际情况来分析也不能说是毫无影响。以 HashMap 为例,存储的对象增多会一定程度上导致 hash 碰撞的概率增加。但有些数据结构遇到此类问题的影响较小,例如一开始提到的 MutableList(实际实现就是 ArrayList),通过下标来访问对象的性能和 List 中保存的对象数量没有直接联系。

实际上无论包体积大小还是运行时性能都需要经过更复杂 benchmark 设计才能具有说服力。本节内容从理论分析的角度来说,对包体积影响而言取决于具体项目,而运行时性能则是 Hilt > Koin。在具体项目中这些影响也许会细微到可忽略不计,但也许也会带来无法忽视的影响,这取决于实际项目本身。

Multi-Module 项目支持

一个复杂的项目避免不了多模块场景。在 Android 上多模块的场景通常可以简单地分为以下三种情况:

  • 1.非 app 模块和 app 模块属于同一个 project,它们会一起编译。
  • 2.非 app 模块位于一个单独 project,通过打包成 aar 文件发布到 Maven 源,app 模块通过 Gradle 依赖 Maven 源上的 aar 文件。
  • 3.将非 app 模块作为 feature module(请见参考链接 1)。

对于场景 1 来说,其实和在单 module 工程的情景下没有什么不同,但对于场景 2 场景 3,Hilt 和 Koin 的表现区别很大,我们分别分情形对比。

先谈谈场景 2,它和场景 1 的主要区别在于:

  • 非 app module 和 app module 之间互相不可见。
  • 非 app module 可能不止被一个 app module 集成,比如说它可能是一个 library。

在一个非 app module 中使用 Hilt 是可行的,将其编译为 aar 并发布后给另一个 app module 集成也是可行的,在 app module 编译构建的过程中 Hilt 会生成整个 DI graph,并检测 DI 的正确性。但是 Hilt 要求使用者必须提供一个自定义的 Android Application 类,这也就导致了所有消费使用了 Hilt 的 module 的 app module 必须也使用 Hilt,并且为了避免出现问题尽可能还要保证二者的版本一致,这无疑极大的增加了耦合,这对于一些较为通用的 SDK,例如地图、登录、支付等等极难使用 Hilt。此外在开发者进行开发的时候,非 app module 和 module 是互相不可见的,这就有极大的可能出现 DI 冲突的问题,以本文最开始的 demo 为例,如果非 app module 和 app module 中都各有一个需要被注入的属性类型为 A,但 app module 中注入的是 B 对象,而非 app module 中注入的是 C 对象,这在两个 modules 集成的时候必然出现编译错误,因为编译器不知道该把 B 还是 C 提供给这个属性。这个问题并不是绝对不可解的;比如我们可以使用 qualifier 来解决这个问题,但这又提出了更高的要求,非 app module 和 app module 都必须使用 qualifier 来注解所有期待被注入的属性以及注入的实例,但这无疑要让多个 projects 之间形成一种开发约定,并且增加了开发的工作量以及耦合。另一个方法是使用 Kotlin 的 internal 权限修饰符来解决这个问题,让可能会出现冲突的 class 都变成模块内可见的,但这无疑无法处理本身就要对外公开的 public class。

而 Koin 已经考虑了该问题,它提供了一种叫做 Context Isolation 的功能来处理这种情况。前面提到,Koin 的初始化依赖一个 KoinApplication 实例。Koin 支持在 module 的内部创建另一个 KoinApplication 实例,来看一个 Koin 官方的例子:

kotlin 复制代码
// Get a Context for your Koin instance
object MyIsolatedKoinContext {
    private val koinApp = koinApplication {
        // declare used modules
        modules(coffeeAppModule)
    }
    
    val koin = koinApp.koin
}

通过这个新的 KoinApplication,我们可以获取到另一个 Koin 对象,实际上无论 get() 还是 inject(),最终都是 Koin 对象的成员函数。接着我们创建一个隔离的 KoinComponent:

kotlin 复制代码
internal interface IsolatedKoinComponent : KoinComponent {
    // Override default Koin instance
    override fun getKoin(): Koin = MyIsolatedKoinContext.koin
}

我们覆盖了 getKoin() 函数,这样 IsolatedKoinComponent 获取的 Koin 对象就是我们刚刚创建的 Koin 对象了。之后需要被注入的 class 只要实现 IsolatedKoinComponent 接口,就可以从这个隔离的 Koin 中获取注入对象。Context Isolation 特别适合多 modules 的情况,但即使在同一个 module 中,它也可以用来满足不同业务的 DI 需要被隔离的需求。

上面讨论的问题其实已经从实现原理角度阐述了 Hilt 与 Koin 的差别。针对场景 3,我们首先要了解 feature module 是一种 Google 官方提供的按需加载方案,它将部分业务代码 build 成一个 bundle 然后发布到 Google Play 上,当用户需要使用到这个功能模块的时候再动态下发。针对这种场景,app module 在编译的时候使用了 DI 框架的这个非 app module 是不存在的,因为它要在运行时才会下发。这时候,Hilt 直接就放弃治疗了(参考链接 2),在官方文档中直接建议大家去使用 Dagger。对于 Koin 来说,Context Isolation 也能适用于这种情形。总体来看在多模块项目中,Koin 的能力的确远远高于 Hilt。

多平台支持

如今,Kotlin Multiplatform 非常受欢迎,对于大部分 Android 开发者来说,入门 Kotlin Multiplatform 的门槛极低,因此许多 Android 开发者在构建应用程序的时候都会考虑自己的技术选型能否扩展至 Kotlin Multiplatform。

Hilt 起初就是为 Android 而设计,大家可以看到 Hilt 官方定义了许多 EntryPoint,例如 Application、Activity、Fragment、View、Service 等等,其中 Application 是必须提供的,因此 Hilt 不仅不支持 Kotlin Mulitplatform,以后即使要支持也必须修改整体设计,当前已经有消息传出说 Google 将会开始开发 Dagger 和 Hilt 对 Kotlin Multiplatform 的支持,但具体日期尚无法确认。但 Koin 诞生之初就支持 Kotlin Multiplatform,其主要代码实现都在 common 层,与平台无关,可以非常简单的编译到各个目标平台。因此如果你一开始的目标就是构建 KMP 应用程序,Koin 是目前最好的选择。

总结

通过以上论述我们从各个方面了解了 Hilt 和 Koin 的差异,我们可以简单的用以下表格来总结:

Hilt Koin
API 风格 Annotations Kotlin DSL (可选 Annotations)
学习难度 较高 较低
编译期注入类型校验 较强 较弱
注入实例追踪(不依赖 IDE) 较易
注入实例追踪(使用 IDE 工具)
Size 影响 size 小但与使用量成正比 size 大但影响相对固定
运行时性能额外开销 较低 较高
Multi-Module 支持
Feature Module 不支持 支持
Kotlin Multiplatform 不支持 支持

基于以上评估可以看到 Koin 适应的复杂场景更多,针对部分场景甚至是唯一选择。但 Hilt 也在某些场景下具有优势,例如理论运行时性能,小规模项目中的包体积等等。单纯评判某个库更优秀是困难的,它们总能找到可以发挥自身优势的具体场景。

参考链接

相关推荐
绝无仅有2 小时前
企微审批对接错误与解决方案
后端·算法·架构
一起搞IT吧2 小时前
相机Camera日志实例分析之五:相机Camx【萌拍闪光灯后置拍照】单帧流程日志详解
android·图像处理·数码相机
写不出来就跑路2 小时前
Spring Security架构与实战全解析
java·spring·架构
浩浩乎@2 小时前
【openGLES】安卓端EGL的使用
android
Patrick_Wilson3 小时前
青苔漫染待客迟
前端·设计模式·架构
zzq19964 小时前
Android framework 开发者模式下,如何修改动画过度模式
android
木叶丸4 小时前
Flutter 生命周期完全指南
android·flutter·ios
阿幸软件杂货间5 小时前
阿幸课堂随机点名
android·开发语言·javascript
没有了遇见5 小时前
Android 渐变色整理之功能实现<二>文字,背景,边框,进度条等
android