はじめに
-
UIの“状態”を1つに集約して常に最新を保持したい →
stateIn()(=StateFlow化) -
イベント/通知を複数購読者へブロードキャストしたい →
shareIn()(=SharedFlow化) -
迷ったら:「状態は
stateIn」「イベントはshareIn」で分離する
1. 何をしてくれる関数?
| 関数 | 返すもの | 典型用途 | コアの違い |
|---|---|---|---|
stateIn(scope, started, initial) |
StateFlow<T> |
画面の UI 状態、ローディング/エラー、フォーム値など | 最新値を1つ保持(初期値必須) |
shareIn(scope, started, replay) |
SharedFlow<T> |
SnackBar/Navigate/Dialog、複数購読者へのイベント配信 | 最新値は保持しない(replayで件数指定) |
共通点:どちらも コールドFlowを“ホット化” し、scope 内で上流を1回だけ実行・共有します。
2. 「started」の挙動(ここが肝)
stateIn/shareIn どちらにも SharingStarted を渡します:
-
Eagerly:scope開始と同時に共有開始(購読0でも動く) -
Lazily:最初の購読が来たら開始(購読0で停止) -
WhileSubscribed(stopTimeoutMillis):購読が0になったら 一定時間後に停止(実戦の定番)
実務推奨:
SharingStarted.WhileSubscribed(5_000)
→ 画面が一瞬非アクティブでも上流を即停止させず、再表示で暖機が効く
3. 実戦シナリオで比較
3.1 UI状態:ユーザープロフィール表示(stateIn)
@HiltViewModel
class ProfileViewModel @Inject constructor(
repo: UserRepository
): ViewModel() {
// Flow<User> -> StateFlow<UiState> に昇格
val uiState: StateFlow<UiState> = repo.userStream() // Flow<User>
.map<User, UiState> { UiState.Success(it) }
.onStart { emit(UiState.Loading) }
.catch { emit(UiState.Error(it.message ?: "unknown")) }
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
initialValue = UiState.Loading // 初期値が必須
)
}
ポイント
- 最新1件を保持するので、Compose再構築・画面回転で即描画
- 初期値があるから初回の空白を回避
- 上流は購読0で止まる(WhileSubscribed)
3.2 One-shotイベント:ログイン結果通知(shareIn)
@HiltViewModel
class LoginViewModel @Inject constructor(
private val login: LoginUseCase
) : ViewModel() {
private val _events = MutableSharedFlow<LoginEvent>(
replay = 0, // Stickyを避ける
extraBufferCapacity = 1
)
val events: SharedFlow<LoginEvent> = _events
// 参考:UseCaseがFlow<Result>を返すケースでも...
val resultEvents: SharedFlow<LoginEvent> = login.resultFlow()
.map { if (it.isSuccess) LoginEvent.NavigateHome else LoginEvent.Toast("失敗") }
.shareIn( // <- FlowをSharedFlow化
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5_000),
replay = 0 // One-shotなら0が原則
)
fun onSubmit(id: String, pw: String) = viewModelScope.launch {
runCatching { login(id, pw) }
.onSuccess { _events.tryEmit(LoginEvent.NavigateHome) }
.onFailure { _events.tryEmit(LoginEvent.Toast("ログイン失敗")) }
}
}
ポイント
-
再購読で過去イベントを再発火させないため
replay=0 - 複数購読者(画面/Service/他コンポーネント)に同時通知可能
- 「絶対1箇所にだけ届けたい」なら
Channel + receiveAsFlow()も選択肢
4. Compose 側の安全な購読
@Composable
fun ProfileScreen(vm: ProfileViewModel = hiltViewModel()) {
val state by vm.uiState.collectAsState() // StateFlowはこれでOK
// ... when(state) { ... }
}
@Composable
fun LoginScreen(
vm: LoginViewModel = hiltViewModel(),
onNavigateHome: () -> Unit,
showToast: (String) -> Unit
) {
val lifecycle = LocalLifecycleOwner.current.lifecycle
LaunchedEffect(Unit) {
lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) {
vm.events.collect { e ->
when (e) {
LoginEvent.NavigateHome -> onNavigateHome()
is LoginEvent.Toast -> showToast(e.message)
}
}
}
}
}
-
StateFlow→collectAsState()(再コンポーズ安全) -
SharedFlow→LaunchedEffect + repeatOnLifecycle(多重購読を防止)
5. “実行コスト”の違いと設計の勘所
| 観点 | stateIn |
shareIn |
|---|---|---|
| 上流の実行回数 | scope内で1回(購読数に依存しない) | 同左 |
| メモリ | 最新値1件を保持(必須) | デフォルトは保持なし(replay>0でリングバッファ) |
| 初期値 | 必要 | 不要 |
| 再描画/再購読 | 即時に最新値が届く |
届かない(replayを増やすと届く=Stickyに注意) |
| 代表用途 | UI状態 | イベント/通知 |
負荷観点:どちらも「共有」するので、複数collectでも上流は1本。
UX観点:初期表示の滑らかさは stateIn が圧倒的に有利。
6. よくある落とし穴(アンチパターン)
-
イベントを
stateInで扱う
→ 画面回転でイベント再発火(多重ナビゲーション)
対策:イベントはshareIn(replay=0)orChannel -
shareInでreplay>0を安易に使う
→ 新規購読時に過去イベントが“Sticky”発火
対策:UIイベントは原則replay=0 -
Eagerlyを乱用
→ 画面が見えていなくても上流が動き続ける
対策:基本はWhileSubscribed(5_000) -
UIで
collectを直接呼んで多重購読
対策:LaunchedEffect + repeatOnLifecycle
7. テスト最小例(差が出るところ)
@OptIn(ExperimentalCoroutinesApi::class)
class FlowShareTest {
@get:Rule
val main = MainDispatcherRule()
@Test
fun stateIn_keeps_latest_value() = runTest {
val src = MutableSharedFlow<Int>(replay = 0)
val state = src
.stateIn(this, SharingStarted.Eagerly, initialValue = -1)
src.emit(1)
src.emit(2)
// 新規購読でも最新2が即取得できる
val latest = state.value
assertEquals(2, latest)
}
@Test
fun shareIn_does_not_reemit_past_when_replay0() = runTest {
val src = MutableSharedFlow<Int>(replay = 0)
val shared = src.shareIn(this, SharingStarted.Eagerly, replay = 0)
src.emit(1); src.emit(2)
val received = mutableListOf<Int>()
val job = launch { shared.take(1).toList(received) } // ここから先の1件だけ
src.emit(3)
job.join()
assertEquals(listOf(3), received) // 1,2 は来ない
}
}
8. 設計チェックリスト
-
UI状態は
stateIn(WhileSubscribed, initial)でStateFlow化したか -
イベントは
shareIn(..., replay=0)かChannelに分離したか - Compose側は
collectAsState()/LaunchedEffect + repeatOnLifecycleを使っているか -
Eagerlyをむやみに使っていないか - テストで
runTest+advanceUntilIdle()で順序を担保しているか
まとめ
-
状態 =
stateIn(StateFlow):初期値必須、最新を常に即返し、再描画に強い -
イベント =
shareIn(SharedFlow):replay=0が原則、複数購読者へ同報 -
WhileSubscribedで上流を省エネ運転 - UIはライフサイクル対応で購読し、多重collect事故を防ぐ
これだけ守れば、“粘つくイベント/空白の初期描画/上流回りっぱなし” といった現場あるあるを避けられます。