Koin DSL:module DSL详解、qualifier、bind、named的使用

说实话,Koin 最让我喜欢的地方,就是它的 DSL 设计。你想想看,不用注解、不用代码生成,光靠 Kotlin 的 DSL 就能把依赖注入写得明明白白。今天我们就来深入聊聊 Koin 的 module DSL,以及那几个让你又爱又恨的关键字——qualifier、bind、named。

module DSL 的核心结构

Koin 的 module 本质上就是一个 Kotlin 的 lambda 表达式。我刚开始用的时候,觉得它跟 Dagger 的 Module 差不多,但用久了才发现,这完全是两种思路。

val myModule = module {
    single { MyRepository() }
    factory { MyViewModel(get()) }
}

嗯,这里要注意:singlefactory 是 Koin 最基础的两种声明方式。single 创建的是单例,整个应用生命周期内只有一个实例;factory 每次注入都会创建新实例。我在项目中遇到过一个问题——有个网络请求的 ViewModel 用了 single,结果每次切回来数据都不刷新。后来改成 factory 就好了。

关键区别:

  • single:全局唯一实例,适合无状态的服务类
  • factory:每次注入新实例,适合有状态的组件
  • scoped:在特定作用域内保持单例,比如 Activity 生命周期内

named:给依赖起个名字

有时候我们需要同一个接口的不同实现。比如我有两个数据库,一个本地 SQLite,一个远程 Firebase。这时候 named 就派上用场了。

val databaseModule = module {
    single<Database>(named("local")) { LocalDatabase() }
    single<Database>(named("remote")) { RemoteDatabase() }
}

// 注入时指定名字
class MyService {
    private val localDb: Database by inject(named("local"))
    private val remoteDb: Database by inject(named("remote"))
}

我个人习惯用字符串作为名字,但也可以用 Kotlin 的 object 或者枚举。不过要注意,名字一定要唯一,否则 Koin 会报错。我曾经犯过一个低级错误——两个模块里用了相同的名字,结果注入的时候总是拿到同一个实例,排查了半天才发现是命名冲突。

qualifier:更灵活的限定符

qualifiernamed 其实是一回事。说白了,named 就是 qualifier 的一个快捷方式。Koin 内部把 named("xxx") 转换成了 qualifier(StringQualifier("xxx"))

// 这两种写法等价
single(named("local")) { LocalDatabase() }
single(qualifier(StringQualifier("local"))) { LocalDatabase() }

// 自定义 qualifier
val LocalQualifier = object : Qualifier {
    override val value: String = "local"
}

single(qualifier(LocalQualifier)) { LocalDatabase() }

你可能会问,既然 named 已经够用了,为什么还要用 qualifier?嗯,当你的项目规模变大,需要更复杂的限定逻辑时,自定义 Qualifier 就很有用了。比如你可以根据运行环境动态生成 qualifier。

bind:让一个实现绑定多个类型

这个功能我特别喜欢。有时候一个类实现了多个接口,用 bind 可以一次性注册所有类型。

interface ApiService
interface AuthService

class MyServiceImpl : ApiService, AuthService

val module = module {
    single { MyServiceImpl() } bind ApiService::class bind AuthService::class
}

// 现在可以注入任意一个接口
class Client {
    private val api: ApiService by inject()
    private val auth: AuthService by inject()
}

注意看,这里 bind 是链式调用的。你可以绑定任意多个类型。我在重构一个老项目时,发现有个类实现了三个接口,用 bind 一次性搞定,代码瞬间清爽了很多。

避坑指南:

我曾经遇到过一个诡异的问题——用 bind 绑定了多个接口,但注入时总是报错说找不到依赖。后来发现是因为我忘了在 single 后面加 bind,而是写成了 single { MyServiceImpl() }.bind(...)。记住,bindsingle 的扩展函数,要写在花括号外面。

module DSL 的组合与拆分

项目大了以后,把所有依赖写在一个 module 里显然不现实。Koin 允许你把 module 拆分成多个文件,最后再组合起来。

// network.kt
val networkModule = module {
    single { HttpClient() }
    single { ApiService(get()) }
}

// database.kt
val databaseModule = module {
    single { Database() }
    single { UserDao(get()) }
}

// viewmodel.kt
val viewModelModule = module {
    factory { MainViewModel(get(), get()) }
}

// 启动时组合
startKoin {
    modules(networkModule, databaseModule, viewModelModule)
}

我个人习惯按功能模块来拆分,而不是按层。比如用户模块一个 module,订单模块一个 module。这样每个 module 内部自包含,改动时影响范围小。

知识体系总览

下面这张图总结了 Koin DSL 的核心概念和它们之间的关系:

Koin DSL 核心知识体系 module DSL 声明方式 single { } factory { } scoped { } 限定符 named("xxx") qualifier { } 自定义 Qualifier 绑定与组合 bind 多类型绑定 modules() 组合 按功能拆分 核心原则:单一职责、按功能拆分、命名唯一

实际项目中的最佳实践

说了这么多理论,来点实际的。我在一个电商项目里是这样组织 Koin 的:

// 1. 基础模块 - 网络、数据库、缓存
val baseModule = module {
    single { Retrofit.Builder().baseUrl("https://api.example.com").build() }
    single { Gson() }
    single { SharedPreferences(context) }
}

// 2. 业务模块 - 按功能拆分
val userModule = module {
    single { UserApi(get()) }
    single { UserRepository(get()) }
    factory { UserViewModel(get()) }
}

val productModule = module {
    single { ProductApi(get()) }
    single { ProductRepository(get()) }
    factory { ProductViewModel(get()) }
}

// 3. 测试模块 - 用 named 区分不同环境
val testModule = module {
    single<ApiService>(named("mock")) { MockApiService() }
    single<ApiService>(named("real")) { RealApiService(get()) }
}

我的小技巧:

named 区分不同环境时,建议定义一个常量类来管理所有名字。这样不会拼错,也方便全局搜索。

object QualifierNames {
    const val LOCAL_DB = "local_db"
    const val REMOTE_DB = "remote_db"
    const val MOCK_API = "mock_api"
    const val REAL_API = "real_api"
}

常见问题与避坑

最后分享几个我踩过的坑:

  1. 循环依赖:Koin 不会自动检测循环依赖,运行时才会报错。我建议在写 module 时就用 by inject() 懒加载,而不是在构造函数里直接传参。
  2. module 覆盖:如果你在多个地方定义了同名的 single,后面的会覆盖前面的。我曾经在测试模块和生产模块里用了相同的名字,结果测试时一直拿到生产环境的实例。
  3. 作用域泄漏:用 scoped 时一定要记得在合适的时机释放。我在一个 Fragment 里用了 scoped,但忘了在 onDestroy 时清理,结果导致内存泄漏。

嗯,Koin 的 DSL 其实不难,关键是要理解它的设计哲学——用 Kotlin 的语法糖,让依赖注入变得像写普通代码一样自然。你多用几次,就会爱上这种简洁的感觉。

公众号:蓝海资料掘金营,微信deep3321