Infinito Nirone 7

白羽の矢を刺すスタイル

RxJava と Kotlin Coroutines のテストチートシート

忘れそうになるのでメモ。テストは Kotest をつかっているが、極力 JUnit でのテストの記述と対比しやすいよう StringSpec で書いていく。 最近 RxJava から Kotlin Coroutines への乗り換えを進めていて、テストの書き方がどう異なるか(あるいは同様となるのか)についてまとめたくなったので残している。

RxJava

次の SomeModel をテストしたい。

class SomeModel {
  private val textMutation: BehaviorSubject<String> = BehabiorSubject.createDefault("foo")
  val text: Subject<String> = textMutation.hide()

  fun changeText(text: String) {
    textMutation.onNext(text)
  }
}

changeText 関数によるテキストの変更を text を観測して確かめる。 RxJava では TestObserver があるのでこれを使う。

class SomeModelTest : StringSpec() {
  init {
    "Change text" {
      // Given: SomeModel 
      val model = SomeModel()
      val observer: TestObserver<String> = model.text.test()

      // When: Change text
      model.changeText("bar")

      // Then: Observe 2 updates
      observer.awaitCount(2).assertValueCount(2)

      // And: "foo" comes first
      observer.assertValueAt(0) { text ->
        text == "foo"
      }

      // And: "bar" comes second
      observer.assertValueAt(1) { text ->
        text == "bar"
      }
    }
  }
}

Kotlin Coroutines

RxJava の例と似たようなクラスをテストしてみる。RxJava では BehaviorSubject をつかっていたが、Kotlin Coroutines では StateFlow を使う。

class SomeModel {
  private val textMutation: MutableStateFlow<String> = MutableStateFlow("foo")
  val text: StateFlow<String> = textMutation.asStateFlow()

  fun changeText(text: String) {
    textMutation.value = text
  }
}

SomeModel のテストは RxJava の例と同じく、changeText 関数によるテキストの変更を text を観測して確かめる。 RxJava の TestObserver に相当するものとして、StateFlow の通知する値を詰め込んでおく ArrayList を用意する。 ただし kotlinx-coroutines のバージョンによってテストの書き方が変わることに注意する。

次の例は 1.5.2 以前の書き方。

class SomeModelTest : StringSpec() {
  init {
    "Change text" {
      runBlockingTest {
        // Given: SomeModel 
        val model = SomeModel()
        val observedChanges: MutableList<String> = ArrayList()
        val job = launch {
          model.text.toList(observedChanges)
        }

        // When: Change text
        model.changeText("bar")

        // And: Tear down job
        job.cancel()

        // Then: Observe 2 updates
        observedChanges.size.shouldBe(2)

        // And: "foo" comes first
        observedChanges[0].shouldBe("foo")

        // And: "bar" comes second
        observedChanges[1].shouldBe("bar")
      }
    }
  }
}

次の例は 1.6.0 以降の書き方。 runBlockingTest が deprecated となり、代わりに runTest を使う。これにともなって、テストの前準備として StandardTestDispatcherDispatchers.setMain にわたすことと、runTest ブロック内で launch するときに UnconfinedTestDispatcher を利用して StateFlow が通知する全ての値を観測可能にすることが必要になる。 この変更についてはマイグレーションガイドが提供されている。

class SomeModelTest : StringSpec() {
  init {
    "Change text" {
      runTest {
        // Given: SomeModel 
        val model = SomeModel()
        val observedChanges: MutableList<String> = ArrayList()
        val job = launch(UnconfinedTestDispatcher()) {
          model.text.toList(observedChanges)
        }

        // When: Change text
        model.changeText("bar")

        // And: Tear down job
        job.cancel()

        // Then: Observe 2 updates
        observedChanges.size.shouldBe(2)

        // And: "foo" comes first
        observedChanges[0].shouldBe("foo")

        // And: "bar" comes second
        observedChanges[1].shouldBe("bar")
      }
    }
  }

  // Robolectric を利用したテストを記述するとき、StandardTestDispatcher の設定は必ず beforeTest で実行する
  //(beforeSpec では Looper クラスが見つからず失敗する)
  override fun beforeTest(testCase: TestCase) {
    super.beforeTest(testCase)
    Dispatchers.setMain(StandardTestDispatcher())
  }

  override fun afterTest(testCase: TestCase, result: TestResult) {
    Dispatchers.resetMain()
    super.afterTest(testCase, result)
  }
}

2021 振り返り

去年の年末の振り返りも発掘したので、同じような感じで振り返ってみます。

チームで育てる Android アプリ設計の出版

2020年振り返りでは、技術者視点でDXを上げていく活動 として前職での活動やアウトプットを軽く振り返っていました。その結実として今年春に チームで育てる Android アプリ設計 を執筆・出版しました。

peaks.cc

クラウドファンディングの出だしも好調で、出版後もオンライン勉強会ということで YouTube 配信も実施しました。

www.youtube.com

共著の kgmyshin さんとともに、新規事業におけるチーム開発や大規模なチームでの開発という2つの軸で内容を詰め、最後に両者の対比をして締めくくるというような構成です。どちらも実際の事例をもとにした考察から様々な手法・手順の提案をしていて、この書籍をベースに各チームで議論を深め実践できる内容になっています。 現状の開発体制やコードの状態に課題を感じていたり、何らかのアクションを起こしたいが良いヒントはないかと探していたりする方にピッタリな本だと思いますので、是非手元に書籍または電子書籍を用意しつつ、YouTube のアーカイブ動画を視聴いただければと思います。

書評も書いていただいているので、合わせてご覧ください。

muumuutech.hatenablog.com

jmatsu.hatenablog.com

note.com

DroidKaigi における定期的な YouTube 配信業

当初は DroidKaigi 2021 のオンライン開催を事前に盛り上げていくための企画として動き始めた DroidKaigi: Weekend Chat ですが、会期後にも引き続き配信を続けています。

www.youtube.com

配信の頻度は不定期で間があくこともありますが、こちらは 2022 年以降も続けていく予定です。 配信は基本的に音声を YouTube に流せれば良いので Mac でやっています。Windows のほうが配信環境の作りやすさでは分があるのですが、普段遣いが全部 Mac なので Mac でやっています。 OBS ではマイクの音声しか拾えないので、Discord での会話や素材 BGM を OBS に流すためには Loopback という別のソフトウェアが必要です。無課金だと一定時間までしか音声が拾えず、時間を超えると雑音がのる仕様なことに気をつけてください(ライセンス課金しましょう)。

rogueamoeba.com

F1

2021 年はレッドブル・ホンダがついにドライバーズ・ワールドチャンピオンを取りましたね。物議を醸すことの多い年でもありましたが、実況・解説でも語られたとおりフィクションの筋書きでは臭すぎるくらい劇的な結末を迎えました。

F1 といえば近年は YouTube 動画や配信にも力を入れているようで、レース期間中の無線によるドライバーとチームの会話まとめ動画も毎レース作られています。今年は特にイタリア GP でマクラーレンが優勝したときの無線が好きで何度か繰り返し聞いています。チーム側がねぎらいの言葉をドライバーにかけている裏で、チーム代表が大はしゃぎで「HAHA!」と喜んでいる声が聞こえてきてとても良いです。伝統あるチームの雰囲気も今の代表で大分変わったんだなと思いました。

自転車

夏も終わり始めた頃に見かけた記事がきっかけで、11月に富山にサイクリングしに行きました。

めっちゃきれいな景色やん…とおもい、一年のうちでも晴れの確率の高い11/22にサイクリングを決行!とおもったら、見事に雨に振られてしまいました。悲しい。ただせっかくなので、雨天100km海岸沿いライドをしてきました。なかなか大変ではありましたが、普段見ることのない日本海を横目に射水から滑川まで往復しました。しっかりとコースが設定してあり、路面の標示と標識の両方が設置してあるため、初見でも迷うことなく走れるようになっています。

www.strava.com

あとは、細々したことですがものが壊れる一年でもありました。目立ったものではクリートの破断とタイヤ・チューブのパンクですね。パンクは11月と12月で連続して発生してしまい、何かと悲しい気持ちになりました…

ところで、去年は真面目にまとめていた運動の成果ですが今年はご覧の有様でございます。本当にありがとうございました。やはり苦手なランニングは続かなかったよ……

Dagger Hilt と WorkManager を組み合わせて使う

Dagger Hilt は Android 用に様々な場面で使いやすいようになっていて、WorkManager を使った場合にも Dagger Hilt で DI が実現できるようになっています。

依存関係

Dagger Hilt と WorkManager を依存関係に追加します。2021/12 時点で Dagger Hilt の KSP 対応は完了していないので、kapt を使います。

buildscript {
    dependencies {
        // Hilt Gradle Plugin への依存を追加
        classpath("com.google.dagger:hilt-android-gradle-plugin:2.40.3")
    }
}
plugins {
    // kapt を適用
    kotlin("kapt")
    // Hilt Gradle Plugin を適用
    id("dagger.hilt.android.plugin")
}

dependencies {
    // Dagger-Hilt の依存
    implementation("com.google.dagger:hilt-android:2.40.3")
    kapt("com.google.dagger:hilt-android-compiler:2.40.3")
    // AndroidX Hilt の依存
    implementation("androidx.hilt:hilt-common:1.0.0")
    kapt("androidx.hilt:hilt-compiler:1.0.0")
    implementation("androidx.hilt:hilt-work:1.0.0")
    // WorkManager の依存
    implementation("androidx.work:work-runtime-ktx:2.7.1")
}

kapt {
    correctErrorTypes = true
}

Worker の定義

Worker の定義は次の通りで、@HiltWorker, @AssistedInject, @Assisted の 3 つのアノテーションを使います。 - @HiltWorker: クラス定義に使うアノテーション - @AssistedInject: Worker のコンストラクタに使うアノテーション - @Assisted: WorkManager 内部で Worker に渡される Context と WorkerParameters に対して使うアノテーション

@HiltWorker
class SampleWorker @AssistedInject constructor(
    @Assisted applicationContext: Context,
    @Assisted params: WorkerParameters,
) : Worker(applicationContext, params) {
    override fun doWork(): Result {
      // ...
    }
}

この Worker に対し、さらに別のオブジェクトを差し込みたい場合は単純にコンストラクタに差し込みたいオブジェクトを追加していくだけです。

@HiltWorker
class SampleWorker @AssistedInject constructor(
    @Assisted applicationContext: Context,
    @Assisted params: WorkerParameters,
    // なんらかの data source レイヤのクラスを注入する(別途この data source の実体を提供するための module の宣言などは必要)
    private val sampleDataSource: SampleDataSource,
) : Worker(applicationContext, params) {
    override fun doWork(): Result {
      // ...
    }
}

WorkerFactory の追加

WorkManager では Work クラスの生成はほぼ自分で記述することがありません。Worker の定義がシンプルでコンストラクタの引数に Context と WorkerParameters しかない場合は、WorkRequest を経由して自動で Worker がインスタンス化されます。一方で引数に Context と WorkerParameters 以外のものがある場合は WorkManager が自動で Worker を生成できないため、Worker を生成するための手順を実装し WorkManager に教えてあげる必要があります。

Dagger Hilt を使う場合、先ほどの SampleWorker を生成するには Dagger が知っている object graph から SampleDataSource の実体を取り出して Worker にわたす手順が必要ですが、その手順は hilt-work が用意してくれていて、アプリの実装としては Application クラスを拡張し必要なインタフェースの実装を追加するだけになります。

@HiltAndroidApp
// androidx.work の Configuration.Provider を実装する
class ExampleApplication : Application(), Configuration.Provider {

    // Dagger Hilt 用の WorkerFactory
    @Inject lateinit var workerFactory: HiltWorkerFactory

    // Dagger Hilt 用の WorkerFactory を設定して Configuration を返す
    override fun getWorkManagerConfiguration() =
        Configuration.Builder()
            .setWorkerFactory(workerFactory)
            .build()
}

この設定のため AndroidManifest.xml では WorkManager の default initializer を無効化しておきます。default initializer の無効化手順は、アプリで App Startup を使っているかどうかによって変わります。WorkManager は内部で App Startup の仕組みを使っているため、アプリが WorkManager 以外の用途で App Startup を使っている場合は、その App Startup から WorkManager のみを無効化する必要があります。

<!-- アプリで他に AppStartup を使わない場合 -->
<provider
    android:name="androidx.startup.InitializationProvider"
    android:authorities="${applicationId}.androidx-startup"
    tools:node="remove">
</provider>
<!-- アプリで他に AppStartup を使っている場合 -->
<provider
    android:name="androidx.startup.InitializationProvider"
    android:authorities="${applicationId}.androidx-startup"
    android:exported="false"
    tools:node="merge">
    <meta-data
        android:name="androidx.work.WorkManagerInitializer"
        android:value="androidx.startup"
        tools:node="remove" />
</provider>

WorkRequest を使って動かす

あとは Worker を動かす手順を実装するだけです。注意点として、WorkManager のインスタンスは必ず WorkManager.getInstance(Context) を使って取得します(引数のない getInstance メソッドでは WorkerFactory の設定などが無視されてしまう)。

val delay = 1000L
val params = Data.Builder()
    .putInt("number", 1)
    .build()

val workManager = WorkManager.getInstance(Context)
val work: OneTimeWorkRequest = OneTimeWorkRequest.Builder(SampleWorker::class.java)
    .setInitialDelay(delay, TimeUnit.MILLISECONDS)
    .setInputData(params)
    .build()
workManager.beginUniqueWork("sample_work", REPLACE, work).enqueue()

Target SDK Version を 31 に上げるときに引っかかるポイント: SparseArray

Kotlin を使用したアプリケーションという前提で、次のような構成のアプリがあり、このアプリの Target SDK Version および Compile SDK Version を 31 (Android 12) に上げることを考えます。

Target SDK Version を上げる前の構成: build.gradle

android {
    compileSdk 30

    defaultConfig {
        applicationId "dev.keithyokoma.sample"
        minSdk 23
        targetSdk 30
    }
}

dependencies {
    implementation 'androidx.core:core-ktx:1.6.0'
}

SparseArray に値をセットする

上記の構成のとき、次のようなコードは正しく意図通り動作します。 注目すべきは SparseArray に値をセットするコードです。AndroidX Core KTX の set 拡張関数 を使うことで、メソッド呼び出しではなく指定した添字への値の代入のように記述できます。

import androidx.core.util.set

val array = SparseArray<String>()
array[0] = "test" // AndroidX Core KTX の set 拡張関数を使っている

Target SDK Version および Compile SDK Version を 31 にあげる

ここで構成を変え、Target SDK Version と Compile SDK Version を最新の 31 に上げます。 するとビルド時にエラーが出力されます。

Call requires API level S (current min is 23): android.util.SparseArray#set

エラーメッセージが示す関数が androidx.core.util.set ではなく android.util.SparseArray#set になっています。これは、Android 12 (API Level 31) から新たに SparseArray#set メソッドが追加されたためです。 この新しい SparseArray#set メソッドを Kotlin で解釈したとき、Java で定義されたメソッドを添え字での値の代入として扱えるようにする仕組みが働き、array[0] = "test" はそのまま正しくコンパイル可能なコードになります。 同時に AndroidX Core KTX の set 拡張関数が使われなくなり(31 に上げると、上記 Kotlin のコードの import 文が unused になります)、minSdkVersion = 31 を要求する新しいメソッドを使っていることになるため、このようなエラーになります。

回避策

Target SDK Version および Compile SDK Version を 31 にした状態では拡張関数が利用できないので、次のように拡張関数が呼び出していた SparseArray#put を使います。

val array = SparseArray<String>()
array.put(0, "test")

類似のケース

実は Android 11 (API 30) 対応でも SparseArray の使用方法で実行時にエラーとなるケースがあったようです。

https://issuetracker.google.com/issues/168724187?pli=1

BottomNavigationView で setupWithNavController と setOnNavigationItemSelectedListener を同時に使いたい

Android Jetpack の Navigation Component と BottomNavigationView を組み合わせる場合、setupWithNavController 拡張関数を呼びだすだけで nav graph と BottomNavigationView の menu を紐付けてくれるようになります。

val navView: BottomNavigationView = ...
val navController: NavController = ...

navView.setupWithNavController(navController)

ここで、BottomNavigationView の menu を選択したときにコールバックを受けたい場合、次のように setOnNavigationItemSelectedListener を使ったコードを書くと BottomNavigationView の menu と nav graph の紐付けが壊れて画面の切り替えができなくなります。 setupWithNavController 拡張関数は内部で setOnNavigationItemSelectedListener を使って BottomNavigationView の menu を選択したときの処理を記述しているため、次のコードはその処理を上書きしてしまい、その結果画面の切り替えができなくなります。

val navView: BottomNavigationView = ...
val navController: NavController = ...

navView.setupWithNavController(navController)
navView.setOnNavigationItemSelectedListener { menu ->
  // Callback
  Log.d("Sample", "$menu is selected!!!")
  true
}

NavController の addOnDestinationChangedListener を使って各 menu に対応する destination に切り替わったことを検知するロジックを作ると、BottomNavigationMenu の menu を選択したときのコールバックと同等の機能が実現できます*1。 もし setOnNavigationItemSelectedListener を使いたい場合は、次のように setupWithNavController の実装を持ってくる必要があります。

val navView: BottomNavigationView = ...
val navController: NavController = ...

navView.setupWithNavController(navController)
navView.setOnNavigationItemSelectedListener { menu ->
  // Callback
  Log.d("Sample", "$menu is selected!!!")  
  NavigationUI.onNavDestinationSelected(menu, navController) // この部分が setupWithNavController でやっていること
}

Hilt 1.0.0 へのマイグレーション

Google I/O 2021 で Hilt がついに安定版に到達し、1.0.0 がリリースとなったことが告知されました。

この記事を執筆時点で Dagger Hilt および AndroidX Hilt の最新版は次のとおりです。

Dagger Hilt: 2.36 AndroidX Hilt: 1.0.0

それぞれにコンポーネントがあり別々のバージョン番号があるので少し分かりづらい状況になっていますが、少なくとも Hilt が安定版となったのは Dagger Hilt 2.35 からであることに特に注意します。

バージョンアップにともなうマイグレーション作業

ここでは主に Dagger Hilt 2.34 より前からのマイグレーション作業にフォーカスして記述します。

Dagger Hilt 2.34: @ViewModelInject の置き換え

@ViewModelInject が廃止され、@HiltViewModel に置き換わりました。これにともなって、コンストラクタに @Inject アノテーションをつける必要があります。

@HiltViewModel // @ViewModelInject ではなく @HiltViewModel を使い、コンストラクタに @Inject をつける
class SampleViewModel @Inject constructor(
  ...
) : ViewModel()

Dagger Hilt 2.34: SavedStateHandle のための @Assisted の削除

ViewModelSavedStateHandle を inject するために利用していた @Assisted が不要になりました。単純に削除するだけで OK です。

@HiltViewModel
class SampleViewModel @Inject constructor(
  ...
  savedStateHandle: SavedStateHandle, // @Assisted を消す
) : ViewModel()

Dagger Hilt 2.34: androidx.hilt:hilt-lifecycle-viewmodel への依存の削除

AndroidX Hilt には ViewModel 対応のためのアーティファクトとして androidx.hilt:hilt-lifecycle-viewmodel が用意されていますが、安定版では必要ないため削除します。Google Maven Repository には 1.0.0-alpha-03 までのバージョンがアップロードされていますが、純粋に必要なくなったため 1.0.0 のリリースはありません。依存を削除しましょう。 (AndroidX Hilt 側のリリースノートではなく Dagger Hilt 側のリリースノートに記述があるためすこし紛らわしいですが…… twitter を検索すると gerrit code review で androidx.hilt:hilt-lifecycle-viewmodel への依存を切るための差分がサブミットされている様子も見つかります。)

Dagger Hilt 2.36: Fragment#getContext の振る舞いの修正

これまで、Hilt において Fragment#getContext がうっかり Framgent が削除されたあとでもContext インスタンスを返していました。これは通常の Fragment とは異なる動きであり、通常の Fragment のように振る舞うことが本来の動作であったため、2.36 で修正が入ります。ただし、この修正には相当のインパクトが見込まれるため、 -Adagger.hilt.android.useFragmentGetContextFix=true をつかって feature flag を有効にしない限り、2.36 でも以前のバージョンと同じく Hilt の Fragment#getContextFragment が削除されたあとでも Context インスタンスを返します。

Deprecated アノテーションが deprecated になってしまったのを undeprecated した流れ

Android 12 の preview 段階で @Deprecated アノテーションが deprecated になったのがちょっと話題になりましたが、Android 12 Beta 1 では deprecated になったのを undeprecated にしたことが What's new in Android で語られました。

youtu.be

@Deprecated アノテーションは少し特殊で、Javadoc に @deprecated タグを記述することでもクラスやメソッドなどが非推奨であることを示します。 Android 12 では @Deprecated アノテーションに変更があり、forRemoval メソッドと since メソッドが増えています。これらを用いて、非推奨となったクラスやメソッドが将来的に削除予定かどうかを簡単に示せるようになります。

developer.android.com

このメソッドの追加にあわせて @Deprecated アノテーションの Javadoc も拡充されていて、より詳しく @Deprecated アノテーションの役割や挙動を説明するようになりました。 ここで Javadoc 内の @Deprecated アノテーションを表記するために {@code @Deprecated} と言う形でコードブロックの記法を使うようになりましたが、このコードブロック内の @Deprecated が Javadoc の @deprecated タグと解釈され、結果として @Deprecated アノテーションそのものが deprecated と解釈されてしまった、というのが @Deprecated アノテーションが deprecated となった経緯のようです。

うっかり deprecated になってしまった @Deprecated を undeprecated にするため、コードブロック内であっても @ をエスケープする(&#64;にする)差分が作られ、晴れて undeprecated することができたようです。