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()) }
}
嗯,这里要注意:single 和 factory 是 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:更灵活的限定符
qualifier 和 named 其实是一回事。说白了,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(...)。记住,bind 是 single 的扩展函数,要写在花括号外面。
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 的:
// 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"
}
常见问题与避坑
最后分享几个我踩过的坑:
- 循环依赖:Koin 不会自动检测循环依赖,运行时才会报错。我建议在写 module 时就用
by inject()懒加载,而不是在构造函数里直接传参。 - module 覆盖:如果你在多个地方定义了同名的
single,后面的会覆盖前面的。我曾经在测试模块和生产模块里用了相同的名字,结果测试时一直拿到生产环境的实例。 - 作用域泄漏:用
scoped时一定要记得在合适的时机释放。我在一个 Fragment 里用了scoped,但忘了在 onDestroy 时清理,结果导致内存泄漏。
嗯,Koin 的 DSL 其实不难,关键是要理解它的设计哲学——用 Kotlin 的语法糖,让依赖注入变得像写普通代码一样自然。你多用几次,就会爱上这种简洁的感觉。