6
5

More than 3 years have passed since last update.

Kotlin, LiveData, coroutine なんかを使って初めてのAndroidアプリを作る(7)リファクタリングとKoinでDI編

Last updated at Posted at 2020-02-02

すっかり遅くなってしましました(汗)
テストにドはまりしたり、Flutterに浮気したりしてしたわけではありませんよ?(笑)

ということで、前回の続きです。

今回の目標

  • ライブラリの最新版対応
  • Koinを使ってDI(依存性注入)を行う

アプリの機能としては進歩しませんが、ちょっとだけ「リファクタリング」ぽいことをやります。
メインはDIの方ですが、ちょうどよいので使っているライブラリのバージョンも最新にしてみようと思います。

ライブラリの最新版対応

アプリの作成中には、1ヶ月ほどで完成できるようなアプリならともかく、ある程度の期間掛けて開発する場合には、その途中でライブラリや開発ツールのバージョンがいつの間にかドンドン上がってしまい、リリースする頃にはいつの間にか「まだそのバージョン使ってるの?」なんてことも。

単にバージョンの数字を上げてやるだけで良い場合は特に問題ないのですが、微妙にinterfaceが変わったり、使っていたAPIが非推奨になったりなどがあると、コーディングレベルでも対応が必要になってきます。酷い場合には全く互換性のないコードになってしまい、ビルドが全く通らない、なんてこともあります。
ということで、私の場合は、定期的に使っているライブラリの最新版に上げてビルドと動作確認をする、ということを心がけています。

ただ、リリース間近にやってしまうとかえってコードを汚くしたり改変箇所が多すぎて期限に間に合わなくなりそうになったり等あり得るので、そういう場合は、Release Noteを見て、入れて大丈夫かどうか判断してからやります。

ということで、各種ライブラリのバージョンをチェックしてみましょう。

(1) ライフサイクル関係

最初にライフサイクルを導入したときの、第2回の記事の時点では、ライフサイクル関係のライブラリが以下のバージョンになっているかと思います。

app/build.gradle

    def lifecycle_version = "2.0.0"
    implementation "androidx.lifecycle:lifecycle-extensions:$lifecycle_version"
    kapt "androidx.lifecycle:lifecycle-compiler:$lifecycle_version"

それが、実は第5回の記事の時点で、2.1.0に上げてありました。

    def lifecycle_version = "2.1.0"

何で上げちゃってたのか不明ですが(汗)、恐らく、AndroidStudioがご丁寧に「新しいバージョンがあるから上げたら?」としつこく勧めてくるので、うっかりalt + Enter(Mac版のショートカット)"Change to XXXX"をしちゃったのでしょう(汗)
このように、基本的にはmaven等で新しいバージョンが公開されると、わざわざAndroidStudioが拾ってきて教えてくれるので、マイナーバージョンアップ程度であれば一度上げてみて、ビルド&テスト、動作確認して問題なければそのままGO、で良いと思います。

で、この記事を執筆中の間に、2.2.0がstableになったようで、また「最新バージョンがあるよ」とAndroidStudioが教えてくれます。

が!!

test系のバージョンがまだ上がってないようで、以下のようなバージョン番号の定義を参照する書き方をしていると、test系ので「バージョンが見つからない」とGradle Syncエラーになってしまいます。

app/build.gradle
    def lifecycle_version = "2.1.0"
    implementation "androidx.lifecycle:lifecycle-extensions:$lifecycle_version"
    implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version"
    kapt "androidx.lifecycle:lifecycle-compiler:$lifecycle_version"
    androidTestImplementation "androidx.arch.core:core-testing:$lifecycle_version"
    testImplementation "androidx.arch.core:core-testing:$lifecycle_version"

ということで、まだ2.2.0には上げないでおきます。

また、2.2.0ではライブラリの整理が行われたようで、以下はdeprecated(非推奨)となりました。

  • lifecycle-extensions

ライブラリの指定方法が変わるようなので、2.2.0まで上げる際には注意が必要そうですね。
リリースノートを見ていると、他にもいくつかdeprecatedになったものがあるようです。

さて、ライフサイクルの2.1.0バージョン、実はリリースノートにこんな記述があります。

拡張プロパティ ViewModel.viewModelScope を追加することで、ViewModels にコルーチンのサポートを追加しました。

今まで、コルーチンを使うViewModelクラスには、以下のようにして自前で色々と定義してきていました。

MainViewModel.kt
    // coroutine用
    private var parentJob = Job()

    private val coroutineContext: CoroutineContext
        get() = parentJob + Dispatchers.Main

    private val scope = CoroutineScope(coroutineContext)

    override fun onCleared() {
        super.onCleared()
        parentJob.cancel()
    }

これを、いろんなViewModelクラスで同じようなことを書かねばならず、正直言って面倒でした。自前でベースクラスを作ってしまっていたりした人も多かったのでは無いでしょうか。
それが、ViewModelクラスがデフォルトでviewModelScopeプロパティを持つことになったので、この定義を一切しなくて良くなったのです!
早速書き変えてみましょう。

ただし、ライブラリの追加が必要となります。

app/build.gradle
    implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version"

別のライブラリが必要なことが分かるとおり、viewModelScopeプロパティは、Kotlinの拡張関数の機能を利用しているようですね。

さて、これを受けて、MainViewModelはこうなります。

MainViewModel.kt
class MainViewModel(app: Application) : AndroidViewModel(app) {

    // データ操作用のリポジトリクラス
    val repository: LogRepository
    // 全データリスト
    val stepCountList: LiveData<List<StepCountLog>>

  init {
        val logDao = LogRoomDatabase.getDatabase(app).logDao()
        repository = LogRepository(logDao)
        stepCountList = repository.allLogs
    }

    fun addStepCount(stepLog: StepCountLog) = viewModelScope.launch(Dispatchers.IO) {
        repository.insert(stepLog)
    }

    fun deleteStepCount(stepLog: StepCountLog) = viewModelScope.launch(Dispatchers.IO) {
        repository.delete(stepLog)
    }
}

viewModelScopeは、import androidx.lifecycle.viewModelScopeすることで使えるようになります。
スッキリしましたね。

(2) その他のライブラリのバージョンアップ

前回までの記事を書いた後に残っているプロジェクトだと、以下のライブラリで最新バージョンがあることが分かります。

  • RecyclerView
  • Croutines
  • core-ktx
  • material

それぞれ、alt + Enter(Macの場合のショートカット)や、電球アイコンからChange to XXXXを選んで最新バージョンにして、ビルドして、テストやアプリの動作確認をしてみましょう。

開発環境の最新版対応

ライブラリと順番が逆じゃ無いかという気もしないでもないですが、これを機会に開発環境も最新版にしようと思います。

開発環境と言うときは、以下を指す・・・と思っています。

  • AndroidStudioのバージョンアップ
  • GradlePluginのバージョンアップ
  • Kotlinのバージョンアップ

(1) Android Stuidoのバージョンアップ

AndroidStudioも、stable版が出たからといって直ぐにはバージョンアップすることはお勧めしないです。
特に業務で使っている場合には、メンバーで一斉に変えてしまうのでは無く、誰かが人柱となって使ってみる、ネット上の不具合情報が出そろうまで待つ、などした方がよいでしょうね。
個人でも、入れてみる場合でも、AndroidStudioは一応別バージョンを同時にインストールできるので(Macでは出来ます。Windowsのやり方は知りませんが・・・)、一応一つ前くらいは残しておくのが無難でしょうね。

さて、2020/02/01現在の最新版は、3.5.3です。
すでにインストールしているAndroidStudioからアップデートするときは、AndroidStudioのメニューの[Check for Updates]から出来ます。
設定などを過去のバージョンのものを使うかどうかなどを聞かれるくらいで、特に難しいところは無いかと思います。

(2) Gradle関連のバージョンアップ

Gradle Pluginのバージョンも上がっているので指示に従ってあげましょう。こちらも最新版は3.5.3のようです。

プロジェクトルートのbuild.gradleを修正します。

root/build.gradle
buildscript {
   ...
    dependencies {
        classpath 'com.android.tools.build:gradle:3.5.3'
        classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
        // NOTE: Do not place your application dependencies here; they belong
        // in the individual module build.gradle files
    }
}

そこでGradleSyncを行うと、エラーになります。

qiita07_20.png

Gradleのバージョンに齟齬があると怒られていますね。
こちらも上げましょう。

ここでは、修正サジェスチョンの2つ目のリンクFix Gradle wrapper and re-import projectをクリックしてみましょう。
gradle-warpper.propertiesというファイルが開きます。

gradle-wrapper.properties
distributionUrl=https\://services.gradle.org/distributions/gradle-4.10.1-all.zip

上記のようになっている場合、distributionUrl4.10.1という箇所を、提示されている5.4.1に書き変えます。

gradle-wrapper.properties
distributionUrl=https\://services.gradle.org/distributions/gradle-5.4.1-all.zip

これでGradle Syncが通るはずです。

(3) Kotlinのバージョンアップ

プロジェクトを開いたときに、Kotlinをバージョンアップするように出てウザいので、これも上げてみることにします。

qiira07_05.png

1. Pluginのバージョンアップ

まず、Installをクリックします。
ダウンロードとインストールが終わったら、一度AndroidStudioを再起動した方が良さそうです。

2. build.gradleの修正

次に、プロジェクトルートにあるbuild.gradleのバージョンも書き変える必要があります。

root/build.gradle
buildscript {
    ext.kotlin_version = '1.3.61'

3. リビルド

clean & rebuildしましょう。
Kotlinのバージョンを上げると、今まで何とも言われなかったコードで警告が出ることがあります。
基本的には潰しておきましょう。放置はバグの元になります。
ただ、Kotlinの警告は非常に気付きにくいので、ビルドログをじっくり見る必要があります。

この時点で、手元のプロジェクトでは、以下のような警告が出ていました。
(多分バージョンを上げる前から出ていたとは思いますが)

qiita07_06.png

使ってないパラメータを_に置き換えられるよという親切な警告ですね。

LogEditFragment.kt
        builder.setItems(arrayOf("Twitter", "Instagram")) { dialog, which ->

私はパラメータの型が推測できるように残しておきたい派なのですが(静的型言語を長くやってきたせいですかね)、気になる方は警告に従って変えておきましょう。

LogEditFragment.kt
        builder.setItems(arrayOf("Twitter", "Instagram")) { _, which ->

ライブラリ側の設計の修正で、nullable、non-nullなどが変わったりするとかなり大量に修正が必要になったりすることもあるので、バージョンアップには注意が必要です。
実際のプロジェクトでは、ブランチを切ってからバージョンを上げて、ビルドログを見てみてから適用するかは判断することになるでしょう。
ただ、ずっとバージョンを上げないでいることも出来ないので、その他のライブラリと同様、タイミングを見てreleaseNoteの内容を把握しつつ適宜上げていくことが必要になってきます。

テスト

(1) テスト通過確認

ここまでで、テストを実行しておきましょう。
通る・・・はずです・・・

テストが通ったら、コミット。
これを習慣づけておくとよいでしょうね。

ところで、テストをまとめて流していると、時々失敗するテストがいくつかありませんか?
私の手元では、MainViewModelTestIの1つか2つのテストが、時々失敗していました。
個別に実行すると通るのだけど、なぜかまとめて流すと時々失敗したり、通ったりする。
こういうのはだいたいアプリ内の情報(preferenceやdatabase等)の前提条件が変わってしまっているために起こるのですが、どうにも追い切れない・・・
(これで2週間費やしました)

どうしたものか?と悩み、ググっているうちに、Android Test Orchestratorというのに行き着きました。

テスト後に、デバイスの CPU およびメモリで共有された状態をすべて削除するには、clearPackageData フラグを使用します

お、良さそう!
と早速飛びついてみるのは、業務で作っているアプリでは少々怖かったりしますが、個人開発のものなので新しいものにはドンドン挑戦していきましょう。
(というかAndroid Test Orchestrator自体は、2018年頃には出ていたらしく、大して新しくもないです汗)

(2) Android Test Orchestrator

ということで、早速入れていきます。
基本的には先ほどのページにある通りです。
せっかくなので忘れていたテストアプリケーションIDの設定なんかもしました。

app/build.gradle
    defaultConfig {
        ...
        testInstrumentationRunnerArguments clearPackageData: 'true'
        testApplicationId "jp.les.kasa.sample.mykotlinapp.test"
    }

    testOptions {
        unitTests {
            includeAndroidResources = true
        }
        execution 'ANDROIDX_TEST_ORCHESTRATOR'
    }

    dependencied{
        ...

        androidTestUtil 'androidx.test:orchestrator:1.2.0'
    }

これでテストをクラス単位やパッケージ単位で流してみると・・・
複数回実行しても、失敗しなくなりました!
やったー:smile:

ただ、ちょっとテストの実行が重くなったような気がします。まあ、「テストごとにclearパッケージする」んだから、やむを得ないかな。

(3) テストコード整理

さて、テストごとにデータは削除されることが保証できるようになりました。
なので、各テストの開始時や終了時に、preferenceやdatabaseを削除するコードを入れていたのは不要になります。
ということで、@Before@Afterに指定したメソッドの中で、それらの処理をしているコードをガンガン削除してOKです。

ただし!
このAndroid Test Orchestrator、Robolectricのテストでは使われないようです。TestRunnerが
Robolectric向けテスト(testフォルダ下に置いてあるテスト)では、残しておく必要がありますね。

Koin

KoinとはKotlin向けの「DI = 依存性注入」ライブラリです。
DI, 依存性注入ってなんぞや?
という方は、こちらなどで先に勉強しておくと良いかと思います。

猿でも分かる! Dependency Injection: 依存性の注入

ざっくり言えば、クラスAの中でクラスBのインスタンスをnewして初期化するのでは無く、外から渡せるようにする、というのが依存性(オブジェクト)の注入(外部から渡す)ということになります。

class A {
     val bVal = B()    
}

こうじゃなくて、

class A(val bVal : B){
}

こうするってことですね。

勿論、これではほとんど意味が無くて、実際には、別のクラスを渡したいときに不十分なので、こういう場合は、インターフェースを定義して、とやるのが普通です。

interface I

class B : I {
}

class C : I {
}

class A(val b: I) {
}

/// bにはBでもCでも渡せる

val ab = A(B())
val ac = A(C())

こうなっていれば、変数bには、インターフェースIを継承したクラスなら何でも渡せることになります。

でも、見てお分かりの通り、これを色んなところで律儀にやっていこうとすると、インターフェースの定義だらけになります。
結構煩雑なプロジェクトになっていきます。
そこで登場するのが、DIライブラリです。
DIライブラリは、このインターフェース生成やらを裏で秘かによしなにやってくれる、というふうに考えれば良いと思います。
実際には、その「秘かな処理」をコンパイル時にやるか実行時にリフレクションでやるかといった違いがライブラリによってあります。

今回DIを使ってみようと思うのは、主にDatabseが絡む周りです。テスト中はメモリ上に作成したDatabaseを使いたいのですが、LogRepositoryTestのテスト以外は実データベースを作っては消し、とやっているのを解消できたらいいなーという感じです。

今それが出来ないのは、リポジトリクラスを使うMainViewModelの初期化がこうなっているからですね。

MainViewModel.kt
class MainViewModel(app: Application) : AndroidViewModel(app) {

    // データ操作用のリポジトリクラス
    val repository: LogRepository
    ...

はい、見事に依存性が注入できない仕様になっております(汗)
これを外部から注入できるようにするのに合わせて、DIのライブラリも使って行こう、という目論見です。
というか、ライブラリを使って強制的にDI出来るようにコードをリファクタリングしていきたい、という方が本音に近いのですが。

ただ、Koinは遅いという実験結果もあるようです。

Androidで有名なDIのフレームワークと言えばDagger2が有名ですが、なぜ早いんでしょうか?ビルドは凄く遅くなる実感がありますが・・・
ということで調べたら、Dagger2は、「コンパイル時生成」ですね。そりゃあ、ビルドは重くなるよね。
リフレクション使うライブラリに比べたら、実行速度は圧倒的に速くなるよね。
私の感覚は間違ってなかったわけですな(笑)

あ、でも、Koinも、公式ページによれば、

Written in pure Kotlin using functional resolution only: no proxy, no code generation, no reflection!

とのことなので、コード生成も無いしリフレクションも使ってないはずなんだけどな・・・

(1) Koinの導入

公式ページを参考に、ライブラリの設定を行います。
いつものようにapp/build.gradleのdependenciesに追加していきます。

app/build.gradle
dependencies {
   ....
    // Koin for Kotlin apps
    def koin_version= "2.0.1"
    implementation "org.koin:koin-core:$koin_version"
    // Testing
    testImplementation "org.koin:koin-test:$koin_version"
    androidTestImplementation "org.koin:koin-test:$koin_version"
    // Koin Extended & experimental features
    implementation "org.koin:koin-core-ext:$koin_version"
    // Koin for Android
    implementation "org.koin:koin-android:$koin_version"
    // Koin AndroidX Scope feature
    implementation "org.koin:koin-androidx-scope:$koin_version"
    // Koin AndroidX ViewModel feature
    implementation "org.koin:koin-androidx-viewmodel:$koin_version"

koin-androidx-fragmentというのもあるのですが、Unstableバージョンしか無いようなので、今回は使わずに行こうと思います。
koin-core-extもexperimentalなので使わないかも知れませんが、使うかも知れないので入れてあります。
ちょっと依存に書かなきゃいけないのが多い印象ですね。
純粋なKotlin用と、Android向けがあるせいでしょうか。

(2) ViewModelのインジェクション

ViewModelのインジェクションが簡単そうなのでまずはやってみましょう。

1. moduleの用意

まずはmoduleというのを用意する必要があるようです。
多くのサンプルはApplicationクラス内に直接書いてますが、今後のことも考えて、別ファイルに出してあります。
パッケージはdiというのを作ってその下に置くことにします。

di/module.kt
// ViewModel
val viewModelModule = module {
    viewModel { MainViewModel(androidApplication()) }
    viewModel { LogItemViewModel(androidApplication()) }
    viewModel { InstagramShareViewModel() }
}


// モジュール群
val appModules = listOf(viewModelModule)

いろいろな種類のモジュールを分けて書いていけるように、ちょっと分割してあります。
例えば、今後リポジトリクラスも入れていく予定で、その場合、次のようになっていくはずです。

modules.kt
// Repository
val repositoryModule = module {
    single { LogRepository(...) }
}

// モジュール群
val appModules = listOf(viewModelModule, repositoryModule)

LogRepositoryの初期化方法がこの時点ではまだ分からないので濁してあります。当然ながらこのままではコンパイルが通りません。

2. Koinを初期化

アプリケーションクラスで、Koin自体を初期化する必要があります。

まずはアプリケーションクラスを作りましょう。

MyApp.kt
class MyApp : Application() {

}

このクラスを作っただけではダメです。マニフェストファイルで、このクラスを使うように指定しなければなりません。
<application>タグに、android:name属性を追加します。この時、""(ダブルクォーテーション)の中で"."を打つと、以下の画像のようにアプリケーションクラスの候補が表示されるので、そこから選べば簡単。便利ですね。

qiita07_03.png

こうなります。

AndroidManifest.xml
   <application
            android:name=".MyApp"
            android:allowBackup="true"
            android:icon="@mipmap/ic_launcher"
            android:label="@string/app_name"
            android:roundIcon="@mipmap/ic_launcher_round"
            android:supportsRtl="true"
            android:theme="@style/AppTheme">
....

次に、Koinの初期化コードをonCreate内に書きます。

MyApp.kt
class MyApp : Application() {
    override fun onCreate() {
        super.onCreate()

        startKoin {
            if (BuildConfig.DEBUG) androidLogger(Level.DEBUG) else EmptyLogger()
            androidContext(this@MyApp)
            modules(appModules)
        }
    }
}

Debugビルド以外ではログを吐かないようにしてみました。
後は公式サイト通りです。

3. ActivityのViewModelをInject

早速インジェクトするコードです。
まずはMainActivityクラスから見ていきましょう。
MainViewModelのインスタンス宣言を次のように書き変えます。

MainActivity.kt
    val viewModel by viewModel<MainViewModel>()

次に、そのインスタンスを取得していたコードを削除します!

MainActivity.kt

    override fun onCreate(savedInstanceState: Bundle?) {
        ...
//        viewModel = ViewModelProviders.of(this).get(MainViewModel::class.java) // この行を削除またはコメントアウト

これだけ!
このViewModelProviders.of(のコードはViewModelクラス名が違うだけのコードが各ActivityやFragmentで繰り返し出てきて、はっきり言って毎回書くの面倒だなと思ってませんでしたか?
なんとそれをKoinによるDI(依存性注入)のお陰で書かなくて済むようになったのです!

注入している箇所は、まさしく先ほど書き変えたインスタンス宣言の部分のここです。

    by viewModel<MainViewModel>()

変数宣言の箇所で注入されるということは、コンストラクタで注入が行われるということになり、 lateinit varで宣言しておきながら実は初期化されていないルートが存在するかも知れない、なんてことを心配する必要もなくなります。

2020/02/12追記
コンストラクタで注入は行われません。by viewModel()は、lazy property declaration=遅延初期化 なので、by lazy {getViewModel()}と同義で、つまり「最初にプロパティにアクセスがあったときに注入される」が正しいです。

実行してみましょう。
意図通りに動いていますか?

同様に、他のクラスも書き変えればこのステップは完了です。
尚、対象の全クラスは次の通り。

  • MainActivity.kt
  • InstagramShareActivity.kt
  • LogItemActivity.kt

テストも通るでしょうか?
再び全テストを流してみましょう。

androidTestの方は問題ないと思います。
testの方が問題だらけかと思います。
こんなエラーだらけのはず・・・

org.koin.core.error.KoinAppAlreadyStartedException: A Koin Application has already been started

どうやら、Koinの何かをテストごとに止めてやらなければならないようだと予測できます。
上のエラーメッセージでググると、どうやら、stopKoin()をテスト終了ごとに呼ぶ必要があるようなことが分かりました。
androidTestの方は問題ないのは、アプリの実際のプロセスが毎回killされているからなんでしょうか?
ちょっと謎ですが、仕方ないので、テストクラスの@AfterメソッドにstopKoin()を入れてやるか、テストクラスをAutoCloseKoinTestの派生としてやれば良さそうです。
全部のテストクラスの@AfterメソッドにstopKoin()を入れていくのは大変なので、ここはテストクラスを全部AutoCloseKoinTestの派生にしましょう。

class AaaTest : AutoCloseKoinTest(){
}

なお、LogEditFragmentTestLogInputFragmentTest等の@RunWith(AndroidJUnit4::class)を指定していないテストは、単純なJunitテストなので、アプリケーションプロセスは起動されないため、この作業は不要です。

これで、全部のテストが通過するはずです。

4. FragmentのViewModelをInject

Fragmentはどうしましょう。
FragmentでViewModelのインスタンスを得る場合、Fragmentのライフサイクルに合わせるのか、Activityのライフサイクルに合わせるのかで初期化方法が違うのを覚えていますか?

XXFragment.kt

// FragmentのライフサイクルでViewModelのインスタンスを得る。
// すなわち、Activity-Fragment間や、複数のFragment間でViewModelを共有できない
viewModel = ViewModelProviders.of(this).get(XXViewModel::class.java)

// ActivityのライフサイクルでViewModelのインスタンスを得る。
viewModel = ViewModelProviders.of(activity!!).get(XXViewModel::class.java)

なので、Fragmentクラスで、

val viewModel by viewModel<XXViewModel>()

と書いたとき、どのライフサイクルに紐付いたViewModelが作られるのかが気になります。
取り敢えずこれで動かしてみると・・・

ViewModelのインスタンスが、ActivityとFragmentで共有されていません。

ググってみます。キーワードはkoin viewmodel shareあたりだったかな。。。
で、見つけたのが、こちらのページ

by sharedViewModelを使えば良いようですね!

例えばLogInputFragmentLogEditFragmentは、ActivityとViewModelを共有する実装です。
こうすれば良さそうですね。

    val viewModel  by sharedViewModel<LogItemViewModel> ()

あとはDateSelectDialogFragmentでも共有していますので、こちらも書き変えます。

DateSelectDialogFragment.kt
   override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
        val viewModel by sharedViewModel<LogItemViewModel>()
        ...
    }

動作は、OK。
テストを流す前に、テストクラスの中でViewModelを生成しているところもKoinでインジェクションするように書き変えます。

   val viewModel: XxxxViewModel by inject()

それぞれのViewModelクラスでやっておきましょう。

これで、テストを通しておきます。

(3) リポジトリクラスのDI

さて、先ほど例に出したように、がっつりLogRepositoryクラスに依存してしまっているMainViewModelの初期化に代表される「DI出来てない」コードを直していきます。

リポジトリクラスをKoinでインジェクション出来るように書き変えていきましょう。

1. SettingsRepositoryクラスをDI

LogRepositoryクラスはちょっと難易度が高そうなので、先にSettingRepositoryクラスをDIにしていきます。

まずはmoduleにリポジトリ群を追加します。

modules.kt
...

// Repository
val repositoryModule = module {
    single { SettingRepository(androidApplication()) }
}

// モジュール群
val appModules = listOf(viewModelModule, repositoryModule)

singleはシングルトンになります。リポジトリクラスはシングルトンにしたいので、singleを使っています。

LogItemViewModelはこのようになります。

LogItemViewModel.kt
class LogItemViewModel(
    application: Application,
    private val settingRepository: SettingRepository
) : AndroidViewModel(application) {
    ...

コンストラクタでSettingRepositoryを受け取るようにします。
当然、メンバー変数で初期化していた行は削除します。

LogItemViewModelの初期化パラメータが変わったので、viewModelModuleも変更します。

modules.kt
    viewModel { LogItemViewModel(androidApplication(), get()) }

Koinにインジェクトして欲しい引数は、get()と書きます。
Koinは、モジュール群の中から、型が一致するものを探して、それを入れ込んでくれます。

SettingRepositoryはこうなります。

SettingRepository.kt
class SettingRepository constructor(private val applicationContext: Context) {
    companion object {
        const val PREF_FILE_NAME = "settings"
    }

Koinによってシングルトンになることが保証されるようになるので、シングルトン処理を自前で書いている部分は消しています。
また、シングルトンにするためコンストラクタをprivateにしてましたが、Koin側で不都合があるのでpubicに変更しています。

これでビルドが通るはずです。
動かしてみましょう。
新規登録画面で、シェアメニューを変更して登録するとその選択状態が保存され、次に新規登録画面を開いたときに設定が復元されるのでしたね。
動きが変わっていなければOK。

テストも通しておきたいのですが、そのままではビルドが通りません。SettingRepositoryのインスタンス化メソッドを削除したので当然ですね。
ここは全部by inject()に直していきます。

    private val settingRepository: SettingRepository by inject()

ここでまたテストを流します。
androidTesttestも通るはずです。

2. LogRepositoryクラスをDI

MainViewModelクラスのコンストラクタで依存オブジェクト(=LogRepositoryのインスタンス)を受け取るようにします。

MainViewModel.kt
class MainViewModel(
    app: Application,
    val repository: LogRepository
) : AndroidViewModel(app) {

    // 全データリスト
    val stepCountList: LiveData<List<StepCountLog>> = repository.allLogs

    fun addStepCount(stepLog: StepCountLog) = viewModelScope.launch(Dispatchers.IO) {
        repository.insert(stepLog)
    }

    fun deleteStepCount(stepLog: StepCountLog) = viewModelScope.launch(Dispatchers.IO) {
        repository.delete(stepLog)
    }
}

init{}が不要になりましたね。随分スッキリしました。

MainViewModelのコンストラクタに引数が増えたので、Koinのmoduleの定義も変えてやる必要があります。

modules.kt
    viewModel { MainViewModel(androidApplication(), get()) }

LogItemViewModelのときと同じく、get()でインジェクトされるようにします。

LogRepositoryrepositoryModuleに追加します。

modules.kt
// Repository
val repositoryModule = module {
    single { SettingRepository(androidApplication()) }
    single { LogRepository(get()) }
}

同じく引数のLogDaoget()でインジェクトされるようにします。

さて、そうなると、LogDaoもモジュールに追加してやらねばなりませんね。
このようにしてみました。

modules.kt
// database,dao
val daoModule = module {
    single { Room.databaseBuilder(androidApplication(), LogRoomDatabase::class.java, DATABASE_NAME).build() }
    factory { get<LogRoomDatabase>().logDao()  }
}

// モジュール群
val appModules = listOf(viewModelModule, daoModule, repositoryModule)

LogRoomDatabaseのインスタンス作成も、シングルトンにしていました。遠い昔ですが、覚えてますか?(笑)
なので、Koinでsingleを使い、シングルトン関連コードは削除します。
モジュール群を分けたのは何となくですが、後でテストするときに、daoModuleを差し替えられると良いのかな?とぼんやり考えてこうしました。

ここまででビルド、実行できるはずです。
動作確認OK?

3. LogRoomDatabaseの絡むテストの修正

さて、ここからが一番やりたかった内容になります。
テスト中は、メモリ上に作成したデータベースを使いたいのです。
LogRepositoryTestはそうなっていますが、他のテストはそうなっていません。
これがずっと気になっていました。

ということで、早速DIの醍醐味、モッククラスとの差替えに入ります。

a. Robolectricテストの修正

まず、LogRepositoryTestをKoinを使った書き方に変えてみましょう。

最初に、差し替え用のdatabaseモジュールを定義します。
testパッケージにdiパッケージを作って、その下にMockModules.ktというのを作成しました。

MockModules.kt
// テスト用にモックするモジュール
val mockModule = module {
    single(override = true) {
        Room.inMemoryDatabaseBuilder(
            androidApplication(),
            LogRoomDatabase::class.java
        ).allowMainThreadQueries().build()
    }
}

(override=true)というのが胆です。
Koinは、あとからモジュールを上書きすることが出来て、この指定がそれに該当します。

LogRepositoryTestはこうなります。

LogRepositoryTest.kt

@RunWith(AndroidJUnit4::class)
class LogRepositoryTest : AutoCloseKoinTest() {

    @get:Rule
    val rule: TestRule = InstantTaskExecutorRule()

    private val database: LogRoomDatabase by lazy { get<LogRoomDatabase>() }
    private val logDao: LogDao by lazy { get<LogDao>() }
    private val repository: LogRepository by lazy { get<LogRepository>() }

    @Before
    fun setUp() {
        loadKoinModules(mockModule)
    }

    @After
    fun tearDown() {
        database.close()
    }

    ....

lazyはKotlinの遅延初期化といわれる機能です。
最初にそのプロパティにアクセスがあったときに初めて代入されます。
このクラスのテストの場合、まずテストプロセスが立ち上がったときに、startKoinされてしまいます。
そのため、setUpメソッドのloadKoinModulesでモジュールを読み込んでいます。
ここで読み込んでいるモジュールには、(override=true)を指定してあるので、結果的にデータベースはメモリ上に作成するものが使われることになります。

そして、databaselogDaorepositoryといったメンバー変数に最初にアクセスされたとき、Koinのgetメソッドによってインジェクトされて変数が初期化される、という流れになります。

ちなみに、なんでby lazyにしたかというと、メンバー変数の初期化は、@Beforeメソッドより前に行われると思ったので、下記だと、overrideされる前のdatabaseモジュールが読み込まれてしまうかと思ったのです。

    private val database: LogRoomDatabase by inject()
    private val logDao: LogDao by inject()
    private val repository: LogRepository by inject()

が、実行してみたところ、by inject()を使っても大丈夫でした。(MockModuleと通常のModuleの方にブレークポイントを貼って確認しました。)
startKoin - @Beforeメソッド - メンバー変数初期化という処理の流れなんですね。

まあ、lazy(遅延初期化)の説明が出来たと言うことで(汗)

※その後よくよく調べていくと、by inject()の中身は、結局by lazy使っているようでした。

お次は、LogRepositoryのインスタンスを所有するクラスのテストです。
MainViewModelMainActivityのテストですね。

これも同じように@Beforeメソッドで上書きするようにしてみます。

MainViewModelのインスタンス初期化部分は、by inject()に変更します。

MainViewModelTest.kt
    val viewModel: MainViewModel by inject()

    @Before
    fun setUp() {
        loadKoinModules(mockModule)
    }

ところで、MainViewModelTestaddStepCountメソッドを試しに復活させてみたら(Robolefcricだとスレッドの問題で動かせなかった)、やはりdatabaseがメインスレッドアクセス可になったお陰で、実行できるようになっていました。
ただ、リストの順番は降順になっているのでそこだけ直せば、パスするようになります。

ということで、Robolectricはスレッド問題でテストを書くのを断念していたいくつかのテストが、復活できることになります。
リポジトリにアップしてありますので、参考にしてみて下さい。

MainActivityTestもほぼ同じで、これまでandroidTestの方にしか書けてなかったテストも移植してこられます。ですが、以下の点に注意です。

  • ActivityTestRuleでの記述でActivity起動が自動起動(launchActivityパラメータ=true)の場合、@BeforeメソッドよりActivity起動の方が先になるため、loadKoinModulesが間に合いません。以下のようにActivityTestRuleを変更して、起動を遅らせる変更が必要になります。
MainActivityTest.kt
      @get:Rule
      val activityRule = ActivityTestRule(MainActivity::class.java, false, false)

      @Before
      fun setUp(){
          loadKoinModules()
          activityRule.launchActivity(null)
        }
  • Activity同士の遷移のテストは複雑になるため、工夫が必要です。特に、起動したかどうかと言うより、起動するIntentが正しくセットされたか、それを正しく処理したか、という観点の、より「単体テスト」に近い視点が必要になります。そのため、Espresso-Intentsモジュールを使うようになりますので、app/build.gradleに以下の依存の追加が必要です。
app/build.gralde
    testImplementation 'androidx.test.espresso:espresso-intents:3.2.0'

こちらも、リポジトリにアップしてありますので、興味あれば覗いてやって下さい。

(一度は諦めかけていたRobolectricでのUIテストも、また可能性が出てきました。)

b. Espressoテストの修正

Robolectricで全部テストできそうな雰囲気になってきましたが、Espresso版も作ってきたのでせっかくなので対応しておきます。
やることは同じで、モック用のテストモジュールをdiパッケージに用意して、LogRepositoryを使うクラスのテストの@Beforeメソッドで上書きしてやるだけです。

androidTest/.../di/MockModules.kt
// テスト用にモックするモジュール
val testMockModule = module {
    single(override = true) {
        Room.inMemoryDatabaseBuilder(
            androidApplication(),
            LogRoomDatabase::class.java
        ).build()
    }
}

モジュール変数名をtestMockModuleにしています。mockModuleだと、testフォルダ下にあるのと名前が競合するとしてエラーになってしまうからです。
このモジュールは、UIが実際に動くテストで使われるものなので、メインスレッドアクセスの許可は無くしてあります。
まああっても害はないとは思いますが・・・

次に、MainViewModelTestIMainActivityTestI@BeforeメソッドでloadKoinModules(testMockModule)してやります。
ここはRobolectricのテストと同じです。他のテストはLogRepositoryを参照してないので今のところ必要はありません。

もう一点、Robolectricのところでも書きましたが、 ActivityTestRuleでActivity起動を自動起動(launchActivityパラメータ=true)にしている場合、@BeforeメソッドよりActivity起動の方が先になるため、loadKoinModulesでの上書きが間に合いません。以下のようにActivityTestRuleを変更して、起動を遅らせる変更が必要になります。

MainActivityTestI.kt
      @get:Rule
      val activityRule = ActivityTestRule(MainActivity::class.java, false, false)

loadKoinModulesの後にActivityを起動するコードも追加してやります。

MainActivityTestI.kt
    @Before
    fun setUp(){
      loadKoinModules()
      activityRule.launchActivity(null)
    }

こうして、

  • 本番アプリはデータベースをストレージに作成し、メインスレッドアクセス不許可
  • テスト時は、データベースをメインメモリに作成し、Robolectric版ではメインスレッドからのアクセスも許可

ということが出来るようになりました!

今後APIを作ってモック返答させるとか、そんなことも見えてきます。
まあ、通信のモックはMockServerという便利なのがあるので、そっち使った方が良いんですけどね。
でも開発の初期で、サーバー側のモック応答すらなかなか出来上がらないのに、モックアプリの提出は要求されている、なんてときには、モック用moduleを定義しておいて、サーバー側が出来たらモジュールを切り替える、というようにするという方法を採ることが出来ます。

このアプリでAPI使うことは恐らくないので(データをアップロード的なものは、Firestoreを考えています)、紹介する機会は無さそうですが、興味ある人はググってみて下さい。

ちなみに、MockServerRetrofit2OkHttpと組み合わせたサンプルばかりゴロゴロしてますが、ベタなHttpUrlConnectionとかしか使って無くても、使えますよ。
もし機会があればサンプル載せようかな。

最後に、すべてのテストを通して、通過すれば、完了です。

まとめ

ライブラリ、開発環境のバージョンアップをして、新しい機能やビルドの警告に対応しました。
なるべく定期的に、これらの作業は行っていった方が良いでしょう。
(ただし大規模な開発の場合、リリース直前のバージョンアップなど、メンバーとの意思共有が必要になります)

ライフサイクルのライブラリの新機能により、拡張プロパティ ViewModel.viewModelScopeを使えるようになり、それに合わせたリファクタリングを行いました。

Koinを使い、DIなコードにリファクタリングしました。
また、テスト用モジュールを用意し、テスト時にはデータベースをメモリ上に作成する、メインスレッドからのアクセスを許可するなど、本番アプリとは異なる条件にする方法を学びました。

ここまでの状態のプロジェクトをGithubにpushしてあります。

予告

カレンダー表示に手を付けるか、グラフ描画にするか・・・
時間があまりない・・・
グラフ描画はテストしづらそうだなあ・・・(むしろテストしようがないから書かない、と逃げられるかなあ笑)
Androidアプリでは非常によく使う、ViewPagerの勉強にもなるし、やっぱりカレンダー表示かなあ・・・
でもこっちはUIのテストが激変するからやりたくない

ということで、まだ迷っています。

いっそFlutterに浮気しちゃうかも(笑)
ここまでのと同じアプリを、Flutterで作る講座とか、需要あるかな??

参考ページなど

6
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
5