Koin in Android: 更简单的依赖注入

Dagger 之殇

如果还不清楚什么是依赖注入,那么请参考之前写的 Dagger2 in Android(一)通俗基础开头部分。如果你不了解 Dagger 倒也无妨,本文会进行一定的对比,但仅针对接触过 Dagger 的同学,否则大可以忽略。

Dagger2 作为著名优秀的依赖注入框架广为流传,何况还是 Android 的亲爸爸 – Google 在维护,因此相信很多人会将其作为 Android 开发的首选 DI 框架。Dagger 从入门到放弃一定是很多很多人必经甚至多次经历的历程:anger:

诚然,Dagger 很强大。但它的学习曲线太过于陡峭,即使好不容易搞清楚了各种注解与概念,也很难适当地运用到项目中。同时对于 Activity 之类重要但却不能我们自己初始化的类 Dagger 明显水土不服。为此,Google 搞了个 .android 扩展库来「简化」使用。我不否认最终确实简化了代码,但是这玩意本身就很难度,学习成本堪称指数级。

除此之外,.android 扩展库对于 ViewModel 依然是严重的水土不服,甚至 Google 官方 Demo 的实现也是一堆问题。

Koin 基础

Koin 是纯 Kotlin 编写的轻量级依赖注入框架,轻量是因为它只使用 Kotlin 的函数解析特性,没有代理,没有代码生成,没有反射!官方声称5分钟快速上手。随着 Kotlin 的推广,Koin 这个后起之秀也获得了越来越多的关注。当然它也提供了 implementation "org.koin: koin-java:1.0.0" 扩展库来支持 java,但本文不会涉及。

不建议新手阅读 Koin 源码。作为 DSL,它大量使用了 Kotlin 的高级特性,例如 inline 函数。相对来说难以理解。

使用 Koin 所需的依赖在官方文档已经说得很明确了。这里因为使用了 AndroidX 库,所以引入 org.koin: koin-androidx-viewmodel:2.0.1,事实上它已经包含了 Koin 基础库以及 Android 扩展库,没有必要手动依赖了。

依然是使用厨师与火炉的例子来帮助理解,创建一个厨师 Chef 类,他需要一个火炉 Stove

class Stove() {}

class Chef(val stove: Stove) {}

Module

Module 同时充当了 Dagger 里的 @Inject@Module。Module 是一个容器,它储存了所有需要注入的对象的实例化方式。换句话说,假如我们想要在某个类中注入 Stove,那么就必须在 Module 中定义究竟如何取得或创建 Stove 的实例,这一过程本质是将 Service 插入 Module 图中。

在 Dagger 中,我们自己编写的类只需加上 @Inject 就可以被框架所识别,但是 Koin 要求手动设置。

鉴于 Koin 是一个 DSL,所以 Module 的定义非常简单,不用啰里啰嗦的注解,只需要使用 module 函数即可:

val myModule = module{
    factory { Stove() }
}

注意:这里是定义在 top-level 的,而不是在某个类中。

就这么简单,我们创建了一个 Module 叫做 myModule,并且添加了一个 Service 就是 Stove。添加 Service 有两个函数分别是 factorysingle。区别在于前者将在每次被需要时都创建(获取)一个新的实例,也就是说后边代码块将被多次运行。而 single 会让 Koin 保留实例用于今后直接返回,类似于 Dagger 中 @Singleton 的作用。

Get

get 用于最终实现注入,顾名思义就是获得一个实例。在 Dagger 中,依赖 @Inject@Component 来实现注入很别扭。相比之下 get 非常符合常规习惯,在需要获取实例的地方直接填个 get,Koin 就会根据数据类型自动从上文 Module 中找到匹配的方法取得实例。

val myModule = module{
    factory { Stove() }

    factory { Chef(get()) } // 注意这行
}

在正式使用注入之前我们先新增一个 Chef Service。根据之前的定义,Chef 构造函数中需要传入一个 Stove,这里就可以直接使用 get 获取。在运行时 Koin 判断出这里需要一个 Stove 类型的对象,于是去搜寻所有装载的 Module 是否有对应的 Service,显然之前我们已经定义过了,因此会直接调用 Stove() 来创建一个新的实例并返回,完成了依赖注入流程。

如果所需类型不确定,或者需要手动指定一个类型,也可以这么写:get<TYPE>()

初始化与使用

前面我们已经完成了所有准备工作,是不是特别简单?距离成功注入只有一步之遥啦,现在需要初始化 Koin,通常来说我们会在 Application.onCreate 中进行。

class MyApplication : Application() {

    override fun onCreate() {
        super.onCreate()

        startKoin {
            androidLogger(Level.INFO)
            androidContext(this@MyApplication)
            modules(localModule)
        }

    }
}

使用 startKoin 启动一个全局 Koin。作为 Android 平台,还可以指定 Logger 与 Context。

androidLogger 可以将 Koin 日志输出从默认的 java logger 框架切换到 Android Logcat,更加符合习惯,同时也可以自定义日志级别。

androidContext 可以传入一个全局 Context,一般来说就采用 Application。作为 Android 开发对于 Context 一定不陌生,许许多多的地方都需要用到,例如发送广播或读取 SharedPreferences。这里传入 Context 后相当于在 Module 中插入了 Context Service 定义,在任何需要的地方直接使用 get 就可以拿到。

最后通过 modules 装载我们写好的 Module。

初始化完成后就可以在任何地方实现注入了:

class LocalWatchFaceAty : AppCompatActivity() {
    private val chef: Chef = get()
}

OK,官网说的5分钟入门确实不算夸张 :sunglasses:

进一步

Bind

Bind 是一个中缀函数,可以用于把一个 Service 关联到多个类。例如现在有两个接口:Tool, FlammableStove 实现了他们。显然如果只定义1个 Service 是不能同时注入 Stove 和这两个接口的。这时候就轮到 Bind 大显身手了:

val myModule = module{
    factory { Stove() } bind Tool::class bind Flammable::class // <- here!

    factory { Chef(get()) }
}

这么一来,下面的三个注入都是合法的,并都会得到一个 Stove 实例:

val chef: Chef = get()
val tool:Tool = get()
val flammable:Flammable = get()

Scope

Scope 用于控制对象在 Koin 内的生命周期。事实上,前面所讲的 singlefactory 都是 scope。

  • single 创建的对象在整个容器的生命周期内都是存在的,因此任意地方注入都是同一实例。
  • factory 每次都创建新的对象,因此它不被保存,也不能共享实例。

定义 Scope 比较简单:

val myModule = module{
    scope(named("MY_SCOPE")){
        scoped {
            Stove()
        }
    }
}

但是使用起来就比较麻烦了,我们需要创建或关闭 scope,毕竟 Kolin 怎么会知道你究竟想实现怎样的生命周期呢?

// 如果存在则直接获取,否则创建 scope
val scope = getKoin().getOrCreateScope("myScope", named("MY_SCOPE"))
val stove1: Stove = scope.get()
val stove2: Stove = scope.get()
scope.close()

这里首先得到了一个 scope 实例,然后进行注入,最后关闭 scope。那么在同一个 scope 中注入的实例是相同的。例如 stove1stove2 实际上是同一个实例。当 scope 被关闭时其缓存会被清空,自然下一次重新创建后会注入新的对象。

注意区分一点,定义 Scope 时使用的叫做 Qualifier,通过 named 可以用字符串包装。在创建 scope 时需要通过 Qualifier 关联到定义,并同时给一个字符串类型的 idid 仅在运行时使用。可以类比成 Android 的布局文件的 View id 与实际变量名的关系。我们需要通过 View id 来获取实例并赋值给变量保存,变量名与 View id 没有必然的关系。


在 Android 中我们经常需要以 Activity 为单位创建 scope,为了简化使用,Koin 提供了 Android 扩展库。在 ActivityFragment 中,可以直接使用 currentScope 变量来表示当前 scope,他会被自动创建,并绑定到 Android 组件的生命周期。

class LocalWatchFaceAty : AppCompatActivity() {
    private val stove: Stove by currentScope.inject()
}

除了之前使用的 get,还可以像这样使用 inject 实现懒加载。

定义 scope 也变得简单。之前我们使用字符串作为限定符定义了 scope,现在直接使用类作为限定符:

val myModule = module{
    scope(named<LocalWatchFaceAty>()){
        scoped {
            Stove()
        }
    }
}

ViewModel

ViewModel 可以说是 Android 架构组件发布后最流行的部分了,幸运的是 Kolin 对其做了非常方便的适配。对于 ViewModel 类直接使用 viewModel 来定义 Service:

val localModule = module {
    viewModel {
        KitchenViewModel()
    }
}

ActivityFragment 中直接使用 by viewModel()getViewModel() 来注入。

class LocalWatchFaceAty : AppCompatActivity() {
    private val vm: KitchenViewModel by viewModel()
}

这样一来得到的 ViewModel 可以自动与 UI 生命周期关联。而如果使用传统的 get 只能得到实例但没有任何关联,失去了 ViewModel 最重要的作用。

总结

可以明显感受到,Koin 小巧精美,上手难度低,与现代化架构技术非常协调。使用起来符合常规习惯,不要被迫学习一堆概念与复杂的模式。

事实上,Koin 还有更多的高级功能,例如动态加载 Module、本地配置项读取等,也都很简单,通过官方文档可以快速了解。

在 “Koin in Android: 更简单的依赖注入” 上有 1 条评论

发表评论

电子邮件地址不会被公开。 必填项已用*标注