PHP2Shell で騒ぐ前に知っておくべきこと:「PHPのバージョンを上げれば終わり」ができない現場の構造的欠陥

最近、「PHP2Shell」と呼ばれる深刻な脆弱性が話題になり、SNSやセキュリティ関連のニュースで大きく取り上げられています。

これに対して「何をそんなに大騒ぎしているのか? PHPのバージョンを安全な版にアップデートすれば終わる話ではないのか?」と感じた人も多いのではないでしょうか。

結論から言えば、その通りです。

技術的な解決策としては「修正されたバージョンに上げる」のが唯一かつ完全な正解です。しかし、それでもこれほどの大騒ぎになっているのは、「バージョンを上げることすらできないレガシーシステムが、世の中に大量に放置されているから」に他なりません。

この記事では、なぜ「バージョンアップ」という当たり前の対応ができないのか、そしてその裏にある現場の構造的な課題について整理します。

 

🤔 なぜ「PHPのバージョンアップ」で終わらないのか?

今回の脆弱性は、PHP 5.4から8.5という非常に広い範囲に影響を与えます。そして、ここが最大のネックなのですが、すでにサポートが終了(EOL)しているPHP 8.1以前のバージョンには、公式からのパッチが提供されません。

つまり、古いPHPを使っている環境は「修正版の8.2以降」へ一気にジャンプアップ(メジャー/マイナーバージョンアップ)する必要があります。

しかし、古いPHPで動いているシステムをそのまま最新のPHP 8.2/8.3に持っていくと、以下のような問題が確実に発生します。

・ 非推奨(Deprecated)機能・廃止関数の影響で画面が真っ白になる
・ 古いフレームワークやWordPressプラグインが動作しなくなる
・ データベースの接続ドライバや仕様変更に伴う挙動不審

結果として、「ただPHPのバイナリを差し替える」だけでは済まず、アプリケーションコードの大規模な改修と全機能の再テストが必要になります。

 

🤔 バージョンアップできないシステムは、ただの「延命」である

「コードを直せばいいじゃないか」と思うかもしれませんが、現場ではそれができません。

・ 仕様書がなく、当時の開発者もいない(誰も全体像を把握していない)
・「現状動いているから触るな」という経営陣・決裁者の圧力
・ 売上を生み出さない基盤改修に予算が出ない

このようにして、「修正費用が出せないから」「壊れるのが怖いから」とバージョンアップを先送りにしてきたシステムが、今回のPHP2Shellのような致命的な脆弱性に晒された瞬間、手も足も出なくなるのです。

WAF(Web Application Firewall)の設定調整や、一時的な設定変更で攻撃を防ごうとする現場もありますが、これはあくまで「時間稼ぎの応急処置」にすぎません。

バージョンアップに追従できないシステムは、ソフトウェアとしての寿命をすでに迎えており、「技術的負債の返済を先送りして延命しているだけ」というのが残酷な現実です。

 

🤔 開発者・エンジニアが取るべき姿勢

今回の騒動から私たちが学ぶべきなのは、「脆弱性が出たときにどう対処するか」だけではありません。「そもそもバージョンアップを前提としたアーキテクチャや保守体制をどう維持するか」という点です。

・ フレームワークや言語のマイナーアップデートを日々の運用に組み込む
・ EOL(サポート終了)を迎える前に、計画的なマイグレーション予算を確保する
・「動いているから触らない」ではなく、「触り続けられる状態(テストコードやCI/CD)を保つ」

これらができていないシステムは、どれだけ強固なセキュリティ製品を前段に置いたとしても、いつか必ず破綻します。

 

🤔 まとめ

「PHPのバージョンを上げたら終わりじゃないの?」という疑問に対する答えは、「理屈の上ではその通り。でも、上げられないシステムがあまりにも多すぎるから大騒ぎになっている」です。

セキュリティの脆弱性問題は、単なる技術の問題ではなく、これまでのシステム運用・投資判断のツケが可視化された結果とも言えます。

「そもそも、アナウンス済みのさまざまな脆弱性を放置したままだったよな?」

自社で管理しているシステムや関わっているプロダクトがあるなら、今回の件を機に「いつでも言語バージョンを上げられる状態になっているか?」を改めて見直してみるのが良いかもしれません。

👉 PHP 8.5.12, 8.4.27, 8.3.36 and 8.2.35 fix two critical vulnerabilities that can allow unauthenticated remote code execution. All PHP users are strongly encouraged to update immediately.


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() へ置き換えてみてください!