Dagger2からKoinに移行しようとしてみた。

春ぐらいからFRESH LIVEの配信アプリFRESH CASTを開発しています。 FRESH CASTのDIはDagger2 Android Supportを採用しているのですが、試しにKoinに書き換えてみたので共有します。(リリースはしないと思う)
移行については後半書きます。
*参考にしているKoinのバージョンは1.0.1

Koin?

Kotlin製のDIライブラリです。ReflectionとDelegatedPropetiesを使ってdependencyをinjectしてくれます。
Dagger2のようにJSR-269(AnnotationProcessing)によってコードを自動生成するような作りにはなっていません。
したがって、人間が手で依存性を解決するコードを書かなければなりませんが、提供されているExtensionFunction, TopLevelFunction, DSLのおかげで割と手軽に書くことができます。

Module

まず依存性を提供するModuleを定義します。

val module = module {
    single { FooApi() }
    single { FooDatabase() }
    // 型推論あるので実際にはget関数の呼出に型を明示する必要はない
    single { FooRepository(get<FooApi>(), get<FooRepository>()) }
    viewModel { FooViewModel(get<FooRepository()>) }
    factory { Foo() }
}

single関数を使うとsingletonになります。singletonにしたくない場合はfactory関数を使います。AndroidArchitectureComponentsのViewModelに関してはviewModel関数を使います。

Inject

プロパティまたはローカル変数の初期化をinject関数に委譲することでinjectします。

class FooActivity: AppCompatActivity() {
    val foo: Foo by inject()
    val viewModel: FooViewModel by viewModel()
}

class FooFragment: Fragment() {
    val viewModel: FooViewModel by sharedViewModel()
}

viewModelに関してはinject関数ではなくviewModel関数を使います。ActicityとFragmentでViewModelを共有する場合はsharedViewModel関数を使います。

startKoin

ApplicationのonCreateでstartKoin関数を呼べば完了。

class App: Application() {
    override fun onCreate() {
        super.onCreate()
        startKoin(this, listOf(module))
    }
}

このとき依存グラフの正当性がチェックされます。module内で提供されていない型をgetしようとしたり、同じ型を複数定義していたりすると例外が発生します。(同じ型を複数定義するオプションも存在はする)

module {
    single { FooApi() }
    single { FooApi() } // 重複
    single { BarRepository(get<BarApi>()) } // BarApiは提供されていない
}

提供されていない型をinjectしようとしているのように、実際にinjectするまで評価されないのでチェックできません。
あまり依存グラフが大きいor複雑だとチェックに時間がかかってしまうかもしれません (要検証)

Scope

scopeを定義しscope単位でsingletonを実現することも可能です。

module {
    scope("scopeName") { FooApi() }
}

val fooApi: FooApi by inject(scope = getOrCreateScope("scopeName"))

scopeを指定する場合はmoduleでscope関数を使い、getOrCreateScope関数Scopeを取得してinject関数に渡します。module側と利用側でSscopeの指定が必要なのがちょっと怖いです。

scopeを破棄するにはclose関数を使います。

getOrCreateScope("scopeName").close()

AndroidArchitectureComponentsのLifecycleが利用可能であればこれらを簡略化できます。

module {
    scope("scopeName") { FooApi() }
}

class MainActivity: AppCompatActivity() {
    val fooApi: FooApi by inject()

    override fun onCreate() {
        super.onCreate()
        bindScope(getOrCreateScope("scopeName"))
    }
}

bindScope関数を利用すれば、inject関数にscopeを渡す必要がなくなります。scopeのcloseも自動で行ってくれます。デフォルトではLifecycle.Event.ON_DESTROYが指定されており、onDestroyでscopeがcloseされます。他にもLifecycle.Event.ON_STOPを指定することが可能です。

Dagger2からKoinに移行する

単純な書き換えが多いだけなので大変ではあるが難しくはないです。正規表現でを利用してreplaceしていくと楽。

Dagger2のModuleクラスをすべてKoinのdslで書き換える

@Module class Module {
    @Provide fun foo() = Foo()
    @Singleton fun bar() = Bar()
}
module {
    factory { Foo() }
    single { Bar() }
}

@Moduleアノテーションのincludes@Componentアノテーションのmodulesを利用して複数のModule/複数のファイルに分割していた場合は以下のような方法があります。

@Module(includes = [AnotherModule::class]) class Module
// or
@Component(modules = [Module::class, AnoherModule]) interface Component
val module = module { ... }
val anotherModule { ... }

class App: Application {
    override fun onCreate() {
        super.onCreate()
        startKoint(this, listOf(module, anotherModule))
    }
}
val module = module {
    subModules += listOf(anotherModule())
 }
private val anotherModule { ... }

class App: Application {
    override fun onCreate() {
        super.onCreate()
        startKoint(this, listOf(module))
    }
}

Dagger2のConstructorInjectionをKoinのdslで書き換える

class Foo @Inject constructor(private val bar: Bar) {
    ...
}

Dagger2は依存グラフに存在する型を引数にとるconstructorであれば@InjectでConstructorInjection可能です。Moduleにprovider関数を定義しなくてよいので非常に便利です。
Koinに移行する場合はこれもsingle|factory|scope関数を使って書かなければなりません。

ActivityScopeをKoinで実現する

Dagger2でActivityごとにScopeを切っている場合。
cf. Dagger 2でAndroidのライフサイクルごとのSingletonを実現する

koinでもscopeを定義しscope単位でsingletonをし、bindScope関数によってonDestroyでscopeをclose可能なことは前述しました。
FooActivityからBarActivityに遷移するパターンを考えてみましょう。
このとき、FooActivity#onDestroyよりもBarActivity#onCreateのほうが先に呼ばれるため、FooActivityでscopeをcloseする前にBarActivityでbindScope(getOrCreateScope("scope name"))が実行されます。よってFooActivityで利用していたscopeをBarActivityで再利用してしまいます。

以下のような関数を作ることで画面遷移後に強制的にscopeを再生性することは可能です。

fun LifecycleOwner.recreate(name: String) = (this as ComponentCallbacks).getKoin().scopeRegistry.run {
    getScope(name)?.close()
    createScope(name)
}

上記のような関数を利用する場合、injectにも手を加えたほうが良いとおもいます。
inject関数は以下のように遅延評価のlazyを利用しています。

inline fun <reified T : Any> ComponentCallbacks.inject(
    name: String = "",
    scope: Scope? = null,
    noinline parameters: ParameterDefinition = emptyParameterDefinition()
) = lazy { get<T>(name, scope, parameters) }

scopeが閉じられてからアクセスしてしまうと新しいscopeからインスタンスをinjectすることになってしまうので以下のような手段を取ると良いかもしれません。
scopeに関してはまだベストプラクティスを見つけれていないので他に良い方法があったら教えてほしいです。

class MainActivity: AppCompatActivity() {
    lateinit var foo: Foo

    override fun onCreate(savedInstanceState: Bundle?) {
        ...
        bindScope(recreate("scopeName"))
        foo = get()
        // ViewModelに関しても、viewModel関数の内部で呼んでいる関数を利用して初期化を行えばよい。
    }
}

// or

class MainActivity: AppCompatActivity() {
    val foo: Foo by inject()

    override fun onCreate(savedInstanceState: Bundle?) {
        ...
        bindScope(recreate("scopeName"))
        foo // 意図的にアクセスすることで評価させる
    }
}

複数の型を扱う

interface Fooとその実装class FooImplがある場合を考えてみましょう。

module {
    single { FooImpl() }
}

val foo: Foo by inject()

これはRuntimeで例外が発生します。
FooImplFooおよびFooImpleとして提供するにはbind関数を利用します。

module {
    single { FooImpl() } bind Foo::class
}

val foo: Foo by inject()

Dagger2, Koin比較

個人的な意見ですが、Dagger2 Android Supportは難しすぎます。Componentの取得やSubComponentのインスタンス化や、inject関数の呼出などを省けるようにはなりましたが、Processorに渡さなければならない型情報が増えたので黒魔術感が増大しました。(そしてProcessorの吐くエラーはわかりにくい)
Koinはdslでmoduleを記述し、ReflectionとDelegatedPropertiesを使ってinjectを行うだけなのでKotlinに慣れている人であればDagger2に比べて容易/手軽だと思います。

その手軽さの一方で失ったものもあります。
Koinはruntimeに依存グラフの正当性をチェックをします。もし誤りがあった場合にはruntimeでクラッシュしてしまいます。

Dagger2を捨ててKoinに移行すべきか?

あなた次第 私はCompile-timeに依存グラフの正当性をチェックしてくれるほうが好きです。Runtimeで落ちるものはQAが大変なので。

Motifを試してみよう!

UberがDagger2をwrapしたMotifというDIを開発しているので、次はこちらを試してみようと思います。
simple APIを目指しているそうです。


Koin  DI  Kotlin  Android