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】stateIn() と shareIn() の違いを実戦で比較する

Posted at

はじめに

  • 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)
                }
            }
        }
    }
}
  • StateFlowcollectAsState()(再コンポーズ安全)
  • SharedFlowLaunchedEffect + repeatOnLifecycle(多重購読を防止)

5. “実行コスト”の違いと設計の勘所

観点 stateIn shareIn
上流の実行回数 scope内で1回(購読数に依存しない) 同左
メモリ 最新値1件を保持(必須) デフォルトは保持なしreplay>0でリングバッファ)
初期値 必要 不要
再描画/再購読 即時に最新値が届く 届かないreplayを増やすと届く=Stickyに注意)
代表用途 UI状態 イベント/通知

負荷観点:どちらも「共有」するので、複数collectでも上流は1本。
UX観点:初期表示の滑らかさstateIn が圧倒的に有利。


6. よくある落とし穴(アンチパターン)

  1. イベントを stateIn で扱う
    → 画面回転でイベント再発火(多重ナビゲーション)
    対策:イベントは shareIn(replay=0) or Channel
  2. shareInreplay>0 を安易に使う
    → 新規購読時に過去イベントが“Sticky”発火
    対策:UIイベントは原則 replay=0
  3. Eagerly を乱用
    → 画面が見えていなくても上流が動き続ける
    対策:基本は WhileSubscribed(5_000)
  4. 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() で順序を担保しているか

まとめ

  • 状態 = stateInStateFlow:初期値必須、最新を常に即返し、再描画に強い
  • イベント = shareInSharedFlowreplay=0 が原則、複数購読者へ同報
  • WhileSubscribed で上流を省エネ運転
  • UIはライフサイクル対応で購読し、多重collect事故を防ぐ

これだけ守れば、“粘つくイベント/空白の初期描画/上流回りっぱなし” といった現場あるあるを避けられます。

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?