【Jetpack Compose】もう冗長なコードは不要! collectAsStateWithLifecycle で Flow を安全に収集する


Jetpack Compose の開発において、ViewModel が持つ StateFlow などの Flow を UI 側で購読して State として保持することはよくありますよね。

以前はライフサイクルを安全に考慮しようとすると LaunchedEffect や repeatOnLifecycle を組み合わせる必要がありましたが、現在は専用の拡張関数である collectAsStateWithLifecycle を使うことで、驚くほどシンプルに記述できるようになりました。

今回は、従来の書き方と新しくなったスマートな書き方を比較しながら、そのメリットを見ていきましょう!

 

🧑🏻‍💻 1. 従来の手法:LaunchedEffect + repeatOnLifecycle

まずは、ライフサイクルを安全に考慮して Flow を収集しようとした場合の従来のコードを見てみましょう。


@Composable
fun MyScreen(viewModel: MyViewModel = viewModel()) {
    val lifecycleOwner = LocalLifecycleOwner.current
    var state by remember { mutableStateOf<UiState>(UiState.Loading) }

    LaunchedEffect(viewModel.uiState, lifecycleOwner) {
        lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
            viewModel.uiState.collect { value ->
                state = value
            }
        }
    }
}

・ ボイラープレートが多い: ライフサイクルの取得、LaunchedEffect の設定、repeatOnLifecycle のネストなど、記述量がどうしても多くなってしまいます。

・ 可読性の低下: 本来やりたいことは「StateFlow を Compose の State に変換すること」なのに、コードの大部分がライフサイクルの管理ボイラープレートになってしまいます。

 

🧑🏻‍💻 2. 新しい手法:collectAsStateWithLifecycle

AndroidX Lifecycle 2.6.0(および Compose Integration)以降では、このボイラープレートを丸ごと解決してくれる collectAsStateWithLifecycle() が提供されています。

先ほどのコードをこれに置き換えると、以下のようになります。


@Composable
fun MyScreen(viewModel: MyViewModel = viewModel()) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()
}

たった 1行 です。

冗長だった LaunchedEffect や repeatOnLifecycle の記述が一切不要になり、コードが劇的にスッキリしました。

 

🧑🏻‍💻 3. collectAsStateWithLifecycle を使うべき理由

安全なライフサイクル管理
アプリがバックグラウンド(STARTED 状態を下回る時)に移行した際、自動的に Flow の収集を一時停止し、メモリやCPUリソースの無駄な消費を防ぎます。

コードの可読性と保守性向上
定型文(ボイラープレート)が消えるため、UI のロジックに集中しやすくなります。

公式が推奨するベストプラクティス
Jetpack Compose における標準的なデータ収集方法として公式ドキュメントでも推奨されています。

 

🧑🏻‍💻 まとめ

collectAsStateWithLifecycle を導入するだけで、安全性を担保しつつ、コードを圧倒的にクリーンに保つことができます。

まだ従来の LaunchedEffect や collectAsState() をそのまま使っている箇所があれば、ぜひこの機会に collectAsStateWithLifecycle() へ置き換えてみてください!


【モバイルSuica】新機能「teppay(テッペイ)」は不要?電車に乗るだけならスルーでOKな理由

2026年10月からモバイルSuicaで新たにスタートしたコード決済機能「teppay(テッペイ)」。

「Suicaの中に新しい決済機能が増えたみたいだけど、使ったほうがいいの?」
「設定とか面倒くさそうだけど、使わないと損する?」

そんな疑問を持っている方も多いのではないでしょうか。

結論から言うと、「電車やバスに乗るのがメイン」「普段の買い物はクレカや既存のPayで十分」という方は、teppayを使う必要は一切ありません。

この記事では、なぜ「普通の人」はteppayを使わなくて大丈夫なのか、その理由を分かりやすく解説します。

 

🤔 理由1:改札を通るときは結局「Suica(タッチ)」だから

teppayはPayPayや楽天ペイのような「バーコード・QRコード決済」です。

お店での買い物や送金には使えますが、電車の改札機をピッとタッチして通る機能は、これまで通りの「Suica」の役割です。

teppayを設定したからといって改札が便利になるわけではないため、電車移動がメインの人にはメリットがほぼありません。

 

🤔 理由2:普段のチャージなら「2万円上限」で十分

teppayの大きな特徴のひとつに「チャージ上限が最大30万円まで増える」という点があります。

ですが、毎日の電車代やちょっとしたコンビニの買い物程度なら、これまでのSuicaの上限である「2万円」があれば十分に足ります。

「30万円もスマホの残高に入れて持ち歩きたくない…」という人にとっても、無理に導入する理由は見当たりません。

 

🤔 理由3:画面の切り替えや操作が増えて面倒になる

これまでモバイルSuicaは、アプリを開かなくてもスマホをかざすだけで使えました。

しかしteppayで支払いをしようとすると、アプリを開いて「Suica画面」から「teppay画面」へ切り替え、バーコードを提示するといった手順が必要になります。

「かざすだけ」のシンプルさに慣れている人からすると、かえって手間が増えてしまいます。

 

🤔 理由4:ポイ活目的なら他のPayやクレカでOK

teppayを使うと「teppayポイント」という新しいポイントが貯まりますが、すでにPayPay、楽天ペイ、d払いなどの既存コード決済を使っている人や、クレジットカードのポイントを貯めている人にとっては、管理するポイントが増えて煩雑になるだけです。

無理に新しいポイントプログラムに乗り換える必要はありません。

 

🤔 まとめ:これまで通り「ピッとタッチ」で快適に使おう!

teppayは「高額な定期券を残高で買いたい人」や「Suicaアプリ内で友達と送金し合いたい人」などに向けた新機能です。

・ 電車やバスに乗るのがメイン

・ チャージは2万円以下で問題ない

・ 支払いはかざすだけの電子マネーが良い

これらに当てはまる方は、teppayの機能を気にせず、これまで通りのモバイルSuicaの使い方を続けて全く問題ありません。

便利な機能が増えるのは良いことですが、自分に不要な機能は無理に使わず、シンプルで快適なキャッシュレス生活を送りましょう!


uiState を宣言的に組み立てる Flow オペレーター ベスト10

みんなはいくつ使っていますか。

 

🧑🏻‍💻 1. stateIn: Flow を StateFlow に変換する

宣言的な uiState の締めくくりです。WhileSubscribed(5_000) にしておくと、画面回転では再購読せず、バックグラウンドでは上流を止められます。


val uiState: StateFlow<UiState> = flow
    .stateIn(
        scope = viewModelScope,
        started = SharingStarted.WhileSubscribed(5_000),
        initialValue = UiState.Loading
    )

 

🧑🏻‍💻 2. combine: 複数ソースを 1 つの UiState に合成する

MutableStateFlow を何本も手で更新する代わりに、「ソースの合成結果 = UiState」と宣言できます。


combine(repo.items, query, filter) { items, q, f ->
    UiState.Success(items.filter { it.matches(q, f) })
}

 

🧑🏻‍💻 3. map: ドメインモデルから UI モデルへの変換

整形や表示用の変換はここに集約します。


repo.user.map { it.toUiModel() }

 

🧑🏻‍💻 4. flatMapLatest: 入力の変化で上流を切り替える

検索クエリや選択 ID が変わると、前のリクエストを自動キャンセルして新しい Flow に切り替えます。


query.flatMapLatest { q -> repo.search(q) }

 

🧑🏻‍💻 5. catch: エラーを UiState に変換する

例外を UI の状態として扱えます。上流のエラーだけを捕捉する点が try/catch との違いです。


.catch { emit(UiState.Error(it.message)) }

 

🧑🏻‍💻 6. onStart: Loading の初期値を流す

購読開始時に Loading を流す定番の書き方です。


repo.items
    .map<_, UiState> { UiState.Success(it) }
    .onStart { emit(UiState.Loading) }

 

🧑🏻‍💻 7. distinctUntilChanged: 無駄な再描画を防ぐ

StateFlow 自体も同値を弾きますが、中間の Flow で重い処理の前に挟むと効果的です。


query.distinctUntilChanged()
    .flatMapLatest { repo.search(it) }

 

🧑🏻‍💻 8. debounce: 入力の連打を間引く

検索ボックスなどで使います。debounce は同値の弾き込みはしないため、distinctUntilChanged と併用します。


query.debounce(300)
    .distinctUntilChanged()

 

🧑🏻‍💻 9. scan / runningFold: 状態を畳み込む

ページング風の追加読み込みや、イベントから状態を積み上げる MVI 的な設計で使います。


events.scan(UiState()) { state, event -> reduce(state, event) }

 

🧑🏻‍💻 10. flowOn: 上流の実行スレッドを指定する

重い変換や IO を Main から外します。上流にだけ効く点に注意してください。


repo.items
    .map { heavyTransform(it) }
    .flowOn(Dispatchers.Default)

 

🧑🏻‍💻 よくある組み合わせ例


class SearchViewModel(repo: SearchRepository) : ViewModel() {
    private val query = MutableStateFlow("")
    private val filter = MutableStateFlow(Filter.All)

    val uiState: StateFlow<UiState> = combine(
        query.debounce(300).distinctUntilChanged()
            .flatMapLatest { repo.search(it) },
        filter
    ) { results, f ->
        UiState.Success(results.filter(f::accepts)) as UiState
    }
        .onStart { 
            emit(UiState.Loading) 
        }
        .catch { 
            emit(UiState.Error(it.message)) 
        }
        .flowOn(Dispatchers.Default)
        .stateIn(
            viewModelScope, 
            SharingStarted.WhileSubscribed(5_000), 
            UiState.Loading
        )

    fun onQueryChange(q: String) { 
        query.value = q 
    }
}

 

🧑🏻‍💻 参考


UI Action は UiState を直接変更しない

UiState は入力から導出する

UI = f(State) は、宣言的 UI の基本的な考え方です。

では、UIからのActionによってStateをどう変えるのでしょうか?

ルールはシンプルです。

UI Action から public な UiState を直接変更しない。

UI Action は ViewModel が持つ private な入力を更新します。

そして、UiState はその入力から導出します。


UI Action
  ↓
Private Flow
  ↓
UiState
  ↓
UI

例えば、検索画面なら次のように書けます。


private val _query = MutableStateFlow("")

val uiState = combine(
    _query,
    repository.items
) { query, items ->
    UiState(
        query = query,
        items = items
    )
}

fun onQueryChanged(query: String) {
    _query.value = query
}

次のように UiState を直接変更することもできます。


fun onQueryChanged(query: String) {
    _uiState.update {
        it.copy(query = query)
    }
}

この場合、UI Action 自身が画面の State を管理することになります。

一方、private な入力を更新するだけなら、責務が明確になります。


UI Action
    ↓
入力を変更する
    ↓
UiState を導出する
    ↓
UI を描画する

つまり、


UiState = f(Inputs)

であり、


UI Action → UiState を変更する

ではありません。

UI Action の役割は、「何が起きたか」を ViewModel に伝えることです。

そして、「その結果どのようなUiStateになるか」は Flow の依存関係によって決まります。

UI Action は UiState を変更する命令ではなく、入力を変更するトリガーである。

 

🧑🏻‍💻 参考


UiState で UiEvent を扱う3つのパターン

「AndroidのUI設計って、イベント駆動から状態駆動に完全にシフトしたよね」という話の続きです。

ViewModelから画面側(Compose)へ単発のイベントを伝えるとき、SharedFlowやChannelを使わずにUiStateだけで閉じた設計にしようとすると、だいたい次の3つのアプローチに行き着きます。

実務での使い分けを踏まえて整理してみました。

 

🤔 1. Single Event (nullable)

UiState の中に event: UiEvent? をひとつ持たせる一番シンプルで直感的なパターンです。


sealed interface UiEvent {
    data class ShowSnackbar(val message: String) : UiEvent
    data class NavigateToDetail(val id: String) : UiEvent
    data class ShowDialog(val type: DialogType) : UiEvent
}

data class UiState(
    val isLoading: Boolean = false,
    val items: List<Item> = emptyList(),
    val event: UiEvent? = null,
)

// ViewModel
private val _uiState = MutableStateFlow(UiState())
val uiState: StateFlow<UiState> = _uiState.asStateFlow()

fun onSaveClicked() {
    _uiState.update { it.copy(event = UiEvent.ShowSnackbar("保存しました")) }
}

fun onEventConsumed() {
    _uiState.update { it.copy(event = null) }
}

// UI (Compose)
LaunchedEffect(uiState.event) {
    when (val event = uiState.event) {
        is UiEvent.ShowSnackbar -> {
            snackbarHostState.showSnackbar(event.message)
            viewModel.onEventConsumed()
        }
        is UiEvent.NavigateToDetail -> {
            navController.navigate("detail/${event.id}")
            viewModel.onEventConsumed()
        }
        is UiEvent.ShowDialog -> viewModel.onEventConsumed()
        null -> Unit
    }
}

メリット
・ 構造がとにかくシンプル。フィールドも1つ、消費用コールの記述も1つで済む。
・ StateFlow だけで完結するので、Channel 管理の煩わしさやリークのリスクを回避できる。
・ uiState.value.event をアサートするだけで単体テストが書ける。

デメリット
・ ほぼ同時に複数のイベントが発生すると、最後のやつで上書きされて途中のイベントが消える。
・ 全く同じ内容のイベントが連続すると、data class の同値判定(equals)に引っかかって LaunchedEffect が再発火しない場合がある。

 

🤔 2. Queue + drop(1)

イベントをQueue(List)で保持し、処理が終わった順に先頭を削っていく(drop(1))パターンです。


data class UiState(
    val isLoading: Boolean = false,
    val items: List<Item> = emptyList(),
    val eventQueue: List<UiEvent> = emptyList(),
)

// ViewModel
private fun sendEvent(event: UiEvent) {
    _uiState.update { it.copy(eventQueue = it.eventQueue + event) }
}

fun onSaveClicked() {
    sendEvent(UiEvent.ShowSnackbar("保存しました"))
    sendEvent(UiEvent.NavigateToDetail(savedId))
}

fun onEventConsumed() {
    _uiState.update { it.copy(eventQueue = it.eventQueue.drop(1)) }
}

// UI (Compose)
LaunchedEffect(uiState.eventQueue) {
    val event = uiState.eventQueue.firstOrNull() ?: return@LaunchedEffect
    when (event) {
        is UiEvent.ShowSnackbar -> snackbarHostState.showSnackbar(event.message)
        is UiEvent.NavigateToDetail -> navController.navigate("detail/${event.id}")
        is UiEvent.ShowDialog -> { /* ... */ }
    }
    viewModel.onEventConsumed()
}

メリット
・ 複数イベントの連打や同時発火に対応でき、発生した順番通りに処理できる。
・ when 式の型チェック(exhaustive)をそのまま活かせる。

デメリット
・ UI側で onEventConsumed() を呼び忘れるとキューの先頭が残り続け、後続のイベントがすべて詰まる。
・ 単純に drop(1) しているだけなので、「今処理したイベント」と「実際に削除されたイベント」がずれるリスクが残る(同一内容のイベントが連続した場合など)。

 

🤔 3. Queue + ID matching

キュー構造に加えて、イベントごとに一意のID(System.nanoTime() など)を付与し、ID指定でピンポイントに消費させるパターンです。


data class QueuedEvent(
    val id: Long = System.nanoTime(),
    val event: UiEvent,
)

data class UiState(
    val isLoading: Boolean = false,
    val items: List<Item> = emptyList(),
    val eventQueue: List<QueuedEvent> = emptyList(),
)

// ViewModel
private fun sendEvent(event: UiEvent) {
    _uiState.update { it.copy(eventQueue = it.eventQueue + QueuedEvent(event = event)) }
}

fun onSaveClicked() {
    sendEvent(UiEvent.ShowSnackbar("保存しました"))
    sendEvent(UiEvent.NavigateToDetail(savedId))
}

fun onEventConsumed(id: Long) {
    _uiState.update { state ->
        state.copy(eventQueue = state.eventQueue.filterNot { it.id == id })
    }
}

// UI (Compose)
LaunchedEffect(uiState.eventQueue.firstOrNull()?.id) {
    val queued = uiState.eventQueue.firstOrNull() ?: return@LaunchedEffect
    when (val event = queued.event) {
        is UiEvent.ShowSnackbar -> snackbarHostState.showSnackbar(event.message)
        is UiEvent.NavigateToDetail -> navController.navigate("detail/${event.id}")
        is UiEvent.ShowDialog -> { /* ... */ }
    }
    viewModel.onEventConsumed(queued.id)
}

メリット
・ 内容が全く同じイベントが並んでも、ID指定で削除するため誤削除が発生しない。
・ LaunchedEffect のキーにIDを渡せるため、二重処理や再発火漏れを極力防げる。

デメリット
・ ラッパークラスやID生成、filterNot の処理など、ボイラープレートコードが増える。
・ 通常のトースト表示や画面遷移程度なら、正味オーバースペック。

 

🤔 比較

 

🤔 現場での判断軸

プロダクト開発では、最初から凝りすぎず段階的に育てるアプローチが取りやすいです。

1, まずは「Single Event」で組む

8割方の画面はこれで十分事足ります。コードも簡潔で可読性が高く、チーム内での認知負荷も低く抑えられます。

2.「保存成功スナックバーを出しつつ元の画面に戻る」など、同時発生が要件になったら「Queue」へ移行

Single Eventの限界(上書き問題)が見えたタイミングでキュー構造にリファクタリングします。

3. 二重処理や損失が致命傷になる重要フロー(決済やアカウント削除など)のみ「ID matching」を入れる

堅牢性が必要な局所に絞ってピンポイントで採用するのが、過剰な抽象化(オーバーエンジニアリング)を防ぐコツです。

 

🤔 参考