2025年3月14日金曜日

Jetpack Compose: mutableStateOf の変更の検出の方法を変えるには

はじめに

Jetpack Composeで状態を管理する際に mutableStateOf をよく使うと思います。 その mutableStateOf を作成する際に、policy というパラメータを指定できることをご存知でしょうか?

val state = remember {
    mutableStateOf(initialValue, policy = structuralEqualityPolicy()) // policyを指定
}

この policy パラメータは、Stateオブジェクトの値が変更されたとみなす条件、つまり状態の変更をどのように検出するかを制御することができます。

policy を適切に設定することで不要な再コンポーズを避け、パフォーマンスを向上できる可能性があります。逆に policy を理解せずにデフォルトのまま使用していると、意図しない再コンポーズが発生しパフォーマンスに影響を与えるかもしれません。

この記事では、mutableStateOfpolicy で選択できる3つのポリシーを解説します。

mutableStateOfpolicy で選択できる3つのポリシー

mutableStateOfpolicy パラメータには以下の3つのポリシーを設定できます。

  1. structuralEqualityPolicy(): 構造的等価性ポリシー (デフォルト)
  2. referentialEqualityPolicy(): 参照的等価性ポリシー
  3. neverEqualPolicy(): 常に非等価ポリシー

それぞれ詳しく見ていきましょう。

1. structuralEqualityPolicy() (構造的等価性ポリシー)

概要

structuralEqualityPolicy()mutableStateOfpolicy パラメータに何も指定しない場合のデフォルトのポリシーです。このポリシーは値を equals() メソッドで比較した等価性に基づいて状態の変化を検出します。

データ型は equals() メソッドを適切にオーバーライドする必要があります。

特徴

  • 直感的で分かりやすい: 多くのケースで期待通りの動作をしやすく、値の内容が変化した場合に再コンポーズをトリガーするため、UIが適切に更新されます。
  • データクラスとの相性が良い: データクラスは構造的な等価性の比較が自然にできるため、structuralEqualityPolicy() と非常に相性が良いです。
  • 汎用性が高い: プリミティブ型から複雑なオブジェクトまで、幅広いデータ型に対応できます。

ユースケース

  • 一般的なUIの状態管理: カウンターアプリ、テキスト入力フォーム、リスト表示など、ほとんどのUIの状態管理に適しています。
  • 値の内容の変化に基づいてUIを更新したい場合: ユーザーの入力に応じてテキストを更新したり、リストの内容が変更された際にリストを再表示したりする場合など。

コード例

import androidx.compose.runtime.*
import androidx.compose.material.*
import androidx.compose.foundation.layout.*

@Composable
fun StructuralEqualityExample() {
    var count by remember { mutableStateOf(0) } // policyを省略するとstructuralEqualityPolicy()

    Column(modifier = Modifier.padding(16.dp)) {
        Text("Count: $count", style = MaterialTheme.typography.h6)
        Spacer(modifier = Modifier.height(8.dp))
        Button(onClick = { count++ }) {
            Text("Increment")
        }
    }
}

この例では、countInt 型でありプリミティブ型なので、値が変化すると再コンポーズがトリガーされ画面上の “Count:” の表示が更新されます。

注意点

複雑な構造を持つオブジェクトやコレクションの場合 equals() メソッドによる比較処理にコストがかかることがあります。特にリストやマップなどの要素数が多いコレクションでは比較処理が重くなる可能性があります。パフォーマンスがボトルネックになる場合は、他のポリシーを検討する必要があるかもしれません。

2. referentialEqualityPolicy() (参照的等価性ポリシー)

概要

referentialEqualityPolicy() は、値の参照的な等価性に基づいて状態の変化を検出します。

比較方法

具体的には、=== 演算子 (参照の等価性) を使用して、新旧の値が全く同じオブジェクトであるかどうかを比較します。

値の型や内容に関わらず、オブジェクトの参照が同じかどうかのみを比較します。

特徴

  • 非常に高速な比較: 参照の比較は非常に高速な処理です。構造的な比較のように複雑な比較処理は行わないため、パフォーマンスへの影響がとても少ないです。再コンポーズの判定処理を最速にしたい場合に有効です。
  • 意図的な再コンポーズ制御: オブジェクトの参照が変更された場合にのみ再コンポーズをトリガーするため、より細かく再コンポーズのタイミングを制御したい場合に有効です。特定のオブジェクトインスタンスが入れ替わった時のみ再コンポーズしたい、という高度な制御が可能です。

ユースケース

  • パフォーマンスが極めて重要な場合: 非常に頻繁に状態が更新されるようなケースで、再コンポーズの判定処理のオーバーヘッドを極力減らしたい場合。ただし、安易に使用するとUIが更新されないなどの問題が発生する可能性があるため、慎重な検討が必要です。
  • オブジェクトの参照自体が変化したことを検知したい場合: 例えば、remember で作成した MutableList などの可変コレクションを State で保持し、リストオブジェクト自体を新しいインスタンスで置き換えた時のみ再コンポーズをトリガーしたい場合など、特殊なケースに限られます。

コード例

import androidx.compose.runtime.*
import androidx.compose.material.*
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.referentialEqualityPolicy

@Composable
fun ReferentialEqualityExample() {
    val mutableList = remember { mutableListOf("Apple", "Banana") }
    var listState by remember { mutableStateOf(mutableList, policy = referentialEqualityPolicy()) } // policyにreferentialEqualityPolicy()を指定

    Column(modifier = Modifier.padding(16.dp)) {
        Text("Items: ${listState.joinToString()}", style = MaterialTheme.typography.h6)
        Spacer(modifier = Modifier.height(8.dp))
        Button(onClick = {
            mutableList.add("Orange") // mutableListオブジェクト自体は変わらない
            listState = mutableList // 同じmutableListオブジェクトを再度Stateに設定 (参照は同じ)
        }) {
            Text("Add Orange (No Recomposition)") // <-- 再コンポーズされない
        }
        Spacer(modifier = Modifier.height(8.dp))
        Button(onClick = {
            val newList = mutableList.toMutableList() // 新しいmutableListオブジェクトを作成
            newList.add("Grape")
            listState = newList // 新しいmutableListオブジェクトをStateに設定 (参照は異なる)
        }) {
            Text("Add Grape (Recomposition)") // <-- 再コンポーズされる
        }
    }
}

この例では、“Add Orange” ボタンをタップすることでリストの内容は更新されますが、mutableList オブジェクト自体の参照は変わらないため、referentialEqualityPolicy() では変更が検出されず再コンポーズはトリガーされません。一方、“Add Grape” ボタンをタップした場合は新しいリストオブジェクトが作成されるため、再コンポーズがトリガーされます。

注意点

オブジェクトの内容が変化しても参照が同じままであれば再コンポーズがトリガーされません。そのためUIが更新されず、意図しない動作になる可能性が非常に高いです。安易に referentialEqualityPolicy() を使用すると、UIが正しく更新されないという問題に直面する可能性が高いため、使用は慎重に行う必要があります。

3. neverEqualPolicy() (常に非等価ポリシー)

概要

neverEqualPolicy() は常に状態が変化したとみなす特殊なポリシーです。

比較方法

比較は一切行わず、常に新旧の値は異なると判定します。

特徴

  • 強制的に再コンポーズ: Stateの値が設定されるたびに無条件に再コンポーズをトリガーします。
  • 最も遅いポリシー: 常に再コンポーズが発生するため、3つのポリシーの中で最もパフォーマンスが悪いポリシーとなります。通常の使用では避けるべきです。
  • 非常に特殊な用途向け: 通常の使用ケースではまず利用することはありません。デバッグやテストなど、ごく限られた特殊な状況でのみ利用価値があるかもしれません。

ユースケース

  • 再コンポーズを強制的に実行したい極めて稀なケース: 通常、このようなケースは存在しないはずです。
  • デバッグ目的: 特定のStateが更新された際に常に再コンポーズが実行されるように強制することで、デバッグを容易にする目的で使用できるかもしれません。(ただし、デバッガー等、より適切なデバッグ手段を使う方が一般的です。)
  • UI のアニメーションを強制的に再実行したい場合 (非常に限定的): Stateの値を変更するたびに毎回アニメーションを最初から実行させたい、などの極めて特殊な要件がある場合に、neverEqualPolicy() を使用する、という選択肢も理論上は考えられます。(ただし、より効率的なアニメーション制御の方法を検討すべきです。)

コード例

import androidx.compose.runtime.*
import androidx.compose.material.*
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.neverEqualPolicy
import kotlinx.coroutines.delay

@Composable
fun NeverEqualPolicyExample() {
    var triggerRecompose by remember { mutableStateOf(0, policy = neverEqualPolicy()) } // policyにneverEqualPolicy() を指定

    LaunchedEffect(triggerRecompose) { // triggerRecomposeが変化するたびにLaunchedEffectが再実行
        println("Recomposition triggered at: ${System.currentTimeMillis()}") // 毎回ログが出力される
        delay(1000) // 1秒遅延
        triggerRecompose++ // Stateを更新 (常に再コンポーズが発生)
    }

    Column(modifier = Modifier.padding(16.dp)) {
        Text("Recomposition Count: $triggerRecompose", style = MaterialTheme.typography.h6)
        Spacer(modifier = Modifier.height(8.dp))
        Text("This text will re-render every second due to neverEqualPolicy.", style = MaterialTheme.typography.body1)
    }
}

この例では、LaunchedEffect 内で triggerRecompose Stateを更新していますが、neverEqualPolicy() が設定されているため triggerRecompose の値が変化しなくても常に再コンポーズがトリガーされます。その結果、LaunchedEffect が毎秒再実行され、ログが出力され、画面上のテキストも毎秒再レンダリングされます。

注意点

  • パフォーマンスは最悪: 常に再コンポーズが発生するためパフォーマンスは著しく低下します。通常の使用には絶対に避けるべきです。
  • 意図しない無限再コンポーズループに注意: neverEqualPolicy() を使用したStateを更新する処理をComposable関数内で直接行うと、無限再コンポーズループに陥る可能性があります。LaunchedEffectSideEffect などの副作用を扱うAPIと組み合わせて、再コンポーズループを制御する必要があります。(上記の例では LaunchedEffect を使用して再コンポーズループを制御しています。)
  • 利用シーンは極めて限定的: 通常のアプリ開発で neverEqualPolicy() を積極的に使用する場面はまずありません。本当に特殊な状況でのみ、その特性を理解した上で、最終手段として検討するポリシーと言えるでしょう。

3つのポリシーの比較表

ポリシー 比較方法 特徴 ユースケース 注意点 パフォーマンス
structuralEqualityPolicy() equals() 直感的、データクラス◎、汎用的 一般的なUI状態管理、値の内容変化で再コンポーズしたい場合 比較コスト、不要な再コンポーズの可能性 普通
referentialEqualityPolicy() === 高速比較、意図的な制御 パフォーマンス最重視、参照変化のみで再コンポーズしたい特殊ケース 意図しない動作の可能性大、データクラスと相性最悪、UI更新されないリスク 高速
neverEqualPolicy() 常に非等価 強制再コンポーズ、デバッグ用途 極めて特殊なケース、デバッグ、アニメーション強制再実行 (限定的) パフォーマンス最悪、無限再コンポーズループに注意、通常の使用には不向き 最悪

まとめ

mutableStateOfpolicy パラメータで選択できる3つのポリシー (structuralEqualityPolicy, referentialEqualityPolicy, neverEqualPolicy) について、それぞれの特徴と使い分けを解説しました。

  • 迷ったら structuralEqualityPolicy() (デフォルト) を選択: ほとんどの場合デフォルトの structuralEqualityPolicy() で十分です。直感的で扱いやすく、汎用的なUI状態管理に適しています。
  • referentialEqualityPolicy() は安易に使用しない: referentialEqualityPolicy() は、パフォーマンスが極めて重要な特殊なケースを除き安易な使用は避けるべきです。UIが意図通りに更新されるか十分に検証する必要があります。
  • neverEqualPolicy() は最終手段: neverEqualPolicy() は、非常に特殊なポリシーであり、通常の使用ケースではまず利用しません。使用する場合はその特性を十分に理解し副作用を考慮した上で本当に必要な場合にのみ限定的に使用してください。

policy パラメータを理解し適切に使い分けることで、Jetpack Compose アプリケーションのパフォーマンスとUIの挙動をより細かく制御することができます。この記事があなたの Compose アプリ開発における状態管理の理解を深め、より最適化されたアプリ開発の一助となれば幸いです。

おまけ: さらに高度な制御 - カスタムポリシー

組み込みポリシー以外にも EqualsPolicy<T> インターフェースを実装することでカスタムポリシーを作成することも可能です。カスタムポリシーを利用することで、より複雑な状態変化の検出ロジックを実装できます。例えば、特定の条件でのみ再コンポーズをトリガーしたい場合や、独自の比較ロジックを実装したい場合などに有効です。

ただし、通常は組み込みポリシー (structuralEqualityPolicy, referentialEqualityPolicy, neverEqualPolicy) で十分なケースがほとんどです。カスタムポリシーはさらに高度な制御が必要な場合に検討してみてください。