Kotlin flowsをmikanに導入しました

f:id:iiizukkeyiii:20210617174808p:plain

はじめに

こんにちは。 mikanでAndroidエンジニアをしております、zukkeyといいます。

今回は、mikan AndroidにKotlin flowsを導入したので、Flowのざっくりとしたまとめとmikanでの実装例について紹介させていただきます。

まず初めに、Kotlin flowsに関しての事前知識部分についてざっくりと紹介いたします。

Kotlin flows

簡単にまとめると次のとおりです。

  • Flowは、コルーチンの一種で 複数の値を順次出力 することが可能
  • 一連の値を生成することができるIteratorに似ているが、 非同期に値を生成することができる
  • リアクティブプログラミングスタイルで利用可能。Rxと同様の機能。

詳細については、公式ドキュメント 1とコードラボ2に詳しく載っています。

StateFlow / SharedFlow

簡単にまとめると次のとおりです。

  • StateFlow
    • 状態保持用の監視可能な Flowで同じ値は流れない
    • 「ホット」なFlow
    • LiveDataとの違い
      • StateFlowは、初期状態をコンストラクタに渡す必要がある
      • StateFlowは、StateFlow やその他の Flow からの収集ではコンシューマー側の登録解除がされない
      • UIでデータを収集する際に、Jobを作成して明示的に手動で停止しないとリークする(repeatOnLifecycleAPIでも可能だが、2021/6現在alpha版のみ提供)
  • SharedFlow
    • StateFlowを一般化して、詳細な設定ができる
    • 定期的にすべてのコンテンツをまとめて更新するということができる

詳細については、公式ドキュメント3に詳しく載っています。

ざっくりと概要がわかったところで、実際にどのようにしてmikanでは利用しているのか説明していきます。

mikanプロダクトへ導入した部分の紹介

mikan Androidは、間もなく5年近くになるアプリで、この先mikanを高速かつ、複数人で進化させていくためには厳しい仕組みになっています。今後は、大規模なリファクタリング(リアーキテクチャ)を行い、結果的にチーム全体のパフォーマンス向上を狙っていきたいとチームで取り決めています。

そのため、今まで支えてくれたコードに感謝をしつつ、現在新しくアーキテクチャを刷新していくという状況です。

新しいアーキテクチャは、公式推奨の形に則り下記の図のように定めました。(※今後必要に応じて変わっていく可能性があります。)

f:id:iiizukkeyiii:20210617173433p:plain

現在のmikanでは、すでにCoroutinesを導入しているという背景もあり、Rx部分とLiveData部分をKotlin flowsに置き換えられると判断して、次のように利用していくことといたしました。

f:id:iiizukkeyiii:20210617173450p:plain

非同期に取得できるデータは、Flowで値を流すようにして、View側で収集を行い非同期で受け取った値を元にUIに反映していくというような流れになっています。

具体的には、mikanのアプリで適用した箇所がマイページ画面になります。

f:id:iiizukkeyiii:20210617173508p:plain

マイページでは、高校が設定されている場合には、高校の情報を取得するAPIを初回に叩いており、高校名をニックネーム(添付画像の「ひとかわ むけお」)の部分の下に表示されるようになっています。

実際にどのようなフローで利用しているのか、コードベースで紹介していきます。

まずはじめに、Viewが生成されたタイミングで、ViewModelの関数をコールします。...は省略を意味し、以降同様です。 先程のアーキテクチャの流れだと、下記の部分になります。

f:id:iiizukkeyiii:20210617173526p:plain

実際のコードは次のとおりです。

@FlowPreview
@AndroidEntryPoint
class MyPageFragment : Fragment(), BottomNavigationView.OnNavigationItemReselectedListener {
    ...
    private val myPageViewModel: MyPageViewModel by viewModels()
    ...

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        setUp()
    }
    ...

    private fun setUp() {
        ...
        myPageViewModel.initialize()
    }
    ...
}

下タブをマイページに切り替えた際に、onViewCreatedが呼ばれるので、その中でViewModelのinitialize関数をコールします。

ViewModelのinitialize関数の中身をみていきましょう。

MyPageViewModelの中は次のとおりです。

@FlowPreview
class MyPageViewModel @ViewModelInject constructor(
    @ApplicationContext private val context: Context,
    private val myPageRepository: MyPageRepository
) : ViewModel() {
    // UiDataはUIの表示に必要な情報としています
    private var uiData = MyPageUiData(
        ...
    )
    ...

    fun initialize() {
        if (!uiData.shouldInitialize) return
        viewModelScope.launch {
            myPageRepository.getNickName(context)
                .flatMapConcat {
                    uiData = uiData.copy(nickName = it)
                    // 今回の例における高校の情報を取得する部分がこちら
                    myPageRepository.getSchoolName(context)
                }
                ...
        }
    }
    ...
}

initialize関数の中では、API呼び出し以外にもSharedPreferenceから情報を読み取り、マイページに表示したい情報を取得するようになっています。flatMapConcatでRxのように繋げることが可能です。

高校名の取得部分は、MyPageRepositoryにgetSchoolNameという関数を定義して取得するようになっています。 先程のアーキテクチャの流れだと、下記の部分になります。

f:id:iiizukkeyiii:20210617173549p:plain

次に、MyPageRepositoryからAPIコールする部分をみていきましょう。 先程のアーキテクチャの流れだと、下記の部分になります。

f:id:iiizukkeyiii:20210617173606p:plain

実際のコードは次のとおりです。

class MyPageRepositoryImpl : MyPageRepository {
...
    override suspend fun getSchoolName(context: Context): Flow<String> {
        ...
        return flow {
            try {
                // ServiceはAPIコール部分を担い、APIコールを行い返ってきた値を出力します
                emit(service.getSchoolDetails(schoolId)?.name ?: defaultSchoolName)
            } catch (e: Exception) {
                Logger.e(e)
                emit(defaultSchoolName)
            }
        }
    }
  ...
}

MyPageRepositoryのgetSchoolNameでは、APIコールを行うべくサービスの実体を生成し、Flowビルダー関数で新しいFlowを作成し、APIコールを行い返って来た値を emit します。Flowでは、emitすることで、データストリームに値を出力することができます。高校の表示部分は、デフォルトの文字列が決まっているので、例外でもdefaultSchoolNameをemitするようにしています。

Flowのデータストリームに値を生成して出力をしたら、次は出力した値の収集を行います。

マイページ画面では、ViewModelで収集を行っています。

先程のアーキテクチャの流れだと、下記の部分になります。

f:id:iiizukkeyiii:20210617173625p:plain

実際のコードは次のとおりです。

@FlowPreviewに関しては、こちらに詳しく載っており、flatMapConcatを利用しているため付与しています。

また、mikanではDagger Hiltを利用しており、一部Dagger Hiltのコードが含まれています。

@FlowPreview
class MyPageViewModel @ViewModelInject constructor(
    @ApplicationContext private val context: Context,
    private val myPageRepository: MyPageRepository
) : ViewModel() {
    ...
    fun initialize() {
        if (!uiData.shouldInitialize) return
        viewModelScope.launch {
            myPageRepository.getNickName(context)
                .flatMapConcat {
                    uiData = uiData.copy(nickName = it)
                    myPageRepository.getSchoolName(context)
                }
                .flatMapConcat {
                    ...
                }
                .catch { 
                  // Error時に流れる。UiState.Errorを放出する。
                  _uiState.value = MyPageUiState.Error
                }
                .collect {
                  // データの収集を行う。返って来た値をViewModel側で保持しているところで更新し、UiStateを流す。
                    uiData = uiData.copy(
                        isPro = it,
                        ...
                        shouldInitialize = false
                    )
                    _uiState.value = MyPageUiState.Success(uiData)
                }
        }
    }
    ...
}

collect でFlowのデータストリームに流れているデータ収集をすることができます。 マイページでは、今回はStateFlowと組み合わせているのでcollectに来た場合はSuccessとしてUiStateをsetするようにしています。

また、catch 中間演算子で、データ生成時に例外が発生する可能性があるので、ラムダの中で予期しない例外処理を行います。catchに流れて来た場合は、collectラムダは呼び出されないため、今回はError状態としてUiState.Errorをsetするようにしています。

最後に、ViewModelからViewへデータを流す部分について説明していきます。

LiveDataで実装することもあるかと思いますが、今回はStateFlowで実装を行いました。

先程のアーキテクチャの流れだと、下記の部分になります。

f:id:iiizukkeyiii:20210617173645p:plain

実際のコードは次のとおりです。

@FlowPreview
@AndroidEntryPoint
class MyPageFragment : Fragment(), BottomNavigationView.OnNavigationItemReselectedListener {
    @Inject
    lateinit var myPageAdapter: MyPageAdapter
    private val myPageViewModel: MyPageViewModel by viewModels()
    ...
    private var job: Job? = null
    ...
    override fun onStart() {
        super.onStart()
        job = lifecycleScope.launch {
            myPageViewModel.uiState.collect { uiState ->
                when (uiState) {
                    is MyPageUiState.Success -> {
                        val uiData = uiState.uiDate
                        myPageAdapter.reload(
                            uiData = uiData,
                            ...
                        )
                        ...
                    }
                    is MyPageUiState.Loading -> {
                        ...
                    }
                    is MyPageUiState.Error -> {
                        // 一部省略してます
                        Toast.makeText(...).show()
                    }
                    is MyPageUiState.None -> Unit
                }.safe  // safeは、コンパイル段階で気づけるようにしている拡張関数です
            }
        }.apply { start() }
    }
    ...
    override fun onStop() {
        super.onStop()
        job?.cancel()
        ...
        job = null
    }
  ...
}

Flowと同様に、StateFlowでも collect で収集を行います。ViewModelでsetしたUiStateのデータがcollectラムダに流れてくるようになり、UiStateの各状態に応じて処理を行います。

一点注意が必要なのが、StateFlowは、UIで収集を行うとViewが表示されていなくてもイベントを処理するため、不具合の原因になりえます。そのため、UiStateのJobを定義してライフサイクルに合わせてstartとstopを行うようにします。 repeatOnLifecycleAPIで解決する手段もありますが、alpha版のため今回はこちらの手法で対応しています。

以上がマイページでのFlow導入例についての紹介でした。 まとめると、AndroidにFlowに導入する時に行うことは次のようになります。

  1. FlowビルダーAPIを利用して flow { } でFlowの生成を行う
  2. emit 関数でデータの生成をし、Flowにデータを出力する
  3. collect で作成したFlowデータストリームのデータ収集を行う
  4. UIでデータ収集する場合は、Jobを定義してライフサイクルに合わせて収集を行う

おわりに

Flowを導入してみて、Rxと同様かつStateFlowでもLiveDataを置き換えることができて特に問題なく運用していけそうなことがわかりました。

Flowに置き換えるメリットとしては、公式が出しているので、そのまま公式に統一できるのが一番でかい。公式なので、という一言で共通認識が取りやすいという部分とCoroutinesを入れている場合は特にサードパーティライブラリに頼らないで置き換えていけそうなので、その点だけでも技術採用しても良いと思いました。

まだ、導入したばかりですので、引き続き運用してみてまたわかったことがあれば記事にしていこうかと思います。

さいごに、mikanでは、一緒に英語学習をより良いものにしていくべくサービスを作っていく仲間を募集しています。ご興味のある方は、以下のリンクからぜひご応募ください!

herp.careers


  1. Kotlin flows on Android では、Flowの概要について詳しく説明されている。3つのエンティティの概念だけではなく、具体例も書かれているので必見です。

  2. CodeLab:8. Introducing Flowでは、サンプルアプリを用いてFlowについて解説がある。Roomを用いた例も示されており、初めの導入として一通り目を通すと学びがあります。

  3. StateFlow and SharedFlowでは、StateFlow と SharedFlowの概念と、実際にAndroidアプリで使うときにどのような想定で使うかについて書かれています。今回プロダクトではalpha版を利用していないため、使っていませんがrepeatOnLifecycle APIの実装例も記載されています。