Gunosy Tech Blog

Gunosy Tech Blogは株式会社Gunosyのエンジニアが知見を共有する技術ブログです。

Android の Kotlin Coroutines 導入の第一歩


こんにちは。Android アプリ開発担当の nagayama(@nagayan_dev)です。

この記事はGunosy Advent Calendar 2022の2日目の記事です。前回の記事はかとうさんの Gunosy エンジニア行動指針をつくりました でした。
今回私が担当している「auサービスToday」で Coroutines 導入を行ったのでその内容についてお伝えします。

背景

Coroutines とは Kotlin で記述する軽量な非同期処理です。

developer.android.com

既に登場してから何年も経過しているため、かなり多くのアプリケーションに導入されています。昨年リリースされた「auサービスToday」ではスケジュールの関係で調査の時間が取れず、初期リリースでは Coroutines の導入は諦めて使い慣れた RxJava を引き続き使うことになりました。今回の対応で全てのコードを Coroutines に対応したわけではありませんが、Coroutines 導入の第一歩として調査・対応を行いましたので、そのお話をしたいと思います。

現状と対応方針

「auサービスToday」は MVVM のアーキテクチャを採用し、API リクエスト処理は retrofit・RxJava を用いて実現しています。API リクエストは Service インタフェースクラスを作成しており、

ViewModel → UseCase → Repository → Service

と経由してリクエストを実行しています。

しかし API リクエストでは基本的に自社製の token を付与してリクエストをしているため、処理が入れ子になりがちになっていました。例えば下記のような感じです。

fun loadInitCondition(): Single<InitCondition> {
           // token の取得
        return dataRepository
            .getToken()
            .flatMap { token ->
                // API リクエストの実行
                apiRepository
                   .getInitCondition(token)
            }
            .subscribeOnIoThread()
            .map { initCondition ->
               // リクエスト結果の成形やABテストの準備等
            }
            .doOnSuccess {
                // 取得データの保存や通知
            }
            .doOnError {
               // エラー表示
            }
}

途中の処理を省略していますが、このようにパッと見てすぐに処理が理解しにくい状態になっていました。そこで今回は API リクエストを Coroutines に置き換えていきたいと考えました。

導入実装

① 導入箇所の選定

Coroutines 導入にあたり、一度に全てのリクエストを対応するには影響が大きすぎます。そのため対応の影響がなるべく最小限になるように、

  • 単体で独立している機能 / 画面
  • なるべくシンプルなリクエストをしているところ

を考慮し、通知設定画面の設定情報取得リクエスト を対応することに決めました。

通知設定画面

② ライブラリの導入

Coroutines を利用するためにライブラリの導入を行います。

build.gradle

dependencies {
    implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines_version'
}

Coroutines のバージョンはこちらを参照してください。

基本的に上記のライブラリを取り込むだけで利用は可能ですが、他の Coroutines の API を使用したい場合は下記の導入も必要になります。

build.gradle

dependencies {
    implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version'
}

③ Serivce の変更

まずはリクエストを行っている、retrofit の Service インタフェースクラスを Coroutines に置き換えます。
メソッドは suspend 関数にすることで、 Coroutines の非同期処理内で使用することができるようになります。また、戻り値は RxJava の Single や Completable を使用していたため、retrofit の Response 型を設定しました。

【やること】

  • suspend 関数にする
  • 戻り値を retrofit の Response 型にする

UserService.kt

interface UserService {
    @GET("push_setting")
    suspend fun getPushSetting(
            @Header("token") token: String
    ): Response<PushSetting>

④ Repository の変更

次に Service クラスを実行している Repository クラスの対応をします。

Repository クラスに取り掛かる前に、リクエスト結果を返却するためのクラスを定義します。
「auサービスToday」では以前 マルチモジュール化対応 を行いました。リクエストを行っていない他のモジュールで retrofit ライブラリに依存したくない。ということがあったため、そのまま retrofit の Response 型で返すことができませんでした。

tech.gunosy.io

そこで下記のような共通の Result 型を作成しました。

Result.kt

sealed interface Result<out T : Any> {
  data class Success<out T : Any>(val data: T) : Result<T>
  data class Error<out T : Any>(val responseCode: Int? = null) : Result<T>
}

Success には取得されたデータを格納できるようにし、 Error にはエラーコードを入れられるようにしました。

この Result 型を用いて Repository から UseCaseに返却するように対応します。

【やること】

  • メソッドを suspend 関数にする
  • 戻り値を先ほど作成した Result 型にして変換する
  • withContext を利用して、IOスレッドでリクエストを実行するようにする

PushSettingRepository.kt

suspend fun getPushSetting(token: String): Result<PushSetting> {
    return withContext(Dispatchers.IO) {
         try {
            val response = userService
                                .getPushSetting(token)
            val data = response.body()
            if (response.isSuccessful && data != null) {
                Result.Success(data)
            } else {
                Result.Error(response.code())
            }
        } catch (e: Exception) {
            Result.Error()
        }
    }
}

ここで Coroutines の欠点で、RxJava では通信エラー等の Exception をキャッチできますが、Coroutines ではそれができないために自前で try-catch を実装する必要があります。

※ おまけの追加対応

今後他のリクエストも Coroutines に置き換えることを想定すると、ここまでの処理を毎回記述するのは手間になってしまいます。そこでこの一連の流れを全て行ってくれる Extension を作成し、Repository ではリクエスト実行処理を記述するだけで完了できるようにしました。

ApiExtension.kt

suspend fun <T : Any> executeRequest(apiRequest: suspend () -> Response<T>): Result<T> {
  return withContext(Dispatchers.IO) {
    try {
      val response = apiRequest.invoke()
      val data = response.body()
      if (response.isSuccessful && data != null) {
        Result.Success(data)
      } else {
        Result.Error(response.code())
      }
    } catch (e: Exception) {
      Result.Error()
    }
  }
}

PushSettingRepository.kt

suspend fun getPushSetting(token: String): Result<PushSetting> {
  return executeRequest {
    userService.getPushSetting(token)
  }
}

⑤ UseCaseの変更

続けて UseCase の対応を行います。シンプルに token を取得してそのまま API リクエストを行います。

【やること】

  • メソッドを suspend 関数にする
  • 戻り値を Result 型にする

完成したコードは下記になります。token 取得と API リクエストが入れ子にならずにシンプルに記述できるようになりました。

PushSettingUseCase.kt

suspend fun postNotificationSetting(setting: PushSetting): Result<PushSetting> {
  val token = dataRepository.getToken() ?: return false
  return pushSettingRequestRepository.postPushSetting(token, setting)
}

⑥ ViewModelの変更

最後に ViewModel の対応です。
viewModelScope から launch して Coroutines の処理を実行します。

【やること】

  • viewModelScope から launch を実行する
  • launch の中にリクエスト関連の処理を移植する

全ての処理を直列で記述することができるため、リクエスト実行の前後に読み込み中の progress の表示切り替えを書くことができます。長年 Listener 等の Callback の中で行っていることが直列で書けるようになり・・まだまだ慣れないですね。

PushSettingViewModel.kt

private fun fetchPushSetting() {
  viewModelScope.launch {
    isLoading.postValue(true)
    val result = useCase.fetchSetting()
    when (result) {
      is Result.Success -> row.postValue(result.data.settingList)
      is Result.Error   -> showError()
    }
     isLoading.postValue(false)
  }
}

これで ViewModel から Service インタフェースまでの API リクエストの一連の流れが Coroutines に置き換えることができました。

まとめ

Coroutines の導入を行いました。まだまだ慣れていないので違和感はあるものの、これだけ処理がスッキリ書けるようになるのは Android 開発の歴史の中でもかなり画期的な進化ではないでしょうか。

これを読んで Coroutines ってこんなに簡単に導入できるんだ。と感じていただけたら幸いです。みなさんも新しい技術に果敢に挑戦していきましょう。

次回は suchida さんの M1 Mac に挫けない!TensorFlow に躓かない開発環境をつくる の話です。お楽しみに!