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 を書いてはいけない (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 へつづく)


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


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 
    }
}

 

🧑🏻‍💻 参考


Jetpack Composeで DisposableEffect に任せるべき処理

Jetpack Compose では UI のライフサイクル管理がかなり簡単になりました。

しかし、それでも明示的な後始末が必要な処理があります。

たとえば、

・ Listener を登録する
・ Connection を開始する
・ 外部リソースを取得する
・ 後から停止する必要がある処理を開始する
・ UI が消えたときに登録解除やリソース解放を行う

といった処理です。

このような処理を Composition のライフサイクルに結び付けるのが DisposableEffect です。

基本的には、次のように考えると分かりやすいです。


Composition に入る
       ↓
register / start / acquire
       ↓
Composition に存在している間
       ↓
Composition から出る
       ↓
onDispose
       ↓
unregister / stop / release

 

🤔 remember はリソースを解放しない

例えば、次のコードを考えてみます。


@Composable
fun PlayerScreen() {
    val player = remember {
        MediaPlayer()
    }
}

remember は再コンポジションをまたいでオブジェクトを保持します。

しかし、MediaPlayer が何なのか、いつ release() すべきなのかまでは知りません。

明示的な解放の API を持つオブジェクトなら、その解放処理を適切なライフサイクルに結び付ける必要があります。


@Composable
fun PlayerScreen() {
    val player = remember {
        MediaPlayer()
    }

    DisposableEffect(player) {
        onDispose {
            player.release()
        }
    }
}

ここで重要なのは、


remember
  ↓
オブジェクトを保持する


DisposableEffect
  ↓
オブジェクトに関連する副作用のライフサイクルを管理する

という違いです。

つまり、remember 自体がメモリーリークを起こすわけではありません。

問題になるのは、明示的な解放が必要なリソースを、適切なタイミングで終了させないことです。

 

🤔 よくあるパターン

DisposableEffect を使う処理は、ほとんど同じ形になります。


DisposableEffect(key) {

    resource.register()

    onDispose {
        resource.unregister()
    }
}

名前は変わっても、考え方は同じです。


// LifecycleObserver

DisposableEffect(lifecycle) {
    lifecycle.addObserver(observer)

    onDispose {
        lifecycle.removeObserver(observer)
    }
}


// BroadcastReceiver

DisposableEffect(receiver) {
    context.registerReceiver(receiver, filter)

    onDispose {
        context.unregisterReceiver(receiver)
    }
}


// Sensor

DisposableEffect(sensor) {
    sensorManager.registerListener(
        listener,
        sensor,
        SensorManager.SENSOR_DELAY_NORMAL
    )

    onDispose {
        sensorManager.unregisterListener(listener)
    }
}


// Location

DisposableEffect(locationCallback) {
    locationClient.requestLocationUpdates(
        request,
        locationCallback,
        Looper.getMainLooper()
    )

    onDispose {
        locationClient.removeLocationUpdates(locationCallback)
    }
}


// NetworkCallback

DisposableEffect(networkCallback) {
    connectivityManager.registerNetworkCallback(
        request,
        networkCallback
    )

    onDispose {
        connectivityManager.unregisterNetworkCallback(networkCallback)
    }
}


// MediaPlayer

val player = remember {
    MediaPlayer()
}

DisposableEffect(player) {
    onDispose {
        player.release()
    }
}


// WebSocket

val socket = remember {
    createWebSocket()
}

DisposableEffect(socket) {
    socket.connect()

    onDispose {
        socket.close()
    }
}


// Listener

DisposableEffect(manager) {
    manager.addListener(listener)

    onDispose {
        manager.removeListener(listener)
    }
}


// TextWatcher

DisposableEffect(textView) {
    textView.addTextChangedListener(watcher)

    onDispose {
        textView.removeTextChangedListener(watcher)
    }
}

 

🤔 ViewModel の場合はどうなる?

Composition が所有するリソースなら、DisposableEffect で解放できます。


Composition
    │
    └── resource
          │
          └── DisposableEffect
                  ↓
               onDispose()

一方、ViewModel が所有するリソースは、ViewModel のライフサイクルに従います。


ViewModel
    │
    └── resource
          │
          └── onCleared()

例えば、


class PlayerViewModel : ViewModel() {

    private val player = MediaPlayer()

    override fun onCleared() {
        player.release()
        super.onCleared()
    }
}

ViewModel がリソースを保持すること自体に問題があるわけではありません。

重要なのは、

「このリソースの所有者は誰で、その所有者の寿命はいつ終わるのか?」

ということです。

MediaPlayerが 画面に属するなら、DisposableEffect は自然な選択です。

また、Composition が破棄されても再生を継続したいなど、MediaPlayer を ViewModel の寿命まで保持したいのであれば、ViewModel が所有する設計も考えられます。

 

🤔 メモリーリークとは別の話

ここで、リソースリーク と メモリーリーク を混同しないことも重要です。

典型的なメモリーリークは、長く生存するオブジェクトが、本来なら破棄されるはずのオブジェクトを参照し続けることで起こります。

例えば、ViewModel が Activity への参照を保持しているとします。

Activity が画面から破棄された後も ViewModel がその Activity を参照していれば、Activity を GC できなくなる可能性があります。

これはメモリーリークです。

一方、リソースリークは少し違います。


Object
   │
   └──→ MediaPlayer
            │
            └── 外部リソース

もう使わないのに release() を呼ばなければ、外部リソースを明示的に解放していないことが問題になります。

両者が同時に発生することはありますが、同じものではありません。

 

🤔 気づくための簡単なルール

Composable の中に、次のような処理があったら考えてみてください。

・ register()
・ addListener()
・ connect()
・ start()
・ acquire()

そして、

「これに対応するクリーンアップはどこにある?」

と考えます。

例えば、


register()    →  unregister()
addListener() →  removeListener()
connect()     →  close()
start()       →  stop()
acquire()     →  release()

です。

もし、その処理が Composable が Composition に存在している間だけ必要なら、DisposableEffect は有力な選択肢です。

 

🤔 DisposableEffect の本質

DisposableEffect は、Composition のライフサイクルに合わせて副作用の開始と終了を管理します。

remember:
「再コンポジションをまたいで オブジェクトを保持する」

DisposableEffect:
「Compositionの存在期間に合わせて副作用を管理する」

onDispose:
「もう必要ないので明示的に後始末する」

結局、DisposableEffect を使うかどうかを考えるときに重要なのは、

「このリソースの所有者は誰で、その寿命はいつ終わるのか?」

という1つの質問です。

この視点を持つと、DisposableEffect を単なる API としてではなく、リソースのライフサイクルを Composition に接続するための仕組みとして理解できます。