すっかり遅くなってしましました(汗)
テストにドはまりしたり、Flutterに浮気したりしてしたわけではありませんよ?(笑)
ということで、前回の続きです。
今回の目標
- ライブラリの最新版対応
- Koinを使ってDI(依存性注入)を行う
アプリの機能としては進歩しませんが、ちょっとだけ「リファクタリング」ぽいことをやります。
メインはDIの方ですが、ちょうどよいので使っているライブラリのバージョンも最新にしてみようと思います。
ライブラリの最新版対応
アプリの作成中には、1ヶ月ほどで完成できるようなアプリならともかく、ある程度の期間掛けて開発する場合には、その途中でライブラリや開発ツールのバージョンがいつの間にかドンドン上がってしまい、リリースする頃にはいつの間にか「まだそのバージョン使ってるの?」なんてことも。
単にバージョンの数字を上げてやるだけで良い場合は特に問題ないのですが、微妙にinterfaceが変わったり、使っていたAPIが非推奨になったりなどがあると、コーディングレベルでも対応が必要になってきます。酷い場合には全く互換性のないコードになってしまい、ビルドが全く通らない、なんてこともあります。
ということで、私の場合は、定期的に使っているライブラリの最新版に上げてビルドと動作確認をする、ということを心がけています。
ただ、リリース間近にやってしまうとかえってコードを汚くしたり改変箇所が多すぎて期限に間に合わなくなりそうになったり等あり得るので、そういう場合は、Release Noteを見て、入れて大丈夫かどうか判断してからやります。
ということで、各種ライブラリのバージョンをチェックしてみましょう。
(1) ライフサイクル関係
最初にライフサイクルを導入したときの、第2回の記事の時点では、ライフサイクル関係のライブラリが以下のバージョンになっているかと思います。
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エラーになってしまいます。
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クラスには、以下のようにして自前で色々と定義してきていました。
// 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
プロパティを持つことになったので、この定義を一切しなくて良くなったのです!
早速書き変えてみましょう。
ただし、ライブラリの追加が必要となります。
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycle_version"
別のライブラリが必要なことが分かるとおり、viewModelScope
プロパティは、Kotlinの拡張関数の機能を利用しているようですね。
さて、これを受けて、MainViewModel
はこうなります。
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
を修正します。
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を行うと、エラーになります。
Gradleのバージョンに齟齬があると怒られていますね。
こちらも上げましょう。
ここでは、修正サジェスチョンの2つ目のリンクFix Gradle wrapper and re-import projectをクリックしてみましょう。
gradle-warpper.properties
というファイルが開きます。
distributionUrl=https\://services.gradle.org/distributions/gradle-4.10.1-all.zip
上記のようになっている場合、distributionUrl
の4.10.1という箇所を、提示されている5.4.1に書き変えます。
distributionUrl=https\://services.gradle.org/distributions/gradle-5.4.1-all.zip
これでGradle Syncが通るはずです。
(3) Kotlinのバージョンアップ
プロジェクトを開いたときに、Kotlinをバージョンアップするように出てウザいので、これも上げてみることにします。
1. Pluginのバージョンアップ
まず、Installをクリックします。
ダウンロードとインストールが終わったら、一度AndroidStudioを再起動した方が良さそうです。
2. build.gradleの修正
次に、プロジェクトルートにあるbuild.gradle
のバージョンも書き変える必要があります。
buildscript {
ext.kotlin_version = '1.3.61'
3. リビルド
clean & rebuildしましょう。
Kotlinのバージョンを上げると、今まで何とも言われなかったコードで警告が出ることがあります。
基本的には潰しておきましょう。放置はバグの元になります。
ただ、Kotlinの警告は非常に気付きにくいので、ビルドログをじっくり見る必要があります。
この時点で、手元のプロジェクトでは、以下のような警告が出ていました。
(多分バージョンを上げる前から出ていたとは思いますが)
使ってないパラメータを_
に置き換えられるよという親切な警告ですね。
builder.setItems(arrayOf("Twitter", "Instagram")) { dialog, which ->
私はパラメータの型が推測できるように残しておきたい派なのですが(静的型言語を長くやってきたせいですかね)、気になる方は警告に従って変えておきましょう。
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の設定なんかもしました。
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'
}
これでテストをクラス単位やパッケージ単位で流してみると・・・
複数回実行しても、失敗しなくなりました!
やったー
ただ、ちょっとテストの実行が重くなったような気がします。まあ、「テストごとに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
の初期化がこうなっているからですね。
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に追加していきます。
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
というのを作ってその下に置くことにします。
// ViewModel
val viewModelModule = module {
viewModel { MainViewModel(androidApplication()) }
viewModel { LogItemViewModel(androidApplication()) }
viewModel { InstagramShareViewModel() }
}
// モジュール群
val appModules = listOf(viewModelModule)
いろいろな種類のモジュールを分けて書いていけるように、ちょっと分割してあります。
例えば、今後リポジトリクラスも入れていく予定で、その場合、次のようになっていくはずです。
// Repository
val repositoryModule = module {
single { LogRepository(...) }
}
// モジュール群
val appModules = listOf(viewModelModule, repositoryModule)
※LogRepository
の初期化方法がこの時点ではまだ分からないので濁してあります。当然ながらこのままではコンパイルが通りません。
2. Koinを初期化
アプリケーションクラスで、Koin自体を初期化する必要があります。
まずはアプリケーションクラスを作りましょう。
class MyApp : Application() {
}
このクラスを作っただけではダメです。マニフェストファイルで、このクラスを使うように指定しなければなりません。
<application>
タグに、android:name
属性を追加します。この時、""(ダブルクォーテーション)
の中で"."を打つと、以下の画像のようにアプリケーションクラスの候補が表示されるので、そこから選べば簡単。便利ですね。
こうなります。
<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
内に書きます。
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
のインスタンス宣言を次のように書き変えます。
val viewModel by viewModel<MainViewModel>()
次に、そのインスタンスを取得していたコードを削除します!
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(){
}
なお、LogEditFragmentTest
とLogInputFragmentTest
等の@RunWith(AndroidJUnit4::class)
を指定していないテストは、単純なJunitテストなので、アプリケーションプロセスは起動されないため、この作業は不要です。
これで、全部のテストが通過するはずです。
4. FragmentのViewModelをInject
Fragmentはどうしましょう。
FragmentでViewModelのインスタンスを得る場合、Fragmentのライフサイクルに合わせるのか、Activityのライフサイクルに合わせるのかで初期化方法が違うのを覚えていますか?
// 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
を使えば良いようですね!
例えばLogInputFragment
とLogEditFragment
は、ActivityとViewModelを共有する実装です。
こうすれば良さそうですね。
val viewModel by sharedViewModel<LogItemViewModel> ()
あとはDateSelectDialogFragment
でも共有していますので、こちらも書き変えます。
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にリポジトリ群を追加します。
...
// Repository
val repositoryModule = module {
single { SettingRepository(androidApplication()) }
}
// モジュール群
val appModules = listOf(viewModelModule, repositoryModule)
single
はシングルトンになります。リポジトリクラスはシングルトンにしたいので、single
を使っています。
LogItemViewModel
はこのようになります。
class LogItemViewModel(
application: Application,
private val settingRepository: SettingRepository
) : AndroidViewModel(application) {
...
コンストラクタでSettingRepository
を受け取るようにします。
当然、メンバー変数で初期化していた行は削除します。
LogItemViewModel
の初期化パラメータが変わったので、viewModelModule
も変更します。
viewModel { LogItemViewModel(androidApplication(), get()) }
Koinにインジェクトして欲しい引数は、get()
と書きます。
Koinは、モジュール群の中から、型が一致するものを探して、それを入れ込んでくれます。
SettingRepository
はこうなります。
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()
ここでまたテストを流します。
androidTest
もtest
も通るはずです。
2. LogRepositoryクラスをDI
MainViewModel
クラスのコンストラクタで依存オブジェクト(=LogRepositoryのインスタンス)を受け取るようにします。
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の定義も変えてやる必要があります。
viewModel { MainViewModel(androidApplication(), get()) }
LogItemViewModel
のときと同じく、get()
でインジェクトされるようにします。
LogRepository
をrepositoryModule
に追加します。
// Repository
val repositoryModule = module {
single { SettingRepository(androidApplication()) }
single { LogRepository(get()) }
}
同じく引数のLogDao
はget()
でインジェクトされるようにします。
さて、そうなると、LogDao
もモジュールに追加してやらねばなりませんね。
このようにしてみました。
// 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
というのを作成しました。
// テスト用にモックするモジュール
val mockModule = module {
single(override = true) {
Room.inMemoryDatabaseBuilder(
androidApplication(),
LogRoomDatabase::class.java
).allowMainThreadQueries().build()
}
}
(override=true)
というのが胆です。
Koinは、あとからモジュールを上書きすることが出来て、この指定がそれに該当します。
LogRepositoryTest
はこうなります。
@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)
を指定してあるので、結果的にデータベースはメモリ上に作成するものが使われることになります。
そして、database
やlogDao
、repository
といったメンバー変数に最初にアクセスされたとき、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
のインスタンスを所有するクラスのテストです。
MainViewModel
とMainActivity
のテストですね。
これも同じように@Before
メソッドで上書きするようにしてみます。
MainViewModel
のインスタンス初期化部分は、by inject()
に変更します。
val viewModel: MainViewModel by inject()
@Before
fun setUp() {
loadKoinModules(mockModule)
}
ところで、MainViewModelTest
のaddStepCount
メソッドを試しに復活させてみたら(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.graldetestImplementation 'androidx.test.espresso:espresso-intents:3.2.0'
こちらも、リポジトリにアップしてありますので、興味あれば覗いてやって下さい。
(一度は諦めかけていたRobolectricでのUIテストも、また可能性が出てきました。)
b. Espressoテストの修正
Robolectricで全部テストできそうな雰囲気になってきましたが、Espresso版も作ってきたのでせっかくなので対応しておきます。
やることは同じで、モック用のテストモジュールをdi
パッケージに用意して、LogRepository
を使うクラスのテストの@Before
メソッドで上書きしてやるだけです。
// テスト用にモックするモジュール
val testMockModule = module {
single(override = true) {
Room.inMemoryDatabaseBuilder(
androidApplication(),
LogRoomDatabase::class.java
).build()
}
}
モジュール変数名をtestMockModule
にしています。mockModule
だと、test
フォルダ下にあるのと名前が競合するとしてエラーになってしまうからです。
このモジュールは、UIが実際に動くテストで使われるものなので、メインスレッドアクセスの許可は無くしてあります。
まああっても害はないとは思いますが・・・
次に、MainViewModelTestI
とMainActivityTestI
の@Before
メソッドでloadKoinModules(testMockModule)
してやります。
ここはRobolectricのテストと同じです。他のテストはLogRepository
を参照してないので今のところ必要はありません。
もう一点、Robolectricのところでも書きましたが、 ActivityTestRule
でActivity起動を自動起動(launchActivityパラメータ=true
)にしている場合、@Before
メソッドよりActivity起動の方が先になるため、loadKoinModules
での上書きが間に合いません。以下のようにActivityTestRule
を変更して、起動を遅らせる変更が必要になります。
@get:Rule
val activityRule = ActivityTestRule(MainActivity::class.java, false, false)
loadKoinModules
の後にActivityを起動するコードも追加してやります。
@Before
fun setUp(){
loadKoinModules()
activityRule.launchActivity(null)
}
こうして、
- 本番アプリはデータベースをストレージに作成し、メインスレッドアクセス不許可
- テスト時は、データベースをメインメモリに作成し、Robolectric版ではメインスレッドからのアクセスも許可
ということが出来るようになりました!
今後APIを作ってモック返答させるとか、そんなことも見えてきます。
まあ、通信のモックはMockServer
という便利なのがあるので、そっち使った方が良いんですけどね。
でも開発の初期で、サーバー側のモック応答すらなかなか出来上がらないのに、モックアプリの提出は要求されている、なんてときには、モック用moduleを定義しておいて、サーバー側が出来たらモジュールを切り替える、というようにするという方法を採ることが出来ます。
このアプリでAPI使うことは恐らくないので(データをアップロード的なものは、Firestoreを考えています)、紹介する機会は無さそうですが、興味ある人はググってみて下さい。
ちなみに、MockServer
はRetrofit2
やOkHttp
と組み合わせたサンプルばかりゴロゴロしてますが、ベタなHttpUrlConnection
とかしか使って無くても、使えますよ。
もし機会があればサンプル載せようかな。
最後に、すべてのテストを通して、通過すれば、完了です。
まとめ
ライブラリ、開発環境のバージョンアップをして、新しい機能やビルドの警告に対応しました。
なるべく定期的に、これらの作業は行っていった方が良いでしょう。
(ただし大規模な開発の場合、リリース直前のバージョンアップなど、メンバーとの意思共有が必要になります)
ライフサイクルのライブラリの新機能により、拡張プロパティ ViewModel.viewModelScopeを使えるようになり、それに合わせたリファクタリングを行いました。
Koinを使い、DIなコードにリファクタリングしました。
また、テスト用モジュールを用意し、テスト時にはデータベースをメモリ上に作成する、メインスレッドからのアクセスを許可するなど、本番アプリとは異なる条件にする方法を学びました。
ここまでの状態のプロジェクトをGithubにpushしてあります。
予告
カレンダー表示に手を付けるか、グラフ描画にするか・・・
時間があまりない・・・
グラフ描画はテストしづらそうだなあ・・・(むしろテストしようがないから書かない、と逃げられるかなあ笑)
Androidアプリでは非常によく使う、ViewPager
の勉強にもなるし、やっぱりカレンダー表示かなあ・・・
でもこっちはUIのテストが激変するからやりたくない
ということで、まだ迷っています。
いっそFlutterに浮気しちゃうかも(笑)
ここまでのと同じアプリを、Flutterで作る講座とか、需要あるかな??
参考ページなど
-
Room + Coroutines + Koin + Kotlin構成での実装
https://qiita.com/Slowhand0309/items/ece245e2c0e3656afe6b -
Kotlinで DI (Dependency Injection)~ Koin 編
https://qiita.com/sudachi808/items/8e03503f52b4f11533a2 -
Kotlin + Koinでテストコードを書いてみた
http://monakaice88.hatenablog.com/entry/2019/06/04/071349 -
Robolectric + JetpackでActivityのonActivityResultメソッドをテストする
https://satoshun.github.io/2019/02/androidx-onactivityresult-testing/