こんにちは。STORES 決済 でAndroidエンジニアをしているn-sekiです。 つい最近まで暖かな気候で「今年は紅葉が遅いなぁ」なんて思っていましたが、気がつくと師走です。困ったものですね。
今回はK2移行について書こうと思います。
なお、この記事は STORES アドベントカレンダーの5日目の記事です。
K2
JetBrainsがKotlinの新たなコンパイラを開発しており、「K2」というコードネームで呼ばれていたことはAndroidエンジニアのみなさんには周知の事実でしょう。 Kotlin2.0より前のバージョンではデフォルトでは以前のコンパイラが利用されるため、K2を使うには設定で有効にする必要がありました。
そして2024年5月にKotlin2.0がリリースされました。 このタイミングでK2も安定版となり、Kotlin2.0以降ではK2がデフォルトで利用されるようになりました。
https://blog.jetbrains.com/ja/kotlin/2023/06/k2-kotlin-2-0/
https://kotlinlang.org/docs/whatsnew20.html
私たちの STORES 決済 AndroidアプリもK2移行を計画していました。
移行
ほんとうは事前に影響範囲を確認した上で移行計画を検討するべきなのかもしれませんが、検証を始めたら割とすんなりK2でビルドができてしまい、気がついたら手元で移行が完了していました。
想像していたよりもスムーズで驚いてしまったのですが、とはいえちょこちょこ修正が必要でした。 実際の移行作業中に残していたメモをベースに、STORES 決済 のAndroidアプリのK2移行でどのような対応をしたのか公開してみます。
KotlinアップデートとK2有効化
おおむね公式ドキュメントの移行ガイドに従えばOKです。
https://kotlinlang.org/docs/k2-compiler-migration-guide.html
この記事の移行対応時点でのKotlinの最新バージョンは 2.0.21 でしたが、
The new K2 compiler is enabled by default starting with 2.0.0.
とあるように、Kotlinを2.0.0以上にすればK2はデフォルトで有効になるようです。
Compose Compiler Plugin導入
Kotlinを2.0.21にアップデートしてGradle Syncしたところ、「Compose Compiler Gradle Plugin
を利用せよ」とのエラーメッセージが出力されました。
調べてみると、Jetpack Composeを利用しているプロジェクトでK2移行する際にはこのプラグインに移行する必要があるようです。
https://developer.android.com/develop/ui/compose/compiler
移行ドキュメントとしてはこちらを参照して、既存の composeOptions
などを削除して移行しました。
https://android-developers.googleblog.com/2024/04/jetpack-compose-compiler-moving-to-kotlin-repository.html
この状態でビルドをすると......通りました! が、まだ気になるポイントがあります。
KSP移行
ビルドログを確認しているとkaptに関するワーニングが見つかりました。
w: Kapt currently doesn't support language version 2.0+. Falling back to 1.9.
調べてみると、
- Kotlin 2.0.21 の段階ではkaptはexperimentalな扱い
- そもそもkapt自体メンテナンスモード
という状況でした。
https://kotlinlang.org/docs/kapt.html
決済AndroidではDagger
とRoom
でkaptを利用しており、幸いどちらのライブラリもKSPに対応しているので移行対応を行いました。
RoomのKSP移行
Dagger
のKSP移行は公式ドキュメント通りに設定を変更するだけだったので、ここではRoomのKSP移行について書きます。
Roomも基本的には公式ドキュメントに従えば良いのですが Room Gradle Plugin
への移行を追加で行いました。
RoomではDBのスキーマ定義を.jsonファイルとして出力できます。これをgit管理しておけば、過去バージョンのスキーマに対するテストで利用できます。
https://developer.android.com/training/data-storage/room/migrating-db-versions#export-schemas
スキーマ定義ファイルの出力先はアノテーションプロセッサーのオプションとして渡す必要がありますが、kaptとKSPでは設定に利用するAPIが異なります。
https://developer.android.com/jetpack/androidx/releases/room#annotation-processor-options
Room Gradle Plugin
を使うことでこの差分を吸収しつつシンプルにオプション設定が行えるため、kaptの記述を以下のような記述に書き換えます。
android { room { schemaDirectory("$projectDir/schemas") } }
https://developer.android.com/jetpack/androidx/releases/room#gradle-plugin
シンプルで良いですね。
UnitTestの修正
アプリは問題なく動いてそうなのですが、失敗するUnitTestがあることに気が付きました。具体的には該当のテストケースを実行すると、kotlin.NoWhenBranchMatchedException
という例外が発生する状況でした。クラス名が示す通りwhen
分岐にマッチしない値が流れてきたようです。
なにか妙だな?と思いながら実装を確認します。例外が発生する処理はだいたい下のような実装になっていました。Result
はプロジェクト内で実装しているクラスですが、Kotlin
のResult<T>
のようなものだと思っていただければOKです。
interface UseCase { suspend fun doSomething(): Result<String> } when (usecase.doSomething()) { is Result.Success -> { } is Result.Failure -> { } }
UseCaseの処理結果を受けて、成功と失敗の処理を記述しています。Result
以外の値が流れてくる余地はなさそうに思え、ワーニングなども出ておらず謎は深まるばかりです。
UnitTestの実装を検証してみると次のことが分かりました。
- 失敗するテストケースではUseCaseはMockito-Kotlinでモックされている
whenever
でdoSomething
を適切にコントロールしているテストケースでは例外が発生しないwhenever
を使わず、doSomething
の返り値をモックが返すデフォルト値にしていると例外が発生する
コードで表現すると、
whenever(usecase.doSomthing()).thenReturn(Result.Success())
という記述があるとテストは成功し、ないとテストが失敗(例外発生)となっているようです。
モックライブラリ Mockito、Mockito-Kotlin をご存知でない方向けにかんたんに説明すると、
whenever(モックに対するメソッド呼び出し).thenReturn(返却値)
と記述することで、任意のメソッド呼び出し時に任意の値を返却させるようにモックを振る舞わせることができます。
https://github.com/mockito/mockito-kotlin
ちょっと実験してみましょう。
interface UseCase { suspend fun doSomthing(): String }
non-nullなStringを返すメソッドが定義されたUseCaseインターフェースを作りました。これをモックして、メソッドを呼び出してみます。
@Test fun test() = runTest { val mock = mock<UseCase>() val r = mock.doSomthing() println(r) // null }
null
が出力されました。Mockito-Kotlinで作成したモックインスタンスの各メソッドは、戻り値の型のデフォルト値を返却するように振る舞います。プリミティブでない型の場合には null
となるようです。
Mockitoのドキュメントに、
By default, for all methods that return a value, a mock will return either null, a a primitive/primitive wrapper value, or an empty collection, as appropriate. For example 0 for an int/Integer and false for a boolean/Boolean.
とバッチリ記載されていました。
https://site.mockito.org/javadoc/current/org/mockito/Mockito.html#stubbing
たしかに、
when (usecase.doSomething()) { is Result.Success -> { } is Result.Failure -> { } }
というロジックに対して usecase.doSomething()
が null を返すと kotlin.NoWhenBranchMatchedException
が発生しそうです。
すべてのコードがKotlinであれば null
が流れることはないですが、MockitoはJavaで実装されているため、今回のようなケースでは多少気を付ける必要がありそうです。
回避するにはデフォルトの返却値に頼るのではなく、whenever
を使って適切に戻り値をコントールすればよさそうですね。めでたしめでたし。
でもなにか気になる
でも、なにか気になります。
上記のような内容であれば以前からkotlin.NoWhenBranchMatchedException
になっていても良さそうですよね。
K2で何が変わったのか検証するべく、モックライブラリも使わない最小限のコードで再現させてみます。試行錯誤の結果、次のコードで再現することが分かりました。
kotlin.NoWhenBranchMatchedException
が発生する状況を意図的に作るため、Javaで適当なUseCaseを実装します。
public class UseCase { public Result<Integer> doSomething() { return null; // nullを返す } }
これを使ったJUnitのテスト(のようなもの)をKotlinで実装します。
@Test fun test() { when (UseCase().doSomething()) { is Result.Success -> {} is Result.Failure -> {} } }
これが、
- Kotlin 1.9.24 では成功する
- Kotlin 2.0.0 以降では
kotlin.NoWhenBranchMatchedException
でテスト失敗となる
ことが分かりました。
K2によってこのあたり挙動が改善されているのかもしれません。しかしデコンパイルした結果にも差分はなく、残念ながらこれ以上の探索はできていません。
@Test public final void test() { Result var1 = (new .UseCase()).doSomething(); if (!(var1 instanceof Result.Success) && !(var1 instanceof Result.Failure)) { throw new NoWhenBranchMatchedException(); } }
(デコンパイルした結果。バージョン間の差分はなし。)
どこで挙動差分が生まれているのか気になるので、このあたりに詳しい方いたら是非コメントなどで教えて下さい......!
さいごに
おおむね上記のような対応でK2移行ができました。移行したアプリを先日リリースしましたが、とくに問題なく動いてるようです。
K2移行、正直最初はちょっと身構えていたのですが、ドキュメントが充実していることもありステップ・バイ・ステップで進めると割とすんなり対応できました。
これでK2になったプロジェクトとともに新年を迎えられます!