0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Kotlin】SharedFlow 徹底解説:One-shot イベント設計の完全ガイド

Posted at

1. SharedFlowとは?

SharedFlow複数の購読者に同時配信できるホットなフロー です。
StateFlow のように「最新値を保持」せず、
主に 一度だけ発火するイベント(One-shot Event) の通知に適しています。

一言で言うと:

SharedFlow = 「複数の購読者に同時に配るイベントストリーム」

UIイベント(例:Toast表示、Navigation遷移、Dialog表示)にぴったりです。


2. StateFlowとの違い

項目 StateFlow SharedFlow
目的 状態管理 イベント通知
最新値保持 ✅ する ❌ しない(replay=0)
初期値 必須 不要
更新方法 .value / .update .emit() / .tryEmit()
冪等性 状態更新のみに使う 単発の発火用
再購読時の挙動 最新値を即通知 過去イベントは再送されない
Compose再構築時 値が保持される 再通知されない(One-shot)

3. 基本構文

// MutableSharedFlowを作成
private val _events = MutableSharedFlow<String>(
    replay = 0,              // 過去のイベントを再送しない
    extraBufferCapacity = 1   // バッファ1件(tryEmit成功しやすく)
)
val events: SharedFlow<String> = _events

// 発火
_events.tryEmit("Hello")
// 購読
viewModel.events.collect { msg ->
    println("イベントを受信: $msg")
}

4. One-shot イベント設計パターン(ViewModel)

SharedFlow の本領発揮は「UI単発イベント」にあります。

例:ログイン成功時にナビゲーション+Toast表示

sealed interface UiEvent {
    data object NavigateHome : UiEvent
    data class ShowToast(val message: String) : UiEvent
}
@HiltViewModel
class LoginViewModel @Inject constructor(
    private val loginUseCase: LoginUseCase
) : ViewModel() {

    private val _events = MutableSharedFlow<UiEvent>(
        replay = 0,
        extraBufferCapacity = 1
    )
    val events: SharedFlow<UiEvent> = _events

    fun onLoginClicked(id: String, pass: String) = viewModelScope.launch {
        runCatching { loginUseCase(id, pass) }
            .onSuccess { _events.tryEmit(UiEvent.NavigateHome) }
            .onFailure { _events.tryEmit(UiEvent.ShowToast("ログイン失敗")) }
    }
}

5. Compose側での安全な購読方法

LaunchedEffect + repeatOnLifecycle を組み合わせて
再組成・画面回転でも多重購読を避ける のがポイント。

@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 { event ->
                when (event) {
                    UiEvent.NavigateHome -> onNavigateHome()
                    is UiEvent.ShowToast -> showToast(event.message)
                }
            }
        }
    }
}

ポイント

  • LaunchedEffect(Unit) により Compose 再構築でも重複しない
  • repeatOnLifecycle により STOPPED 時に自動で購読解除
  • replay=0 により再購読時に古いイベントは再発火しない

6. SharedFlow のパラメータ設計

replay

  • 新しい購読者に「過去のイベント」を何件再送するか
  • UIイベントでは 必ず 0(再発火防止)

extraBufferCapacity

  • 一時バッファサイズ。非同期で tryEmit() したいときに便利
  • UIイベントなら 1〜2 が推奨

onBufferOverflow

  • 溢れたときの挙動 (SUSPEND, DROP_OLDEST, DROP_LATEST)
  • デフォルトは SUSPEND
  • UIイベントなら DROP_OLDEST が安全
val _event = MutableSharedFlow<Event>(
    replay = 0,
    extraBufferCapacity = 1,
    onBufferOverflow = BufferOverflow.DROP_OLDEST
)

7. SharedFlow vs Channel(どちらを使うべき?)

特徴 SharedFlow Channel
複数購読者 複数に同時配布 単一のみ
イベント確実性 バッファ次第でロス可 受信されるまで保持
Flowとの統合 そのまま collect receiveAsFlow()
Backpressure制御 柔軟 (BufferOverflow) 明確 (send/receive)
UIイベント 推奨 小規模なら可
テスト容易性 高い 高い(ただし単一受信)

目安

  • 「複数画面や複数購読者にイベントを配信したい」→ SharedFlow
  • 「UI一箇所で確実に1回だけ受けたい」→ Channel + receiveAsFlow()

8. Clean Architecture での使い分け

推奨設計 説明
UseCase層 Flow<Result> 業務結果をFlowで返す
ViewModel層 StateFlow(状態) + SharedFlow(イベント) 状態とイベントを分離
UI層 collectAsState() + collect() 状態とイベントを購読

9. よくある落とし穴と対策

失敗例 問題点 対策
StateFlowでイベントを送る 再購読で再実行(ナビゲーション多重発火) SharedFlowを使う
replay > 0 再購読で過去イベント再発火 replay=0に設定
collectをUIの@Composable内で直接呼ぶ 再組成で多重購読 LaunchedEffect + repeatOnLifecycle
emit()がサスペンドして遅延 バッファ不足 extraBufferCapacity=1 + tryEmit()
同期的にイベントをテストできない 非同期競合 runTest + advanceUntilIdle()

10. SharedFlow のテスト例

@OptIn(ExperimentalCoroutinesApi::class)
class LoginViewModelTest {

    @get:Rule
    val main = MainDispatcherRule()

    @Test
    fun emits_navigateHome_event() = runTest {
        val vm = LoginViewModel(loginUseCase = { }) // 成功と仮定
        val results = mutableListOf<UiEvent>()
        val job = launch { vm.events.take(1).toList(results) }

        vm.onLoginClicked("a", "b")
        advanceUntilIdle()

        assertTrue(results.first() is UiEvent.NavigateHome)
        job.cancel()
    }
}

11. 応用:状態 × イベント のハイブリッド設計

多くのアプリでは StateFlowSharedFlow を組み合わせて使います。

data class UiState(
    val loading: Boolean = false,
    val user: User? = null
)

@HiltViewModel
class ExampleViewModel @Inject constructor(
    private val repo: UserRepository
) : ViewModel() {
    private val _uiState = MutableStateFlow(UiState())
    val uiState: StateFlow<UiState> = _uiState

    private val _events = MutableSharedFlow<UiEvent>()
    val events: SharedFlow<UiEvent> = _events

    fun fetch() = viewModelScope.launch {
        _uiState.update { it.copy(loading = true) }
        runCatching { repo.loadUser() }
            .onSuccess { user ->
                _uiState.value = UiState(loading = false, user = user)
                _events.tryEmit(UiEvent.ShowToast("読み込み成功"))
            }
            .onFailure {
                _uiState.update { it.copy(loading = false) }
                _events.tryEmit(UiEvent.ShowToast("失敗しました"))
            }
    }
}

まとめ

用途 クラス 特徴
UI状態 StateFlow 常に最新値を保持し、UI再生成でも再表示
One-shotイベント SharedFlow(replay=0) 消費型の単発イベント
確実な1回配信 Channel + receiveAsFlow() 受信完了まで保持
複数購読者に配信 SharedFlow 同報・ブロードキャスト

状態は StateFlow、イベントは SharedFlow に分離
これが Kotlin × Compose × MVVM の黄金設計。

SharedFlow は「一度きりの出来事」を安全・明確に扱うための標準的な手法です。
正しく使えば、ナビゲーションの多重実行・Toastの再発火 といった
UIの“バグっぽい挙動”をきれいに防止できます。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?