本文首发于公众号"Android技术圈"
编译零警告,测试全绿,上线直接炸。
用过 Koin 的人或多或少都经历过这种场景------NoBeanDefFoundException 在某个不起眼的页面突然蹦出来,而你根本不知道是哪个依赖没注册。
这不是 Bug,是 Koin 的"特性"。它的运行时解析机制意味着编译器永远不会替你检查依赖图是否完整。但更多时候,问题不在 Koin 本身,而在于我们用错了。

错题集
先来看看项目中最常见的几种写法问题。
永远用构造函数注入
这是最重要的一条,没有之一。
bash
// 错误写法 ❌
class UserRepository : KoinComponent {
private val db: AppDatabase by inject()
private val api: UserApi by inject()
}
看起来很简洁对吧?但这其实是 Service Locator 反模式,不是依赖注入。
依赖被隐藏 :外部看到 UserRepository(),完全不知道它依赖了什么。
测试必须启动 Koin :mock 一个依赖还得先 startKoin,测完还得 stopKoin。
运行时炸弹 :by inject() 是 lazy 的,如果 scope 已关闭才触发,直接崩。
bash
// 正确写法 ✅
class UserRepository(
private val db: AppDatabase,
private val api: UserApi,
) {
fun getUser(id: String) = api.fetchUser(id)
}
val dataModule = module {
single { UserRepository(db = get(), api = get()) }
}
依赖一目了然,测试直接传 mock,和 Koin 零耦合。
规则 :业务类(Repository、UseCase、DataSource)永远不该实现 KoinComponent。只有 Activity、Fragment 这种你无法控制构造函数的框架入口点,才可以用。
这种写法在我们项目中特别多,也是扫描出来数量最大的一类问题。
UseCase / Mapper 不要用 single
bash
// 错误写法 ❌
val domainModule = module {
single { GetUserUseCase(get()) }
single { UserMapper(get()) }
}
UseCase 和 Mapper 本质上是无状态对象。用 single 注册意味着全局共享同一个实例。
一旦哪天某个 UseCase 内部加了一个临时变量存中间结果,所有调用方就共享了这份可变状态。并发场景下,这种 Bug 极难复现、极难排查。
bash
// 正确写法 ✅
val domainModule = module {
factory { GetUserUseCase(get()) }
factory { UserMapper(get()) }
}
factory 每次注入都创建新实例,用机制兜底,彻底消除共享状态风险。
ViewModel 必须用 viewModel { } 注册
bash
// 错误写法 ❌
single { UserProfileViewModel(get()) }
// 正确写法 ✅
viewModel { UserProfileViewModel(get()) }
用 single 注册 ViewModel 会导致三个严重问题:内存泄漏(永远不被回收)、状态残留(页面退出再进入看到旧数据)、脱离生命周期管理。
viewModel { } 会将 ViewModel 绑定到对应的 ViewModelStoreOwner,页面销毁时自动清理。

面向接口注入
bash
// 错误写法 ❌
class UserRepositoryImpl(
private val api: UserApiImpl, // 具体实现类
) : UserRepository
// 正确写法 ✅
class UserRepositoryImpl(
private val api: UserApi, // 接口
) : UserRepository
val dataModule = module {
single<UserApi> { UserApiImpl(get()) }
single<UserRepository> { UserRepositoryImpl(get()) }
}
规则 :single<接口> { 实现类() },构造函数参数类型永远是接口。测试时替换实现只需改 Module,业务代码零改动。
同类型多实例必须用 named
bash
// 错误写法 ❌ ------ 后者会静默覆盖前者
single { OkHttpClient.Builder().addInterceptor(authInterceptor).build() }
single { OkHttpClient.Builder().addInterceptor(logInterceptor).build() }
// 正确写法 ✅
single(named("auth")) { OkHttpClient.Builder().addInterceptor(authInterceptor).build() }
single(named("logging")) { OkHttpClient.Builder().addInterceptor(logInterceptor).build() }
Koin 发现同一类型注册了两次,后者会静默覆盖前者,没有任何警告。你以为注入的是带 Auth 的 Client,实际拿到的是只有 Log 的那个。
Compose Preview 中不能用 koinInject
bash
// 错误写法 ❌
@Preview
@Composable
fun ProductCardPreview() {
val viewModel: ProductViewModel = koinViewModel() // 直接崩溃
ProductCard(viewModel.state)
}
// 正确写法 ✅
@Preview
@Composable
fun ProductCardPreview() {
ProductCard(state = ProductState(name = "示例商品", price = 99.0))
}
Android Studio 的 Preview 渲染器没有 Koin 运行环境,调用 koinViewModel() 或 koinInject() 会直接崩溃。
原则 :koinViewModel() 只在 Screen 级别使用,子组件只接收数据参数。
七条规则汇总
梳理下来,一共整理了 7 条规则,分为必须修改 和建议修改两个级别:
| 规则 | 检测内容 | 级别 | | --- | --- | --- | | KL001 | 业务类实现 KoinComponent | 必须修改 | | KL002 | 非框架类中 by inject() | 必须修改 | | KL003 | ViewModel 用 single 注册 | 必须修改 | | KL004 | @Preview 中用 koinInject/koinViewModel | 必须修改 | | KL005 | UseCase/Mapper 用 single 注册 | 建议修改 | | KL006 | 构造函数参数使用 Impl 类型 | 建议修改 | | KL007 | 同类型多次注册无 named | 建议修改 |
这里的必须修改和建议修改只是相对的。我们认为未来因为更换框架等原因产生影响更严重的为必须修改,并非指实际业务上要立即更改。

写在最后
Koin 是一个好框架,但"好用"和"用好"之间差着一个错题集的距离。
编译器帮不了你,但工具可以,你可以将上述规则整理成skill,让AI帮你自动诊断。
你们项目中 Koin 的使用规范吗?踩过哪些坑?评论区聊聊。