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) で十分なケースがほとんどです。カスタムポリシーはさらに高度な制御が必要な場合に検討してみてください。

2025年3月1日土曜日

KotlinのSharedFlowで、コレクターが存在するときだけ値を流す方法

KotlinのshareIn()はコールドFlowをホットFlowにすることができますが、

  • SharedFlowを使用し、コレクターが存在する場合にのみデータを流したい
  • 不要な処理を避け、パフォーマンスを最適化したい

といったケースでは、適切なフローの仕組みを選択する必要があります。

SharedFlowとは?

SharedFlowは、複数のコレクターが同じデータを受け取れるホットFlowの一種です。しかし、デフォルトではコレクターの有無にかかわらずデータが流れ続けるため、「コレクターがいないときは値を流さない」ように制御したほうがいい場合もあります。

SharedFlowの基本

val coldFlow = flow {
    repeat(5) {
        emit(it)
        delay(1000)
    }
}

val sharedFlow = coldFlow.shareIn(
    scope = this,
    started = SharingStarted.Eagerly,
    replay = 0
)

この場合sharedFlowはコレクターがいなくても値を流し続けます。

コレクターが存在するときだけ値を流す方法

KotlinのSharedFlowでコレクターがいる場合のみデータを流し、いなくなったら停止するようにしたい場合、 shareInSharingStarted.WhileSubscribed()を組み合わせるのが有効です。

val sharedFlow = coldFlow.shareIn(
    scope = coroutineScope,
    started = SharingStarted.WhileSubscribed(),
    replay = 0
)

この方法を使うとコレクターが1つ以上存在するときのみ値が流れ、0になると停止します。

WhileSubscribed() のパラメータをカスタマイズ

SharingStarted.WhileSubscribed(
    stopTimeoutMillis = 5000L,    // コレクターがいなくなってから停止するまでの待機時間
    replayExpirationMillis = 0L   // リプレイキャッシュの保持時間
)
  • stopTimeoutMillis: コレクターが0になってから何ミリ秒待つか(デフォルトは0ms)
  • replayExpirationMillis: リプレイデータの有効期間(デフォルトはLong.MAX_VALUE

例えばstopTimeoutMillis = 5000L に設定すると、コレクターがいなくなっても5秒間はデータを流し続け、その後に完全停止します。

実際の活用例

AndroidアプリではRoomデータベースの変更をFlowで監視することがよくあります。SharedFlowを使い画面が開いている間だけデータを監視し、閉じたら自動的に停止する仕組みを作ることができます。

@Dao
interface UserDao {
    @Query("SELECT * FROM users")
    fun getUsers(): Flow<List<User>>
}

class UserRepository(private val userDao: UserDao) {
    private val userFlow = userDao.getUsers()
        .shareIn(
            scope = CoroutineScope(Dispatchers.IO),
            started = SharingStarted.WhileSubscribed(5000L),
            replay = 0
        )

    fun getSharedUsers(): Flow<List<User>> = userFlow
}

この場合、画面が表示されている間だけRoomデータベースの変更を監視し、閉じたら自動的に監視を停止できます。

まとめ

  • SharedFlowはデフォルトではコレクターがいなくてもデータを流し続ける
  • コレクターがいる間だけ値を流したい場合は shareIn + SharingStarted.WhileSubscribed() を利用する
  • stopTimeoutMillis を使うと一定時間待機してから停止することも可能
  • Roomデータベースの監視に活用すると、画面が開いている間だけデータ変更を反映できる

この仕組みを使うことでSharedFlowをより効率的に活用できます。ぜひ実装に取り入れてみてください。

2025年2月15日土曜日

Jetpack ComposeでのLaunchedEffectとwhileループの動作について

基本的な実装と動作

以下のようなコードでは、アプリ(Activity)がpause中でもwhileループは自動的には停止しません。

LaunchedEffect(key1 = Unit) {
     while (true) {
         indicatorAlpha.floatValue = 1f
         delay(1000L)
         indicatorAlpha.floatValue = 0f
         delay(1000L)
     }
}

なぜかというと、

  • LaunchedEffectはComposable関数のライフサイクルに連動するが、Activityのライフサイクルとは直接連動しない
  • whileループ内のdelayはsuspend関数だが、コルーチン自体は終了しない
  • ActivityがonPause()に入ってもコルーチンは実行を継続する

という理由があるためです。

ライフサイクルに応じた制御の実装

Activityのpause中にループを停止するには以下のような実装が必要です。

var isRunning by remember { mutableStateOf(true) }

LaunchedEffect(isRunning) {
    while (isRunning) {
        indicatorAlpha.floatValue = 1f
        delay(1000L)
        indicatorAlpha.floatValue = 0f
        delay(1000L)
    }
}

DisposableEffect(key1 = LocalLifecycleOwner.current) {
    val observer = LifecycleEventObserver { _, event ->
        if (event == Lifecycle.Event.ON_PAUSE) {
            isRunning = false
        } else if (event == Lifecycle.Event.ON_RESUME) {
            isRunning = true
        }
    }
    LocalLifecycleOwner.current.lifecycle.addObserver(observer)
    onDispose {
        LocalLifecycleOwner.current.lifecycle.removeObserver(observer)
    }
}

Decomposeを使用したMultiplatformアプリでの実装

Decomposeを使用している場合は、Decomposeのライフサイクルを使用することができます。

class YourComponent(
    componentContext: ComponentContext
) : ComponentContext by componentContext {

    private val _isRunning = MutableValue(true)
    val isRunning: Value<Boolean> = _isRunning

    init {
        lifecycle.doOnPause { _isRunning.value = false }
        lifecycle.doOnResume { _isRunning.value = true }
    }
}

まとめ

Jetpack ComposeでLaunchedEffectとwhileループを使用する際は、ActivityやDecomposeのライフサイクルを使って適切なタイミングでループを停止・再開することができます。 これにより、アプリがバックグラウンドに移行した際に不要な処理が実行され続けることを防いでバッテリー消費を抑えることができます。 回しっぱなしのループを作らないように心がけましょう。

2025年2月7日金曜日

MarkdownファイルをHTMLに変換してBloggerに掲載する方法

このブログではMarkdownファイルをローカルでHTMLに変換してBloggerにアップしています。その手順を説明します。

1. Pandocのインストール

私はMac miniをメインで使っているのでMacで作業する場合について説明します。 まずMarkdownファイルを変換するためのPandocをインストールします。 macOSではHomebrewを使用してPandocをインストールできます。

brew install pandoc

または公式サイトからダウンロードできます。

Pandoc 公式サイト

2. MarkdownをHTMLに変換する

最も基本的なコマンドでMarkdownをHTMLに変換できます。

pandoc -o output.html input.md

3. シンタックスハイライトを適用する

Bloggerでテーマの「HTMLを編集」を選択してテーマのHTMLを編集します。

<head> に以下を追加します。

<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/kotlin.min.js"></script>
<script>hljs.highlightAll();</script>

4. 記事をコピー

作成したHTMLファイルをエディタで開き、ファイルの内容をBloggerのHTMLビューへコピペします。最初のh1タグの内容はタイトルなので本文ではなくタイトルの方にコピペします(タグは不要)。

これでMarkdownファイルからBloggerの記事を作成することができます。

2025年2月1日土曜日

KotlinのElvis演算子でUnitはどう評価される?

概要

KotlinのElvis演算子ではUnit型はどのように扱われるでしょうか? この記事では具体的な挙動について説明します。

Unitとは

KotlinではUnitは値を返さない関数の戻り値として使用されます。Kotlinの型システムではUnitは非nullなので、nullとして扱われることはありません。

Elvis演算子の動作

Elvis演算子(?:)は、左辺の値がnullの場合に右辺の値を返します。しかし、Unitは常に非nullなため、Elvis演算子の左辺にUnitを返す関数を指定しても右辺の値は評価されません。

具体例

以下のコードの場合、

fun printMessage() {
    println("Hello, World!")
}

fun main() {
    val result = printMessage() ?: "Default Message"
    println(result)
}

printMessage()関数は常にUnitを返します。Elvis演算子の左辺はUnit(非null)であるため右辺の"Default Message"は評価されず、resultにはUnitが代入されます。その結果、println(result)kotlin.Unitと出力されます。

まとめ

  • UnitはKotlinの型システム上、常に非null
  • Elvis演算子は左辺がnullのときにのみ右辺を評価する
  • UnitをElvis演算子の左辺に置くと、右辺の評価は行われない

ちょっとあやふやなままElvis演算子を使っていたこともありましたが、Unitは値であると考えればすっきり理解できます。

2025年1月26日日曜日

Androidアプリのログの文字列を遅延評価する方法

Androidでログ出力を行う際、実際には出力されないログレベルの場合でも複雑な文字列の作成をしてしまっていることがあります。そこで、文字列を遅延評価して不要な文字列作成コストを削減する方法を解説します。

ログ出力のパフォーマンス問題

以下のコードで複雑なデータ構造を文字列化しているとします。

Log.d("TAG", "現在の状態: ${getComplexState()}")

getComplexState()関数が複雑な処理を行って文字列を生成する場合、ログ出力が行われなくても常にこの処理が実行されてしまいます。

遅延評価とは

遅延評価とは、処理が必要になるまでその実行を遅らせるというプログラミング方法のことです。Kotlinではlazyデリゲートを使って簡単に遅延評価できます。

val message by lazy { "現在の状態: ${getComplexState()}" }
Log.d("TAG", message)

この場合、message変数が実際に使われるまでgetComplexState()関数の呼び出しと文字列の作成は行われません。つまり、ログが出力されない場合はgetComplexState()関数は呼び出されず、無駄な処理をしなくて済みます。

遅延評価ログ関数を作る

遅延評価を簡単に行うためのログ関数を作成してみます。以下のコードはラムダ式を使って遅延評価を行う関数の例です。

inline fun logDebug(tag: String, message: () -> String) {
    if (Log.isLoggable(tag, Log.DEBUG)) {
        Log.d(tag, message())
    }
}

この関数ではログレベルがDEBUG以上の場合にのみ文字列が評価されます。

文字列を遅延評価できるログライブラリ

既存のログライブラリの中には文字列を遅延評価できるものもあります。

Timber

Androidでは非常に有名なログライブラリです。

Timber.d("現在の状態: %s", getComplexState())

この場合getComplexState()自体は常に実行されてしまうため、getComplexState()は文字列を作成するのではなく、データオブジェクトを返すだけにしたほうがいいです。文字列のフォーマットは必要な場合のみ行われます。

Timber: https://github.com/JakeWharton/timber

KmLogging

KmLoggingはKotlin Multiplatform対応の軽量なログライブラリです。こちらはラムダ式を使ってログメッセージを渡すことができます。

KmLogging: https://github.com/LighthouseGames/KmLogging

KmLogging.debug { "現在の状態: ${getComplexState()}" }

ラムダ式の中に複雑な処理を閉じ込めてしまえば必要以上に実行コストを使うことは無くなります。

Napier

NapierもKotlin Multiplatform対応のログライブラリです。文字列の遅延評価もサポートしています。

Napier.d { "現在の状態: ${getComplexState()}" }

Napier: https://github.com/LighthouseGames/KmLogging

まとめ

ログの文字列を遅延評価することでパフォーマンスの向上が期待できます。ついつい何も考えずにログに出力しがちですが、繰り返しが多い部分などは遅延評価を使うことによって本番コードの軽量化を図りましょう。