STORES Product Blog

こだわりを持ったお商売を支える「STORES」のテクノロジー部門のメンバーによるブログです。

K2移行、やるぞ!

こんにちは。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ではDaggerRoomで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はプロジェクト内で実装しているクラスですが、KotlinResult<T>のようなものだと思っていただければOKです。

interface UseCase {
   suspend fun doSomething(): Result<String>
}

when (usecase.doSomething()) {
   is Result.Success -> { } 
   is Result.Failure -> { }
}

UseCaseの処理結果を受けて、成功と失敗の処理を記述しています。Result以外の値が流れてくる余地はなさそうに思え、ワーニングなども出ておらず謎は深まるばかりです。

UnitTestの実装を検証してみると次のことが分かりました。

  • 失敗するテストケースではUseCaseはMockito-Kotlinでモックされている
  • wheneverdoSomethingを適切にコントロールしているテストケースでは例外が発生しない
  • 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になったプロジェクトとともに新年を迎えられます!