Java 脳のまま Jetpack Compose を書いてはいけない (2/2)

👉 Java 脳のまま Jetpack Compose を書いてはいけない (1/2) の続きです。

 

🧑🏻‍💻 画面遷移を if 文だけで管理してはいけない理由

Composition から外れると remember の状態はどうなるのか

小さな UI なら、条件分岐で表示を切り替える方法も有効です。


@Composable
fun App() {
    var showDetail by remember {
        mutableStateOf(false)
    }

    if (showDetail) {
        DetailScreen(
            onBack = { showDetail = false }
        )
    } else {
        HomeScreen(
            onOpenDetail = { showDetail = true }
        )
    }
}

しかし、HomeScreen が Composition から外れると、その内部の remember で保持していた状態は通常失われます。

例えば、ホーム画面でスクロールし、詳細画面へ移動して戻ると、ホーム画面を再構築したときにスクロール位置が初期化されることがあります。

この挙動自体がバグというわけではありません。単純な条件分岐は、複数画面の履歴や状態を管理する仕組みではないからです。

画面履歴、戻る操作、状態の保存・復元が必要になったら、Navigation の導入を検討すべきです。

navBackStack は単なる画面履歴ではない

Navigation 3 では、バックスタックをアプリ側が所有し、画面を識別するキーのリストとして扱います。NavDisplay はその状態を監視し、対応する画面を表示します。

👉 基本を理解して実装する  |  App architecture  |  Android Developers

概念的には、次のような流れです。


ユーザー操作
    |
    v
Back Stack の変更
    |
    v
NavDisplay が状態を反映
    |
    v
表示する画面が変わる

Navigation 3 の基本的な例を見てみましょう。


@Serializable
data object Home : NavKey

@Serializable
data class Detail(
    val itemId: String
) : NavKey

画面を表すキーを定義します。実際の画面コンテンツそのものではなく、画面を特定するための情報です。

次にバックスタックを作成します。


@Composable
fun AppNavigation() {
    val backStack = rememberNavBackStack(Home)

    NavDisplay(
        backStack = backStack,
        onBack = {
            backStack.removeLastOrNull()
        },
        entryProvider = entryProvider {
            entry<Home> {
                HomeScreen(
                    onOpenDetail = { id ->
                        backStack.add(Detail(id))
                    }
                )
            }

            entry<Detail> { key ->
                DetailScreen(itemId = key.itemId)
            }
        }
    )
}

この例では、ホーム画面から詳細画面に移動すると、バックスタックに Detail が追加されます。戻る操作では最後のエントリーが削除されます。

コードのポイントは、画面の表示を手動で切り替えていないことです。バックスタックというナビゲーション状態を変更し、その状態を Navigation が UI に反映します。

なお、この例は Navigation 3 の API を使用しています。必要な依存関係やシリアライザーの設定は、プロジェクトの採用バージョンに合わせて追加してください。

バックスタックだけで状態がすべて保持されるわけではない

ここは誤解しやすいポイントです。

バックスタックに画面のキーが残っていることと、画面内部の状態が自動的にすべて復元されることは同じではありません。

Navigation 3 で状態を適切に保持するには、必要に応じて保存可能なバックスタック、Saveable State、ViewModel のスコープなどを構成します。公式ドキュメントでも、rememberNavBackStack やエントリー用のデコレーターを利用した状態管理が説明されています。

👉 Save and manage navigation state  |  App architecture  |  Android Developers

例えば、画面エントリーに ViewModel のスコープを与える場合は、対応する Navigation 3 の ViewModel 統合を利用します。


NavDisplay(
    backStack = backStack,
    onBack = { backStack.removeLastOrNull() },
    entryProvider = entryProvider {
        entry<Home> {
            HomeScreen()
        }

        entry<Detail> { key ->
            DetailScreen(itemId = key.itemId)
        }
    },
    entryDecorators = listOf(
        rememberSaveableStateHolderNavEntryDecorator(),
        rememberViewModelStoreNavEntryDecorator()
    )
)

これはエントリーごとの Saveable State と ViewModel の管理を組み込む例です。利用する依存関係と API のシグネチャは、採用バージョンの公式ドキュメントで確認してください。

Navigation 3 が重要なのは、単に戻るボタンを実装できるからではありません。画面履歴を明示的な State として表現し、画面の表示や状態管理をそのモデルと連携させられることにあります。

 

🧑🏻‍💻 State・Runtime・Navigation を一つの設計モデルで考える

ここまでの話は、別々のテクニックに見えるかもしれません。しかし、共通する考え方があります。


アプリの状態
    |
    +----------------------+
    |                      |
    v                      v
画面の UI State        Navigation State
    |                      |
    v                      v
Compose Runtime        Back Stack
    |                      |
    v                      v
Composition          表示する画面の決定
    |                      |
    +-----------+----------+
                |
                v
          ユーザーに見える UI

この図は責務の関係を概念的に示したものです。実際には Navigation の画面表示も Compose Runtime 上の Composition として実行されます。

設計では、次の 3 点を区別します。

・ State: UI に何を表示するかを決めるデータ。
・ Compose Runtime: State の読み取りを追跡し、Composition の更新を管理する仕組み。
・ Navigation: 画面履歴やエントリーを管理し、ナビゲーション状態を UI に反映する仕組み。

それぞれの責務を分けると、コードも自然に整理されます。

例えば、次のような実装は避けたいものです。


// Avoid: UI state and navigation are mixed together.
var currentScreen by mutableStateOf("home")
var detailTitle by mutableStateOf("")
var isLoading by mutableStateOf(false)

// Every action manually synchronizes several values.
fun openDetail(title: String) {
    currentScreen = "detail"
    detailTitle = title
    isLoading = false
}

複数の変数を手動で同期すると、更新漏れや矛盾が生まれやすくなります。

代わりに、画面のデータは UiState として導出し、画面遷移はバックスタックの変更として表現します。


// UI state is derived from data sources.
val uiState: StateFlow<DetailUiState> =
    combine(repository.detail, selectedItemId) {
            detail, selectedId ->
        DetailUiState(
            detail = detail,
            selectedItemId = selectedId
        )
    }.stateIn(
        viewModelScope,
        SharingStarted.WhileSubscribed(5_000),
        DetailUiState()
    )

// Navigation is handled separately.
backStack.add(Detail(itemId))

これは概念例であり、selectedItemId の定義や combine の型はアプリに合わせる必要があります。重要なのは、UI データと画面履歴を一つの巨大な可変オブジェクトに詰め込まないことです。

 

🧑🏻‍💻 まとめ: Compose 化とは、構文ではなく設計モデルを変えること

Compose を使えば、自動的に宣言型の設計になるわけではありません。従来の命令型 UI の考え方を持ち込めば、状態の手動同期や画面管理の複雑さは残ります。

移行時には、次の点を確認してみてください。

・ UI を View のプロパティのように直接変更していないか。
・ UI の状態を複数の場所で手動同期していないか。
・ State をどこで所有し、どこで読み取るかが明確か。
・ Composition の寿命と ViewModel の寿命を区別しているか。
・ 画面履歴と戻る操作を、単純な条件分岐だけに任せていないか。
・ 状態の保存・復元を、利用する仕組みに合わせて設計しているか。

Compose の本質は、UI のコードを Kotlin に書き換えることではありません。

State と UI の依存関係を宣言し、Runtime の仕組みに沿って Composition を更新し、Navigation の状態と画面のライフサイクルを適切に連携させることです。

Compose を本当に使いこなすには、Composable の書き方を覚えるだけでなく、Compose Runtime が前提としている設計モデルに合わせる必要があります。

 

🧑🏻‍💻 参考

👉 Lifecycle of composables  |  Jetpack Compose  |  Android Developers
👉 State and Jetpack Compose  |  Android Developers
👉 Understand and implement the basics  |  App architecture  |  Android Developers
👉 Save and manage navigation state  |  App architecture  |  Android Developers


Java 脳のまま Jetpack Compose を書いてはいけない (1/2)

Compose Runtime、State、Navigation を一つの設計モデルで理解する

 

🧑🏻‍💻 Compose に移行したのに、なぜコードはレガシーなままなのか

Jetpack Compose への移行は、XML レイアウトを Kotlin の関数に置き換えるだけではありません。

従来の Android 開発では、View を生成し、プロパティを変更し、イベントに応じて UI を更新するのが一般的でした。

例えば、ボタンを押したらテキストを変更する処理は、次のように書けます。


button.setOnClickListener {
    textView.text = "Hello, Compose!"
}

Compose でも、同じ発想で UI を手動更新しようとすると、設計が複雑になりがちです。


@Composable
fun Counter() {
    var count by remember { mutableIntStateOf(0) }

    Column {
        Text("$count")

        Button(
            onClick = {
                count++
            }
        ) {
            Text("Increment")
        }
    }
}

このコードでは、ボタンを押すと count が変わり、Compose Runtime が変更を検知して UI を更新します。

Text の内容を直接書き換えているわけではありません。State が変われば、UI はその State から再び導出されます。

これが宣言型 UI の基本です。

それでも、Compose を使いながら従来の命令型 UI の設計を持ち込んでしまうケースがあります。

・ UI の状態を複数の場所で手動同期する。
・ Composable を View オブジェクトのように扱う。
・ 画面遷移を単純な条件分岐だけで管理する。
・ Composition、ViewModel、Activity の寿命を同じものとして考える。

これらの問題を理解するには、個々の API の使い方だけでなく、Compose Runtime がどのように State と UI を結びつけているかを理解する必要があります。

 

🧑🏻‍💻 「Java 脳」が持ち込む3つの誤解

Composable を View オブジェクトのように扱う

従来の Android UI では、View オブジェクトを保持して、そのプロパティを変更する設計が一般的でした。


class CounterFragment : Fragment() {
    private var count = 0
    private lateinit var counterText: TextView

    private fun increment() {
        count++
        counterText.text = count.toString()
    }
}

このコードでは、count と TextView の表示を手動で同期しています。

Compose では、同じ考え方を持ち込む必要はありません。


@Composable
fun Counter() {
    var count by remember { mutableIntStateOf(0) }

    Column {
        Text("Count: $count")

        Button(onClick = { count++ }) {
            Text("Increment")
        }
    }
}

ボタンのイベントは State を変更するだけです。UI の更新方法をイベントハンドラーに書く必要はありません。

ここで重要なのは、Composable を単なる純粋関数だと考えることでもありません。Composable は remember や State、Effect などを利用でき、Compose Runtime によって管理されます。

Composable は View の代わりとなるオブジェクトではなく、Composition を記述するための関数です。

State を単なるフィールドとして扱う

次のコードは、一見すると問題がなさそうです。


class CounterViewModel : ViewModel() {
    var count = 0
        private set

    fun increment() {
        count++
    }
}

しかし、count は Compose が監視する State ではありません。


@Composable
fun Counter(viewModel: CounterViewModel) {
    Text("Count: ${viewModel.count}")

    Button(onClick = viewModel::increment) {
        Text("Increment")
    }
}

increment() が呼ばれても、count の変更だけでは Compose の再コンポジションは発生しません。

UI が観測できる State に変更する必要があります。


class CounterViewModel : ViewModel() {
    private val _count = MutableStateFlow(0)
    val count: StateFlow<Int> = _count.asStateFlow()

    fun increment() {
        _count.update { it + 1 }
    }
}


@Composable
fun Counter(viewModel: CounterViewModel) {
    val count by viewModel.count.collectAsStateWithLifecycle()

    Column {
        Text("Count: $count")

        Button(onClick = viewModel::increment) {
            Text("Increment")
        }
    }
}

これで、ViewModel が公開する State と UI の依存関係が明確になります。

さらに複雑な画面では、複数の State を combine などでまとめ、StateFlow<UiState> として公開できます。


data class UiState(
    val count: Int = 0,
    val canIncrement: Boolean = true
)

重要なのは、値を変更することと、その変更を UI が観測できることは別だという点です。

Lifecycle を Activity や Fragment の寿命だけで考える

Compose には、Activity や Fragment の Lifecycle とは別に、Composable が Composition に参加してから退出するまでのライフサイクルがあります。

Composable のライフサイクル:


Composition に参加
       |
       v
Recomposition(0 回以上)
       |
       v
Composition から退出

例えば、次のコードを見てみましょう。


@Composable
fun SearchScreen() {
    var query by remember { mutableStateOf("") }

    TextField(
        value = query,
        onValueChange = { query = it }
    )
}

remember は再コンポジションをまたいで値を保持します。しかし、この Composable が Composition から取り除かれると、その remember の値は通常破棄されます。

画面回転やプロセス終了をまたいで状態を保持したいなら、別の仕組みが必要です。


@Composable
fun SearchScreen() {
    var query by rememberSaveable { mutableStateOf("") }

    TextField(
        value = query,
        onValueChange = { query = it }
    )
}

rememberSaveable は保存可能な値を構成変更やプロセス再生成から復元するために利用できます。ただし、任意のオブジェクトをそのまま保存できるわけではありません。

このように、状態をどこに置くかは、値の性質だけでなく、どの期間保持したいかによって決める必要があります。

 

🧑🏻‍💻 Compose Runtime は State と UI の依存関係を管理する

UI = f(State):UI を手動で更新しない

宣言型 UI の考え方は、次の式にまとめられます。


UI = f(State)

UI は State から導出されます。

例えば、検索画面で検索文字列と検索結果を別々に手動更新するのではなく、検索文字列から結果を導出できます。


@Composable
fun SearchScreen(items: List<String>) {
    var query by remember { mutableStateOf("") }

    val filteredItems = items.filter {
        it.contains(query, ignoreCase = true)
    }

    Column {
        TextField(
            value = query,
            onValueChange = { query = it },
            label = { Text("Search") }
        )

        LazyColumn {
            items(filteredItems) { item ->
                Text(item)
            }
        }
    }
}

検索文字列が変わると、filteredItems は再コンポジション時に再計算されます。

検索文字列と検索結果の両方を別々の可変 State として保持し、毎回同期する必要はありません。

ただし、計算コストが高い場合や、入力データが大きい場合には derivedStateOf、ViewModel、Flow などを含めた設計を検討します。宣言型であることは、あらゆる計算を毎回行うという意味ではありません。

Recomposition は単なる再描画ではない

Compose Runtime は、Composable が読み取った State を追跡します。


@Composable
fun ProfileScreen() {
    var name by remember { mutableStateOf("Alice") }
    var age by remember { mutableIntStateOf(30) }

    Column {
        Text("Name: $name")
        Text("Age: $age")

        Button(onClick = { name = "Bob" }) {
            Text("Change name")
        }
    }
}

name が変わると、その State に依存する Composition の範囲で再コンポジションがスケジュールされます。

ただし、再コンポジションが発生することと、画面全体が実際に再描画されることは同じではありません。 Compose は変更の影響範囲やスキップ可能性などに応じて処理します。

設計上、避けたいのは、必要以上に広い範囲で State を読み取り、無関係な UI まで頻繁に再コンポジションの対象にしてしまうことです。

例えば、State を使う範囲を小さくできます。


@Composable
fun Screen() {
    Column {
        StaticHeader()
        Counter()
    }
}

@Composable
fun StaticHeader() {
    Text("Welcome")
}

@Composable
fun Counter() {
    var count by remember { mutableIntStateOf(0) }

    Text("Count: $count")

    Button(onClick = { count++ }) {
        Text("Increment")
    }
}

この例では、カウンターの State をカウンター自身の近くに置いています。これにより、状態の依存関係が分かりやすくなります。

State を画面の最上位に集めることが常に悪いわけではありません。複数の子 UI が同じ State を共有する場合には、上位への State hoisting が適切です。

大切なのは、State の所有者と State を読み取る場所を意識することです。

State の所有者と観測する場所を区別する

画面が複雑になると、UI の状態をすべて Composable の内部に置くのは適切ではありません。

例えば、Repository のデータ、検索条件、読み込み状態を組み合わせる画面では、ViewModel が状態を導出できます。


data class ItemsUiState(
    val items: List<String> = emptyList(),
    val query: String = "",
    val isLoading: Boolean = false
)

class ItemsViewModel(
    repository: ItemsRepository
) : ViewModel() {

    private val query = MutableStateFlow("")

    val uiState: StateFlow<ItemsUiState> =
        combine(
            repository.items,
            query
        ) { items, query ->
            ItemsUiState(
                items = items.filter {
                    it.contains(query, ignoreCase = true)
                },
                query = query
            )
        }.stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5_000),
            initialValue = ItemsUiState()
        )

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

ここでは、UI アクションが更新するのは ViewModel 内部の query です。公開する uiState は、Repository のデータと検索条件から導出されます。

UI 側は、その状態を購読して表示します。


@Composable
fun ItemsScreen(
    viewModel: ItemsViewModel
) {
    val uiState by
        viewModel.uiState.collectAsStateWithLifecycle()

    Column {
        TextField(
            value = uiState.query,
            onValueChange = viewModel::onQueryChange
        )

        LazyColumn {
            items(uiState.items) { item ->
                Text(item)
            }
        }
    }
}

この設計では、公開された uiState を手動で変更する必要がありません。依存関係を Flow として記述し、ViewModel が UI の状態を導出します。

stateIn は Flow を StateFlow に変換し、共有するための仕組みです。一方、collectAsStateWithLifecycle() は Android の Lifecycle に応じて UI 側の収集を管理します。両者は異なる責務を持っています。

(→ 2/2 へつづく)


ViewModel における stateIn の実力

Kotlin Flow を使って、UI State を宣言的に構築する

Jetpack Compose の ViewModel では、UI State を手動で管理し、init から load() を呼び出し、データが変わるたびに複数のプロパティを更新するコードをよく見かけます。

この方法でも問題なく動作します。しかし、アプリが複雑になるにつれて、複数の状態を同期させるためのコードが増え、管理が難しくなっていきます。

Kotlin Flow を使えば、もっと宣言的に UI State を構築できます。

Flow のオペレーターと stateIn を組み合わせることで、UI State がどの入力から、どのように導出されるのかをコードで表現できます。更新のたびに状態を手動で同期する必要はありません。

 

🤔 手動で UI State を管理するコスト

まずは、よくある ViewModel を見てみましょう。


class MyViewModel(
    private val repository: MyRepository
) : ViewModel() {

    private val _uiState = MutableStateFlow(UiState())
    val uiState = _uiState.asStateFlow()

    init {
        load()
    }

    private fun load() {
        viewModelScope.launch {
            val items = repository.loadItems()

            _uiState.update {
                it.copy(items = items)
            }
        }
    }
}

これは正当な実装です。一度だけデータを取得するような処理なら、理解しやすく、十分に実用的です。

ただし、ViewModel は次のような責務を担っています。

・ 初回ロードを開始する。
・ データ取得後に UI State を更新する。
・ 変更していないフィールドを保持する。
・ 入力が増えたときも、複数の状態の整合性を維持する。

画面が複雑になるほど、こうした処理が繰り返し必要になります。

では、Repository がすでに Flow でデータを公開している場合はどうでしょうか。Repository の Flow を手動で収集し、その値を別の StateFlow にコピーし続ける必要があるのでしょうか。

多くの場合、その必要はありません。

 

🤔 そもそも load() を呼ぶ必要はあるのか?

Jetpack Compose では、次のようなコードをよく使います。


LaunchedEffect(Unit) {
    viewModel.load()
}

あるいは、ViewModel の init ブロックから呼び出します。


init {
    load()
}

どちらも適切な場面があります。ユーザーが明示的に実行する更新処理や、一度だけ行う操作、特定のイベントに応じた処理には、明示的なコマンドが必要です。

しかし、画面が Flow で公開されたデータをそのまま表示するだけなら、手動でロードを開始し、UI State に反映させるための処理は不要かもしれません。

ここで重要なのは、コマンドと状態を区別することです。

・ コマンドは「何を実行するか」を表す。
・ 状態は「現在どうなっているか」を表す。

既存の Flow から UI State を導出できるなら、stateIn を使うことで、命令的な更新処理を減らせます。

 

🤔 stateIn で Flow を UI State に変換する

Repository が次のような Flow を公開しているとします。


interface MyRepository {
    val items: Flow<List<Item>>
}

この場合、ViewModel は UI State を直接導出できます。


class MyViewModel(
    repository: MyRepository
) : ViewModel() {

    val uiState: StateFlow<UiState> =
        repository.items
            .map { items ->
                UiState(items = items)
            }
            .stateIn(
                scope = viewModelScope,
                started = SharingStarted.WhileSubscribed(5_000),
                initialValue = UiState()
            )
}

何がなくなったでしょうか。

・ 同じ UI State を手動更新するための MutableStateFlow
・ 初回収集を開始するためだけの init ブロック
・ Repository の値をコピーするためだけの load() 呼び出し
・ Repository のデータと UI State を同期させる処理

コードは、データと UI State の関係を直接表現しています。


Repository Flow
       |
       v
      map
       |
       v
     UiState
       |
       v
    stateIn
       |
       v
StateFlow<UiState>
       |
       v
   Compose UI

Repository が新しいリストを発行すると、それに対応する UiState が生成されます。

stateIn は、上流の Flow をホットな StateFlow に変換し、最新の値を共有するとともに、初期値を提供します。

公式ドキュメントでも、この用途が説明されています。

👉 stateIn – Kotlin Programming Language
👉 StateFlow – Kotlin Programming Language

変更のたびに状態を同期する方法ではなく、状態がどのように導出されるのかをコードで宣言できるのです。

 

🤔 複数の入力から、一貫した UI State を導出する

このメリットは、画面が複数の入力に依存するときに、さらに明確になります。

例えば、検索機能のあるリスト画面を考えてみましょう。表示するアイテムは、Repository のデータと検索クエリの両方に依存します。

手動で状態を更新する場合、両方の変更を考慮して UI State を更新しなければなりません。

Flow なら、その依存関係を明示できます。


class MyViewModel(
    repository: MyRepository
) : ViewModel() {

    private val query = MutableStateFlow("")

    val uiState: StateFlow<UiState> =
        combine(
            repository.items,
            query
        ) { items, query ->
            UiState(
                items = items.filter {
                    it.matches(query)
                },
                query = query
            )
        }.stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5_000),
            initialValue = UiState()
        )

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

ここで注目したいのは、UI アクションが更新するのは query だけだという点です。公開されている uiState を直接変更することはありません。

次のどちらが起きても、UI State は自動的に再計算されます。

・ Repository が新しいアイテムを発行する。
・ ユーザーが検索クエリを変更する。

表示リストを再計算する処理を、変更箇所ごとに書く必要はありません。

依存関係は combine の中で一度だけ定義されています。


Repository.items ──┐
                   ├── combine ──> UiState
query ─────────────┘
                                  |
                                  v
                               stateIn
                                  |
                                  v
                          StateFlow<UiState>

この設計では、入力を変更する処理と、その入力から UI State を導出する処理が明確に分離されています。

 

🤔 コマンドは明示的に、状態は宣言的に

もちろん、すべての操作を Flow で表現すべきという意味ではありません。

フォームの送信、アイテムの削除、ユーザー操作に応じた明示的な再取得などは、ViewModel がコマンドとして処理する必要があるでしょう。

重要なのは、導出できる状態を同期するためだけに、命令的な処理を増やさないことです。

複数の観測可能な入力から UI State が決まるなら、map や combine で依存関係を表現し、UI に StateFlow を公開する必要がある場合は stateIn を使います。

設計上の役割を整理すると、次のようになります。

依存関係がコードに表れるため、状態がどこで、なぜ変わるのかを理解しやすくなります。

 

🤔 適切な Sharing Strategy を選ぶ

stateIn では、上流の Flow をいつ開始・停止するかを指定します。

よく使われる設定の一つが、次のものです。


SharingStarted.WhileSubscribed(5_000)

これは、最初の購読者が現れたときに上流の収集を開始し、最後の購読者がいなくなってから 5 秒後に収集を停止する設定です。

👉 stateIn – Kotlin Programming Language

5 秒の猶予があることで、画面遷移などによる短時間の購読解除で、上流の収集がすぐに停止・再開するのを避けられます。

ただし、次の2つは同じではありません。

・ Flow の収集が再開されること。
・ ネットワークリクエストが再実行されること。

画面が再表示されたときに API が再度呼び出されるかどうかは、上流の Flow がどのように実装されているかによります。

また、stateIn 自体がデータを取得するわけではありません。上流の Flow が発行した値を共有し、最新の状態として保持する仕組みです。

よく見かける設定をそのまま採用するのではなく、アプリのライフサイクルやデータ取得の要件に合わせて選びましょう。

 

🤔 MutableStateFlow が必要な場面もある

MutableStateFlow をなくすこと自体が目的ではありません。命令的に変更する必要がある入力には、引き続き有用です。

例えば、ユーザーが操作する検索クエリです。


private val query = MutableStateFlow("")

ユーザーの入力に応じて query を変更するのは自然な設計です。

一方、Repository のデータと検索クエリから導出できる uiState は、別の可変状態として保持する必要がありません。

実践的なルールは次のとおりです。

・ 入力には MutableStateFlow を使う。
・ Flow オペレーターで入力から状態を導出する。
・ stateIn で結果を StateFlow として公開する。

これは可変状態を排除する設計ではありません。可変性が必要な場所にだけ、それを配置する設計です。

 

🤔 まとめ:stateIn に状態管理を任せよう

stateIn は、Flow を StateFlow に変換するだけの便利なオペレーターではありません。map や combine と組み合わせることで、ViewModel の状態管理を宣言的に設計するための重要な手段になります。

データを手動で収集し、値をコピーし、関連するフィールドを同期する代わりに、入力と UI State の依存関係を直接表現できます。

特に、複数のデータソースから画面の状態が決まる場合に、そのメリットが大きくなります。

コマンドは明示的に。状態は宣言的に。

Flow で依存関係を定義し、適切な場面で stateIn を使って UI State を公開しましょう。


【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の使い方を続けて全く問題ありません。

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