前回の記事ではSharedFlowの動作について図とサンプルコードで説明しました。今回はSharedFlowとStateFlowの違いを説明し、アプリ内での使い分けについても説明します。
目次
StateFlowは、SharedFlowのいくつかのパラメータを固定したサブクラスです。kotlinlang.orgに、StateFlowの内部動作を説明したソースコードが掲載されているので引用します。
// MutableStateFlow(initialValue) is a shared flow with the following parameters:
val shared = MutableSharedFlow(
replay = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
shared.tryEmit(initialValue) // emit the initial value
val state = shared.distinctUntilChanged() // get StateFlow-like behavior
2~5行目では、repeat = 1, onBufferOverflow = DROP_OLDEST
を指定してMutableSharedFlowを作成しています。6行目では初期値を設定しています。7行目ではdistinctUntilChanged()
を呼び出しています。順に説明していきます。
最新の値を保持する
StateFlowは、repeat = 1, onBufferOverflow = DROP_OLDEST
のSharedFlowです。このオプションを指定するとどんな動作になるのか、前回の説明で使った図を2つ再掲します。
replay = 1
を指定すると、Subscriberを作成するタイミングに関係なく、最新の値を必ず取得できます。
DROP_OLDESTを指定すると、Emitterがデータを送り出すタイミングとSubscriberがデータを取り出すタイミングが一致しない場合でも、Subscriberはその時点で最新の値を取得できます。
StateFlowは初期値を持つ
次はStateFlowの初期値について説明します。内部実装のソースコードでは、初期値をemitしていました。この初期値は、StateFlowオブジェクト作成時に指定する必要があります。
StateFlowオブジェクトはMutableStateFlow()で作成します。この関数の引数 value
が初期値になります。
fun <T> MutableStateFlow(value: T): MutableStateFlow<T>
前回説明したSharedFlowは初期値を持たなかったため、Emitterが最初にemitするまでは値を持たない状態でした。対してStateFlowはオブジェクト作成時点で値を持つのが、SharedFlowと大きく異なる点です。
これは次のようなソースコードで確認できます。初期値 0
で作成したStateFlowに対して1秒ごとに値をセットしています。collectするとまず初めに初期値が取得できていることが、ログからわかります。(※たまたま開いていたアプリのViewModelのコードで実験したのでCoroutineScopeがviewModelScopeになっています。深い意味はないです。)
val stateFlow = MutableStateFlow(0)
var emitValue = 1
init {
viewModelScope.launch {
stateFlow.onEach {
Log.d("FlowSample", "collect $it")
}.collect()
}
viewModelScope.launch {
repeat(2) {
delay(1000)
Log.d("FlowSample", "emit $emitValue")
stateFlow.value = emitValue
emitValue++
}
}
}
連続した同じ値を無視する
内部実装のソースコードでは、最後にdistinctUntilChanged()
を設定していました。distinctUntilChanged()
を適用すると、同じ値が続けてemitされた場合に、そのemitを除外します。結果としてSubscriberが無駄な処理をしなくて済むようになります。
この動作は次のようなコードで確認できます。1
を2回続けてセットしていますが、collect側には2回目の 1
は検出されていません。
val stateFlow = MutableStateFlow(0)
init {
viewModelScope.launch {
stateFlow.onEach {
Log.d("FlowSample", "collect $it")
}.collect()
}
viewModelScope.launch {
listOf(1, 1, 2).forEach { emitValue ->
delay(1000)
Log.d("FlowSample", "emit $emitValue")
stateFlow.value = emitValue
}
}
}
StateFlowはvalueプロパティを持つ
StateFlowのもう一つの特長は、value
プロパティ経由で値のセット・ゲットができることです。
value
プロパティに値をセットするとemitと同等のことができます。しかも、suspend関数ではないのでCoroutineScopeから呼び出す必要はありません。SharedFlowのtryEmitのように失敗することもありません。この動作が実現できるのは、repeat = 1
かつonBufferOverflow = DROP_OLDEST
というパラメータを指定しているおかげです。emit処理が必ず即時完了するため、value
プロパティで簡便にセットすることができるのです。
また、value
プロパティから値をゲットすると、常にその時点の最新の値が取得できます。これも、repeat = 1
を指定していることに加えて、初期値を設定しているおかげで実現できる動作です。常に取り出せる値が存在するというのはSharedFlowには無い利点です。
ここまでの説明をまとめると、StateFlowは以下のような特徴を持っています。
- 初期値を持つ
- Subscriberの好きなタイミングで最新の値を取り出せる
- 値が変化した時だけSubscriberに伝わる
- valueプロパティで簡単に値をセット・ゲットできる
これらの特徴は、UIなどの状態変数を管理する際のメリットになります。UIはアプリを起動した時点で何らかの画面を表示しなければならないので、初期値をもつStateFlowは相性がいいです。またsuspend関数が不要なのでUIからアクセスしやすく、いつでも簡単に最新の値が取得できる点も便利です。さらに、値が変化した時だけUIを更新できるので表示更新の負荷を下げることができます。
一方、データレイヤーでは最初のデータ読み込みが完了するまではデータが存在しないことが一般的なので、初期値を持たないSharedFlowのほうが都合がよいです。また、読み込むデータの特性に合わせて、repeat
, extraBufferCapacity
, onBufferOverflow
の各パラメータを柔軟に設定できることもメリットになります。
もちろんこれらの使い方が絶対ではないですが、まずはこの方針で検討してみるのが設計の近道ではないかと思います。
AndroidアプリではSharedFlowとStateFlowを組み合わせて実装することがよくあります。RepositoryでSharedFlowを使ってデータを設定し、ViewModelでそれをStateFlowに変換し、UIに表示するというのが典型的なパターンです。
では、この動画サンプルの実装例を紹介します。画面には初期状態として”Loading”というテキストを表示し、その後1秒ごとにデータを更新します。
Repositoryは次のようなソースコードになります。
class FlowRepository {
val sharedFlow = MutableSharedFlow<Int>()
private var emitValue = 1
suspend fun load() {
sharedFlow.emit(emitValue)
emitValue++
}
}
このRepositoryクラスは、MutableSharedFlowのプロパティを持っています。load()
を呼び出すたびにInt型のデータを一つemitします。SharedFlowなので初期値はありません。load()
が呼ばれるまでは空っぽです。
ViewModelでStateFlowに変換
ViewModelは次のようなソースコードになります。
class FlowViewModel: ViewModel() {
private val repository = FlowRepository()
val stateFlow: StateFlow<String> = repository.sharedFlow.map { value ->
"Loaded: $value"
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = "Loading"
)
init {
viewModelScope.launch {
while (true) {
delay(1000)
repository.load()
}
}
}
}
ViewModelはUI表示用の状態を定義する必要があります。データ読み込み完了前も何らかの初期状態を表示しなければならないので、初期値を持つStateFlowが役に立ちます。実際のアプリでは、UIに表示する複数のデータをひとまとめにしたdata class
やsealed interface
などをStateFlowのデータとして扱うことが多いですが、ここでは単純なString型を扱うStateFlowを定義しています。
まず、RepositoryのSharedFlowに対してmap()
関数を呼び出して表示用の文字列に変換しています。map()
はFlowから収集される一つ一つのデータに対して処理を行い、データを別の形式に変換することができます。
次に、stateIn()
を使ってSharedFlowをStateFlowに変換します。StateFlowの初期値はここで設定します。scope
にはStateFlowを動かすためのCoroutineScopeを渡します。ViewModel内に実装する場合はviewModelScopeで問題ありません。started
にはStateFlowが動作する期間を指定します。WhileSubscribed
を指定すると、Subscriberが存在する間だけ動作します。「5000ミリ秒」の根拠はよくわからないのですが、この記事やNow in Androidなども同じ実装なのでとりあえず真似しておくのが無難そうです。
また、Repositoryのload()
を呼び出すのもViewModelの役割にしています。ここでは一定時間ごとに呼び出していますが、実際のアプリでは画面遷移やボタン操作をトリガにするなどいろいろバリエーションが考えられます。
Composable関数でStateに変換
UIをJetpack Composeで実装する場合は次のようなソースコードになります。
@Composable
fun FlowSample(viewModel: FlowViewModel = viewModel()) {
val text by viewModel.stateFlow.collectAsState()
Text(text = text) // Modifier等は省略
}
collectAsState()
を使うと、StateFlowをJetpack ComposeのStateに変換できます。これで、StateFlowの値が更新されるたびにRecompositionが動作して表示が更新されるようになります。ちなみに、collectAsState
ではなくcollectAsStateWithLifecycle
を使うほうがよいという情報も出てきていますが、この記事の執筆時点ではcollectAsState
を使うのが一般的だと思います。
以上です。