Jetpack Compose の高度な状態と副作用

1. はじめに

この Codelab では、Jetpack Compose状態副作用の API に関する高度なコンセプトについて学習します。ロジックが単純でないステートフル コンポーザブルの状態ホルダーを作成する方法、コルーチンを作成して Compose コードから suspend 関数を呼び出す方法、さまざまなユースケースを実現するために副作用をトリガーする方法について説明します。

この Codelab の学習を進める際のサポートとして、次の Code-Along 動画をご覧ください。

学習内容

必要なもの

作成するアプリの概要

この Codelab では、未完成のアプリである Crane マテリアル学習アプリをもとに、機能を追加してアプリを改善します。

b2c6b8989f4332bb.gif

2. 設定方法

コードを取得する

この Codelab のコードは、android-compose-codelabs GitHub リポジトリにあります。クローンを作成するには、次のコマンドを実行します。

$ git clone https://github.com/android/codelab-android-compose

または、リポジトリを ZIP ファイルとしてダウンロードすることもできます。

サンプルアプリを確認する

ダウンロードしたコードには、利用可能なすべての Compose Codelab のコードが含まれています。この Codelab を完了するには、Android Studio 内で AdvancedStateAndSideEffectsCodelab プロジェクトを開きます。

main ブランチのコードから始め、ご自身のペースで Codelab を進めることをおすすめします。

Codelab の途中には、プロジェクトに追加する必要があるコード スニペットを記載しています。場所によってはコードを削除する必要もありますが、この部分はコード スニペットのコメントに明示的に記載されています。

コードに慣れ、サンプルアプリを実行する

プロジェクト構造を調べて、アプリを実行してみましょう。

162c42b19dafa701.png

main ブランチからアプリを実行すると、ドロワーやフライトの目的地の読み込みといった一部の機能が動作しないことがわかります。これについては、Codelab の次のステップで対応します。

b2c6b8989f4332bb.gif

UI テスト

このアプリには、androidTest フォルダにあるごく基本的な UI テストを利用できます。常に、main ブランチと end ブランチの両方について合格する必要があります。

[省略可] 詳細画面に地図を表示する

進めるうえで、詳細画面に市街地図を表示する必要はまったくありません。ただし、表示する場合は、API キーを使用するにあるように個人の API キーを取得する必要があります。そのキーを次のように local.properties ファイルに記載します。

// local.properties file
google.maps.key={insert_your_api_key_here}

Codelab の解答

git を使用して end ブランチを取得するには、次のコマンドを使用します。

$ git clone -b end https://github.com/android/codelab-android-compose

または、次のリンク先から解答コードをダウンロードできます。

よくある質問

3.UI 状態生成パイプライン

main ブランチからアプリを実行したときにお気づきだと思いますが、フライトの目的地のリストが空になっています。

これを解決するには、次の 2 つのステップが必要です。

  • ViewModel にロジックを追加して UI 状態を生成します。ここでは、これが目的地候補のリストです。
  • UI から UI 状態を使用し、画面に UI を表示します。

このセクションでは、最初のステップを完了します。

アプリの優れたアーキテクチャは、適切な基本的システム設計のプラクティス(関心の分離やテストの容易性など)に基づいて、レイヤで構成されます。

UI 状態生成とは、アプリがデータレイヤにアクセスして、必要に応じてビジネスルールを適用し、UI 状態を公開して UI から使用できるようにするプロセスです。

このアプリのデータレイヤはすでに実装されています。ここからは、状態(目的地候補のリスト)を UI で使用できるように、その UI 状態を生成しましょう。

UI 状態の生成にあたっては、利用可能な API がいくつかあります。代替の方法については、状態生成パイプラインの出力タイプのドキュメントをご覧ください。一般的に、Kotlin の StateFlow を使用して UI 状態を生成することをおすすめします。

UI 状態を生成する手順は次のとおりです。

  1. home/MainViewModel.kt を開きます。
  2. 目的地候補のリストとして MutableStateFlow 型のプライベート変数 _suggestedDestinations を定義し、空のリストを開始値に設定します。
private val _suggestedDestinations = MutableStateFlow<List<ExploreModel>>(emptyList())
  1. StateFlow 型の 2 つ目の不変変数 suggestedDestinations を定義します。これは読み取り専用のパブリック変数で、UI から使用できるものです。可変変数を内部で使用するときには、読み取り専用変数を公開することをおすすめします。こうすることで、UI 状態は ViewModel を介さない限り変更ができなくなり、信頼できる唯一の情報源となります。拡張関数 asStateFlow によって、フローが可変から不変に変換されます。
private val _suggestedDestinations = MutableStateFlow<List<ExploreModel>>(emptyList())

val suggestedDestinations: StateFlow<List<ExploreModel>> = _suggestedDestinations.asStateFlow()
  1. ViewModel の init ブロックで、destinationsRepository からの呼び出しを追加して、データレイヤから目的地を取得します。
private val _suggestedDestinations = MutableStateFlow<List<ExploreModel>>(emptyList())

val suggestedDestinations: StateFlow<List<ExploreModel>> = _suggestedDestinations.asStateFlow()

init {
    _suggestedDestinations.value = destinationsRepository.destinations
}
  1. 最後に、このクラスにある内部変数 _suggestedDestinations の使用方法のコメント化を解除し、UI からのイベントで適切に更新できるようにします。

これで最初のステップは完了です。ViewModel が UI 状態を生成できるようになりました。次のステップでは、UI からこの状態を使用します。

4. ViewModel から Flow を安全に使用する

フライトの目的地のリストは、まだ空のままです。前のステップでは、MainViewModel で UI 状態を生成しました。ここでは、MainViewModel により公開される UI 状態を使用して UI に表示します。

home/CraneHome.kt ファイルを開き、CraneHomeContent コンポーザブルを確認します。

suggestedDestinations の定義の上に TODO コメントがあり、記憶された空のリストに割り当てられています。これが画面に表示されている空のリストです。このステップでは、これを修正して、MainViewModel が公開する目的地候補を表示します。

66ae2543faaf2e91.png

home/MainViewModel.kt を開き、suggestedDestinations StateFlow を見てみましょう。これは destinationsRepository.destinations に初期化され、updatePeople 関数か toDestinationChanged 関数が呼び出されると更新されます。

CraneHomeContent コンポーザブルの UI が、suggestedDestinations ストリームに新しいデータが出力されるたびに更新されるようにしましょう。この場合、collectAsStateWithLifecycle() 関数を使用できます。collectAsStateWithLifecycle()StateFlow から値を収集し、Compose の State API を介してライフサイクル対応の方法で最新の値を表します。これにより、その状態値を読み取る Compose コードが、新しい出力で再コンポーズされます。

collectAsStateWithLifecycle API の使用を開始するには、まず次の依存関係app/build.gradle に追加します。変数 lifecycle_version は、適切なバージョンのプロジェクトですでに定義されています。

dependencies {
    implementation "androidx.lifecycle:lifecycle-runtime-compose:$lifecycle_version"
}

CraneHomeContent コンポーザブルに戻り、suggestedDestinations を割り当てる行を、ViewModelsuggestedDestinations プロパティにおける collectAsStateWithLifecycle の呼び出しに置き換えます。

import androidx.lifecycle.compose.collectAsStateWithLifecycle

@Composable
fun CraneHomeContent(
    onExploreItemClicked: OnExploreItemClicked,
    openDrawer: () -> Unit,
    modifier: Modifier = Modifier,
    viewModel: MainViewModel = viewModel(),
) {
    val suggestedDestinations by viewModel.suggestedDestinations.collectAsStateWithLifecycle()
    // ...
}

アプリを実行すると、目的地のリストが入力されていて、フライト利用者の人数をタップするたびにこのリストが変更されます。

d656748c7c583eb8.gif

5. LaunchedEffect と rememberUpdatedState

プロジェクトには、今は使用されていない home/LandingScreen.kt ファイルがあります。ランディング スクリーンをアプリに追加して、必要なすべてのデータをバックグラウンドで読み込めるようにします。

ランディング スクリーンは画面全体を占め、アプリのロゴが画面の中央に表示されます。理想的には、この画面を表示してデータがすべて読み込まれた後、onTimeout コールバックを使用して、ランディング スクリーンを閉じてもよい状態であることを呼び出し元に通知します。

Kotlin コルーチンは、Android で非同期処理を行う際に推奨される方法です。アプリは通常、コルーチンを使用して起動時にバックグラウンドでデータを読み込みます。Jetpack Compose には、UI レイヤ内でコルーチンを安全に使用するための API が用意されています。このアプリはバックエンドと通信しないため、コルーチンの delay 関数を使用して、バックグラウンドでの読み込みをシミュレートします。

Compose の副作用は、コンポーズ可能な関数のスコープ外で発生するアプリの状態の変化です。ランディング スクリーンの表示 / 非表示の状態変化は onTimeout コールバックで発生します。onTimeout を呼び出す前にコルーチンを使用して読み込む必要があるため、状態変化はコルーチンのコンテキスト内で発生する必要があります。

コンポーザブル内から suspend 関数を安全に呼び出すには、LaunchedEffect API を使用します。これにより、Compose でコルーチン スコープの副作用がトリガーされます。

LaunchedEffect が Composition に入ると、コードブロックがパラメータとして渡されたコルーチンが起動されます。LaunchedEffect が Composition から退場すると、コルーチンはキャンセルされます。

次のコードは正しくありませんが、この API の使用方法を確認し、間違っている理由について考えてみましょう。このステップで後ほど LandingScreen コンポーザブルを呼び出します。

// home/LandingScreen.kt file

import androidx.compose.runtime.LaunchedEffect
import kotlinx.coroutines.delay

@Composable
fun LandingScreen(onTimeout: () -> Unit, modifier: Modifier = Modifier) {
    Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
        // Start a side effect to load things in the background
        // and call onTimeout() when finished.
        // Passing onTimeout as a parameter to LaunchedEffect
        // is wrong! Don't do this. We'll improve this code in a sec.
        LaunchedEffect(onTimeout) {
            delay(SplashWaitTime) // Simulates loading things
            onTimeout()
        }
        Image(painterResource(id = R.drawable.ic_crane_drawer), contentDescription = null)
    }
}

LaunchedEffect などの一部の副作用 API は、キーのいずれかが変更されるたびに作用を再開するために使用されるパラメータとして、可変数のキーを受け取ります。エラーは見つかったでしょうか。このコンポーズ可能な関数の呼び出し元が別の onTimeout ラムダ値を渡した場合は、LaunchedEffect を起動しないようにする必要があります。これによって再び delay が始まり、要件を満たせなくなります。

この問題を修正しましょう。このコンポーザブルのライフサイクル中に副作用を 1 回だけトリガーするには、定数をキーとして使用します(例: LaunchedEffect(Unit) { ... })。ただし、ここで別の問題があります。

副作用の進行中に onTimeout が変更された場合、作用が終了したときに最後の onTimeout が呼び出されるという保証はありません。最後の onTimeout が呼び出されることを保証するには、rememberUpdatedState API を使用して onTimeout を記憶します。この API は、最新の値をキャプチャして更新します。

// home/LandingScreen.kt file

import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import kotlinx.coroutines.delay

@Composable
fun LandingScreen(onTimeout: () -> Unit, modifier: Modifier = Modifier) {
    Box(modifier = modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
        // This will always refer to the latest onTimeout function that
        // LandingScreen was recomposed with
        val currentOnTimeout by rememberUpdatedState(onTimeout)

        // Create an effect that matches the lifecycle of LandingScreen.
        // If LandingScreen recomposes or onTimeout changes,
        // the delay shouldn't start again.
        LaunchedEffect(Unit) {
            delay(SplashWaitTime)
            currentOnTimeout()
        }

        Image(painterResource(id = R.drawable.ic_crane_drawer), contentDescription = null)
    }
}

長期にわたるラムダまたはオブジェクト式がコンポジション中に計算されたパラメータまたは値を参照する場合は、rememberUpdatedState を使用する必要があります。これは LaunchedEffect を扱うときに一般的です。

ランディング スクリーンの表示

アプリを開いたときにランディング スクリーンを表示する必要があります。home/MainActivity.kt ファイルを開き、最初に呼び出される MainScreen コンポーザブルをチェックします。

MainScreen コンポーザブルに、ランディングを表示するかどうかを追跡する内部状態を追加するだけです。

// home/MainActivity.kt file

import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue

@Composable
private fun MainScreen(onExploreItemClicked: OnExploreItemClicked) {
    Surface(color = MaterialTheme.colors.primary) {
        var showLandingScreen by remember { mutableStateOf(true) }
        if (showLandingScreen) {
            LandingScreen(onTimeout = { showLandingScreen = false })
        } else {
            CraneHome(onExploreItemClicked = onExploreItemClicked)
        }
    }
}

アプリを実行すると、LandingScreen が表示され、2 秒後に消えます。

e3fd932a5b95faa0.gif

6. rememberCoroutineScope

このステップでは、ナビゲーション ドロワーを機能させます。現時点では、ハンバーガー メニューをタップしても何も起こりません。

home/CraneHome.kt ファイルを開き、CraneHome コンポーザブルをチェックして、ナビゲーション ドロワーを開く必要がある場所を確認します。それは openDrawer コールバックです。

CraneHome には scaffoldState があり、DrawerState が含まれています。DrawerState には、ナビゲーション ドロワーの開閉をプログラムで行うメソッドが用意されています。しかし、openDrawer コールバックで scaffoldState.drawerState.open() を書き込もうとすると、エラーが発生します。これは、open 関数が suspend 関数であるためです。再びコルーチンの領域に入りました。

コルーチンを UI レイヤから安全に呼び出す API は別として、一部の Compose API は suspend 関数です。その一例が、ナビゲーション ドロワーを開く API です。suspend 関数は、非同期コードを実行できるだけでなく、時間の経過とともに起こるコンセプトを表すためにも役立ちます。ドロワーを開くには、ある程度の時間、移動、アニメーションが必要であるため、これは suspend 関数に完全に反映され、呼び出されたコルーチンの実行は一時停止されます(終了し実行が再開されるまで)。

scaffoldState.drawerState.open() はコルーチン内で呼び出す必要があります。どうすればよいでしょうか。openDrawer は単純なコールバック関数であるため、次のような制約があります。

  • その中で単純に suspend 関数を呼び出すことはできません。これは openDrawer がコルーチンのコンテキストでは実行されないためです。
  • 先ほどのように LaunchedEffect を使用することはできません。これは openDrawer でコンポーザブルを呼び出すことができないためです。今は Composition 内ではありません。

コルーチンを起動するには、どのスコープを使用すればよいでしょうか。コールサイトのライフサイクルに沿った CoroutineScope を作成することが理想的です。rememberCoroutineScope API を使用すると、Composition 内の呼び出すポイントにバインドされた CoroutineScope が返されます。スコープは、Composition から出ると自動的にキャンセルされます。このスコープを使用すると、Composition 内ではないときにコルーチンを開始できます(openDrawer コールバック内など)。

// home/CraneHome.kt file

import androidx.compose.runtime.rememberCoroutineScope
import kotlinx.coroutines.launch

@Composable
fun CraneHome(
    onExploreItemClicked: OnExploreItemClicked,
    modifier: Modifier = Modifier,
) {
    val scaffoldState = rememberScaffoldState()
    Scaffold(
        scaffoldState = scaffoldState,
        modifier = Modifier.statusBarsPadding(),
        drawerContent = {
            CraneDrawer()
        }
    ) {
        val scope = rememberCoroutineScope()
        CraneHomeContent(
            modifier = modifier,
            onExploreItemClicked = onExploreItemClicked,
            openDrawer = {
                scope.launch {
                    scaffoldState.drawerState.open()
                }
            }
        )
    }
}

アプリを起動してハンバーガー メニュー アイコンをタップすると、ナビゲーション ドロワーが開くことがわかります。

92957c04a35e91e3.gif

LaunchedEffect と rememberCoroutineScope の比較

このケースでは LaunchedEffect を使用できませんでした。Composition の外部にある通常のコールバックで、コルーチンを作成するための呼び出しをトリガーする必要があったためです。

LaunchedEffect を使用したランディング スクリーン ステップを振り返ると、LaunchedEffect を使用する代わりに rememberCoroutineScope を使用し、scope.launch { delay(); onTimeout(); } を呼び出すことができたとも思われます。

そうすると、正しく機能しているように見えますが、正しくはありません。Compose の思想のドキュメントに記載されているように、コンポーザブルはいつでも Compose で呼び出すことができます。LaunchedEffect は、コンポーザブルの呼び出しによって Composition に入ったときに副作用が実行されることを保証します。LandingScreen の本文で rememberCoroutineScopescope.launch を使用すると、LandingScreen が Compose によって呼び出されるたびに、その呼び出しによって Composition に入ったかどうかにかかわらず、コルーチンが実行されます。その結果、リソースを浪費することになり、この副作用を制御された環境で実行することがなくなります。

7. 状態ホルダーの作成

[Choose Destination] をタップすると、フィールドを編集でき、入力した検索語句に基づいて都市をフィルタできます。また、お気づきのとおり、[Choose Destination] を変更するたびに、テキスト スタイルが変更されます。

dde9ef06ca4e5191.gif

base/EditableUserInput.kt ファイルを開きます。CraneEditableUserInput ステートフル コンポーザブルは、hint や、アイコンの横にあるオプションのテキストに対応する caption などのパラメータを取ります。たとえば、caption の To は、目的地を検索するときに表示されます。

// base/EditableUserInput.kt file - code in the main branch

@Composable
fun CraneEditableUserInput(
    hint: String,
    caption: String? = null,
    @DrawableRes vectorImageId: Int? = null,
    onInputChanged: (String) -> Unit
) {
    // TODO Codelab: Encapsulate this state in a state holder
    var textState by remember { mutableStateOf(hint) }
    val isHint = { textState == hint }

    ...
}

理由

textState を更新して表示内容がヒントに対応しているかどうかを判定するロジックは、CraneEditableUserInput コンポーザブルの本文内にあります。この方法には欠点があります。

  • TextField の値はホイストされないため、外部から制御できず、テストが難しくなります。
  • このコンポーザブルのロジックは複雑になり、内部状態が非同期になりやすくなる可能性があります。

このコンポーザブルの内部状態を担当する状態ホルダーを作成することで、すべての状態変更を一元化できます。これにより、状態が非同期になりにくくなり、関連するロジックがすべて 1 つのクラスにまとめられます。さらに、この状態は簡単にホイストでき、このコンポーザブルの呼び出し元から使用できます。

この場合、アプリの他の部分で再利用される可能性のある低レベルの UI コンポーネントであるため、状態のホイスティングをおすすめします。柔軟性や制御性が高いほど良いでしょう。

状態ホルダーの作成

CraneEditableUserInput は再利用可能なコンポーネントであるため、次のように、同じファイルに EditableUserInputState という状態ホルダーとして通常のクラスを作成します。

// base/EditableUserInput.kt file

import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue

class EditableUserInputState(private val hint: String, initialText: String) {

    var text by mutableStateOf(initialText)
       private set

    fun updateText(newText: String) {
       text = newText
    }

    val isHint: Boolean
        get() = text == hint
}

クラスの特徴は次のとおりです。

  • textCraneEditableUserInput の場合と同様、String 型の可変状態です。mutableStateOf を使用して、Compose が値の変更を追跡し、変更が発生したら再コンポーズするようにすることが重要です。
  • text は、非公開の set を持つ var であるため、クラスの外部から直接変更できません。この変数を公開する代わりに、updateText イベントを公開して変更することで、クラスを信頼できる唯一の情報源にできます。
  • クラスは、text の初期化に使用する依存関係として initialText を取ります。
  • text がヒントかどうかを確認するロジックは、オンデマンドでチェックを行う isHint プロパティにあります。

将来的にロジックが複雑になっても、1 つのクラス(EditableUserInputState)に変更を加えるだけで済みます。

状態ホルダーの記憶

毎回新規作成せず Composition に維持するために、状態ホルダーを常に記憶しておく必要があります。これを行うメソッドを同じファイルに作成し、ボイラープレートを削除して、起こり得るミスを防止することをおすすめします。base/EditableUserInput.kt ファイルに、次のコードを追加します。

// base/EditableUserInput.kt file

@Composable
fun rememberEditableUserInputState(hint: String): EditableUserInputState =
    remember(hint) {
        EditableUserInputState(hint, hint)
    }

この状態を remember するだけでは、アクティビティの再作成後に維持されません。これを実現するには、代わりに rememberSaveable API を使用します。この API は remember と同様に動作しますが、保存された値はアクティビティとプロセスの再作成後も維持されます。内部的には、保存されたインスタンスの状態のメカニズムを使用します。

rememberSaveable は、Bundle 内に格納できるオブジェクトについて、余分な作業をせずにこれらすべてを行います。しかし、このプロジェクトで作成した EditableUserInputState クラスではそうなりません。そのため、Saver を使用して、このクラスのインスタンスを保存および復元する方法を rememberSaveable に伝える必要があります。

カスタムのセーバーの作成

Saver には、オブジェクトを Saveable なものに変換する方法を記述します。Saver の実装では、次の 2 つの関数をオーバーライドする必要があります。

  • save: 元の値を保存可能な値に変換します。
  • restore: 復元した値を元のクラスのインスタンスに変換します。

今回の場合、EditableUserInputState クラスに Saver のカスタム実装を作成するのではなく、listSavermapSaver(保存する値を List または Map に格納する)などの既存の Compose API を利用することで、記述するコードの量を削減できます。

Saver の定義は、使用するクラスの近くに配置することをおすすめします。静的にアクセスする必要があるため、EditableUserInputStateSavercompanion object に追加します。base/EditableUserInput.kt ファイルに Saver の実装を追加します。

// base/EditableUserInput.kt file

import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.listSaver

class EditableUserInputState(private val hint: String, initialText: String) {
    var text by mutableStateOf(initialText)

    val isHint: Boolean
        get() = text == hint

    companion object {
        val Saver: Saver<EditableUserInputState, *> = listSaver(
            save = { listOf(it.hint, it.text) },
            restore = {
                EditableUserInputState(
                    hint = it[0],
                    initialText = it[1],
                )
            }
        )
    }
}

この例では、実装の詳細として listSaver を使用し、EditableUserInputState のインスタンスを saver に保存、復元しています。

これで、前に作成した rememberEditableUserInputState メソッドの rememberSaveableremember ではない)で、この saver を使用できるようになりました。

// base/EditableUserInput.kt file
import androidx.compose.runtime.saveable.rememberSaveable

@Composable
fun rememberEditableUserInputState(hint: String): EditableUserInputState =
    rememberSaveable(hint, saver = EditableUserInputState.Saver) {
        EditableUserInputState(hint, hint)
    }

これにより、EditableUserInput の記憶された状態は、プロセスとアクティビティの再作成後も維持されます。

状態ホルダーの使用

textisHint の代わりに EditableUserInputState を使用しますが、呼び出し元コンポーザブルが状態を制御する方法がないため、単に CraneEditableUserInput の内部状態としては使用しません。代わりに、呼び出し元が CraneEditableUserInput の状態を制御できるように EditableUserInputState をホイスティングします。状態をホイスティングすると、コンポーザブルをプレビューで使用できるようになり、呼び出し元から状態を変更できるためテストが容易になります。

そのためには、コンポーズ可能な関数のパラメータを変更し、必要な場合に備えてデフォルト値を指定する必要があります。空のヒントで CraneEditableUserInput を許可する場合があるため、デフォルトの引数を追加します。

@Composable
fun CraneEditableUserInput(
    state: EditableUserInputState = rememberEditableUserInputState(""),
    caption: String? = null,
    @DrawableRes vectorImageId: Int? = null
) { /* ... */ }

onInputChanged パラメータがなくなっていることにお気づきでしょうか。状態をホイスティングできるため、呼び出し元が入力の変更を把握する必要がある場合は、状態を制御して、その状態をこの関数に渡せます。

次に、前に使用した内部状態ではなくホイスティングされた状態を使用するように、関数本文を調整する必要があります。リファクタリング後の関数は次のようになります。

@Composable
fun CraneEditableUserInput(
    state: EditableUserInputState = rememberEditableUserInputState(""),
    caption: String? = null,
    @DrawableRes vectorImageId: Int? = null
) {
    CraneBaseUserInput(
        caption = caption,
        tintIcon = { !state.isHint },
        showCaption = { !state.isHint },
        vectorImageId = vectorImageId
    ) {
        BasicTextField(
            value = state.text,
            onValueChange = { state.updateText(it) },
            textStyle = if (state.isHint) {
                captionTextStyle.copy(color = LocalContentColor.current)
            } else {
                MaterialTheme.typography.body1.copy(color = LocalContentColor.current)
            },
            cursorBrush = SolidColor(LocalContentColor.current)
        )
    }
}

状態ホルダーの呼び出し元

CraneEditableUserInput の API を変更したため、その呼び出し元をすべてチェックして、適切なパラメータを渡すようにする必要があります。

プロジェクトでこの API を呼び出す場所は、home/SearchUserInput.kt ファイルのみです。これを開き、コンポーズ可能な関数 ToDestinationUserInput に移動すると、ビルドエラーが表示されます。ヒントが状態ホルダーの一部になり、Composition で CraneEditableUserInput のこのインスタンスにカスタムのヒントが必要になるため、状態を ToDestinationUserInput レベルで記憶して CraneEditableUserInput に渡す必要があります。

// home/SearchUserInput.kt file

import androidx.compose.samples.crane.base.rememberEditableUserInputState

@Composable
fun ToDestinationUserInput(onToDestinationChanged: (String) -> Unit) {
    val editableUserInputState = rememberEditableUserInputState(hint = "Choose Destination")
    CraneEditableUserInput(
        state = editableUserInputState,
        caption = "To",
        vectorImageId = R.drawable.ic_plane
    )
}

snapshotFlow

上のコードには、入力が変更されたときに ToDestinationUserInput の呼び出し元に通知する機能がありません。アプリの構造上、階層内で EditableUserInputState を上位にホイスティングすることは避ける必要があります。FlySearchContent などの他のコンポーザブルをこの状態に結合することは望ましくありません。ToDestinationUserInput から onToDestinationChanged ラムダを呼び出し、このコンポーザブルを再利用可能なままにするにはどうすればよいでしょうか。

入力が変更されるたびに LaunchedEffect を使用して副作用をトリガーし、onToDestinationChanged ラムダを呼び出せます。

// home/SearchUserInput.kt file

import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.snapshotFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.filter

@Composable
fun ToDestinationUserInput(onToDestinationChanged: (String) -> Unit) {
    val editableUserInputState = rememberEditableUserInputState(hint = "Choose Destination")
    CraneEditableUserInput(
        state = editableUserInputState,
        caption = "To",
        vectorImageId = R.drawable.ic_plane
    )

    val currentOnDestinationChanged by rememberUpdatedState(onToDestinationChanged)
    LaunchedEffect(editableUserInputState) {
        snapshotFlow { editableUserInputState.text }
            .filter { !editableUserInputState.isHint }
            .collect {
                currentOnDestinationChanged(editableUserInputState.text)
            }
    }
}

LaunchedEffectrememberUpdatedState は前に使用したことがありますが、上のコードでは新しい API も使用しています。snapshotFlow API は、Compose の State<T> オブジェクトを Flow に変換します。snapshotFlow 内で読み取られた状態が変化すると、Flow は新しい値をコレクタに出力します。今回は、Flow 演算子を利用するために、状態を Flow に変換します。これにより、texthint でない場合に filter を行い、出力されたアイテムを collect して、現在の目的地が変更されたことを親に通知します。

Codelab のこのステップに見た目の変更はありませんが、コードのこの部分の質が向上しました。アプリを実行すると、すべてが以前と同様に動作していることがわかります。

8. DisposableEffect

目的地をタップすると詳細画面が開き、地図上でその都市の位置を確認できます。このコードは details/DetailsActivity.kt ファイルにあります。CityMapView コンポーザブルでは、rememberMapViewWithLifecycle 関数を呼び出しています。details/MapViewUtils.kt ファイル内にあるこの関数を開くと、ライフサイクルに接続されていないことがわかります。MapView を記憶し、onCreate を呼び出すだけです。

// details/MapViewUtils.kt file - code in the main branch

@Composable
fun rememberMapViewWithLifecycle(): MapView {
    val context = LocalContext.current
    // TODO Codelab: DisposableEffect step. Make MapView follow the lifecycle
    return remember {
        MapView(context).apply {
            id = R.id.map
            onCreate(Bundle())
        }
    }
}

アプリが正常に動作しても、MapView が正しいライフサイクルに従っていないため、これは問題です。そのため、アプリがバックグラウンドに移行するタイミングや View を一時停止するタイミングなどはわかりません。これを修正しましょう。

MapView は View であってコンポーザブルではないため、Composition のライフサイクルと同様に、使用されるアクティビティのライフサイクルにも従うようにします。つまり、LifecycleEventObserver を作成してライフサイクル イベントをリッスンし、MapView の適切なメソッドを呼び出す必要があります。その後、このオブザーバーを現在のアクティビティのライフサイクルに追加する必要があります。

まずは、特定のイベントで MapView の対応するメソッドを呼び出す LifecycleEventObserver を返す関数を作成します。

// details/MapViewUtils.kt file

import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver

private fun getMapLifecycleObserver(mapView: MapView): LifecycleEventObserver =
    LifecycleEventObserver { _, event ->
        when (event) {
            Lifecycle.Event.ON_CREATE -> mapView.onCreate(Bundle())
            Lifecycle.Event.ON_START -> mapView.onStart()
            Lifecycle.Event.ON_RESUME -> mapView.onResume()
            Lifecycle.Event.ON_PAUSE -> mapView.onPause()
            Lifecycle.Event.ON_STOP -> mapView.onStop()
            Lifecycle.Event.ON_DESTROY -> mapView.onDestroy()
            else -> throw IllegalStateException()
        }
    }

次に、このオブザーバーを現在のライフサイクルに追加する必要があります。その際、現在の LifecycleOwnerLocalLifecycleOwner コンポジション ローカルで使用できます。ただし、オブザーバーを追加するだけでは不十分です。削除もできる必要があります。クリーンアップ コードを実行できるように、作用が Composition から出るタイミングを知らせる副作用が必要です。必要な副作用 API は DisposableEffect です。

DisposableEffect は、キーが変更された後またはコンポーザブルが Composition から出た後にクリーンアップする必要がある副作用のためのものです。最後の rememberMapViewWithLifecycle コードが、まさにそれです。プロジェクトに次の行を実装します。

// details/MapViewUtils.kt file

import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.platform.LocalLifecycleOwner

@Composable
fun rememberMapViewWithLifecycle(): MapView {
    val context = LocalContext.current
    val mapView = remember {
        MapView(context).apply {
            id = R.id.map
        }
    }

    val lifecycle = LocalLifecycleOwner.current.lifecycle
    DisposableEffect(key1 = lifecycle, key2 = mapView) {
        // Make MapView follow the current lifecycle
        val lifecycleObserver = getMapLifecycleObserver(mapView)
        lifecycle.addObserver(lifecycleObserver)
        onDispose {
            lifecycle.removeObserver(lifecycleObserver)
        }
    }

    return mapView
}

オブザーバーは現在の lifecycle に追加され、現在のライフサイクルが変更されるか、このコンポーザブルが Composition から出ると削除されます。DisposableEffectkey では、lifecycle または mapView が変更されると、オブザーバーが削除されて正しい lifecycle に再び追加されます。

ここまでの変更により、MapView は常に現在の LifecycleOwnerlifecycle に従い、その動作は View で使用されているかのようになります。

アプリを実行して詳細画面を開き、MapView が引き続き適切にレンダリングされることを確認してみましょう。このステップに見た目の変更はありません。

9. produceState

このセクションでは、詳細画面の起動方法を改善します。details/DetailsActivity.kt ファイルの DetailsScreen コンポーザブルは、ViewModel から cityDetails を同期的に取得し、結果が成功となった場合は DetailsContent を呼び出します。

ただし、cityDetails は UI スレッドでの読み込みコストが高くなり、コルーチンを使用してデータの読み込みを別のスレッドに移動する可能性があります。このコードを改善して読み込み画面を追加し、データの準備ができたら DetailsContent を表示するようにしてみましょう。

画面の状態をモデル化する方法として、あらゆる可能性(画面に表示するデータ、読み込みシグナル、エラーシグナルなど)を網羅した次のクラスがあります。DetailsUiState クラスを DetailsActivity.kt ファイルに追加します。

// details/DetailsActivity.kt file

data class DetailsUiState(
    val cityDetails: ExploreModel? = null,
    val isLoading: Boolean = false,
    val throwError: Boolean = false
)

画面に表示する必要のあるものと ViewModel レイヤの UiState をマッピングするには、データのストリーム、DetailsUiState 型の StateFlow を使用します。これは情報の準備が整ったときに ViewModel が更新し、Compose が既知の collectAsStateWithLifecycle() API で収集します。

ただし、この演習では別の実装を行います。uiState マッピング ロジックを Compose に移行する場合は、produceState API を使用できます。

produceState を使用すると、Compose 外の状態を Compose の Stateに変換できます。これは Composition をスコープとするコルーチンを起動し、value プロパティを使用して、返される State に値をプッシュできます。LaunchedEffect と同様に、produceState も計算のキャンセルと再開のためのキーを受け取ります。

このユースケースでは、次のように produceState を使用し、初期値を DetailsUiState(isLoading = true) として uiState のアップデートを出力します。

// details/DetailsActivity.kt file

import androidx.compose.runtime.produceState

@Composable
fun DetailsScreen(
    onErrorLoading: () -> Unit,
    modifier: Modifier = Modifier,
    viewModel: DetailsViewModel = viewModel()
) {

    val uiState by produceState(initialValue = DetailsUiState(isLoading = true)) {
        // In a coroutine, this can call suspend functions or move
        // the computation to different Dispatchers
        val cityDetailsResult = viewModel.cityDetails
        value = if (cityDetailsResult is Result.Success<ExploreModel>) {
            DetailsUiState(cityDetailsResult.data)
        } else {
            DetailsUiState(throwError = true)
        }
    }

    // TODO: ...
}

次に、uiState に応じて、データの表示、読み込み画面の表示、エラーの報告を行います。DetailsScreen コンポーザブルの完全なコードを次に示します。

// details/DetailsActivity.kt file

import androidx.compose.foundation.layout.Box
import androidx.compose.material.CircularProgressIndicator

@Composable
fun DetailsScreen(
    onErrorLoading: () -> Unit,
    modifier: Modifier = Modifier,
    viewModel: DetailsViewModel = viewModel()
) {
    val uiState by produceState(initialValue = DetailsUiState(isLoading = true)) {
        val cityDetailsResult = viewModel.cityDetails
        value = if (cityDetailsResult is Result.Success<ExploreModel>) {
            DetailsUiState(cityDetailsResult.data)
        } else {
            DetailsUiState(throwError = true)
        }
    }

    when {
        uiState.cityDetails != null -> {
            DetailsContent(uiState.cityDetails!!, modifier.fillMaxSize())
        }
        uiState.isLoading -> {
            Box(modifier.fillMaxSize()) {
                CircularProgressIndicator(
                    color = MaterialTheme.colors.onSurface,
                    modifier = Modifier.align(Alignment.Center)
                )
            }
        }
        else -> { onErrorLoading() }
    }
}

アプリを実行すると、都市の詳細が表示される前に読み込みスピナーがどのように表示されるかを確認できます。

aa8fd1ac660266e9.gif

10. derivedStateOf

Crane に対する最後の改善点として、フライトの目的地のリストをスクロールする際、画面上の最初の要素を通過した後に「一番上に移動」のボタンが常に表示されるようにします。このボタンをタップすると、リストの最初の要素に移動します。

2c112d73f48335e0.gif

このコードを含む base/ExploreSection.kt ファイルを開きます。ExploreSection コンポーザブルは、スキャフォールドの背景に表示されるものに対応します。

ユーザーが最初の要素を通過したかどうかを計算するには、LazyColumnLazyListState を使用して、listState.firstVisibleItemIndex > 0 であるかどうかを確認します。

単純な実装は次のようになります。

// DO NOT DO THIS - It's executed on every recomposition
val showButton = listState.firstVisibleItemIndex > 0

showButton を読み取るコンポーズ可能な関数の再コンポーズは firstVisibleItemIndex の変更と同じ頻度となり、スクロール時に頻繁に発生するため、このソリューションは効率的ではありません。代わりに、truefalse の間で条件が変わった場合にのみ関数を再コンポーズするようにします。

それができる API が derivedStateOf API です。

listState は監視可能な Compose State です。また、値が変更されたときに UI を再コンポーズし、ボタンの表示と非表示を切り替えるようにするために、showButton の計算は Compose State である必要があります。

別の State から派生した Compose State が必要な場合は、derivedStateOf を使用します。derivedStateOf 計算ブロックは内部状態が変化するたびに実行されますが、コンポーズ可能な関数は、計算結果が前回の結果と異なる場合にのみ再コンポーズします。これにより、showButton を読み取る関数が再コンポーズする時間を最小限に抑えることができます。

この場合、derivedStateOf API を使用するほうが適切で効率的な選択です。また、呼び出しを remember API でラップするため、計算値は再コンポーズ後も保持されます。

// Show the button if the first visible item is past
// the first item. We use a remembered derived state to
// minimize unnecessary recompositions
val showButton by remember {
    derivedStateOf {
        listState.firstVisibleItemIndex > 0
    }
}

ExploreSection コンポーザブルの新しいコードには、見覚えがあるはずです。Box を使用して、条件付きで表示される ButtonExploreList の上に配置しています。さらに rememberCoroutineScope を使用して、ButtononClick コールバック内で listState.scrollToItem suspend 関数を呼び出します。

// base/ExploreSection.kt file

import androidx.compose.material.FloatingActionButton
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.foundation.layout.navigationBarsPadding
import kotlinx.coroutines.launch

@Composable
fun ExploreSection(
    modifier: Modifier = Modifier,
    title: String,
    exploreList: List<ExploreModel>,
    onItemClicked: OnExploreItemClicked
) {
    Surface(modifier = modifier.fillMaxSize(), color = Color.White, shape = BottomSheetShape) {
        Column(modifier = Modifier.padding(start = 24.dp, top = 20.dp, end = 24.dp)) {
            Text(
                text = title,
                style = MaterialTheme.typography.caption.copy(color = crane_caption)
            )
            Spacer(Modifier.height(8.dp))
            Box(Modifier.weight(1f)) {
                val listState = rememberLazyListState()
                ExploreList(exploreList, onItemClicked, listState = listState)

                // Show the button if the first visible item is past
                // the first item. We use a remembered derived state to
                // minimize unnecessary compositions
                val showButton by remember {
                    derivedStateOf {
                        listState.firstVisibleItemIndex > 0
                    }
                }
                if (showButton) {
                    val coroutineScope = rememberCoroutineScope()
                    FloatingActionButton(
                        backgroundColor = MaterialTheme.colors.primary,
                        modifier = Modifier
                            .align(Alignment.BottomEnd)
                            .navigationBarsPadding()
                            .padding(bottom = 8.dp),
                        onClick = {
                            coroutineScope.launch {
                                listState.scrollToItem(0)
                            }
                        }
                    ) {
                        Text("Up!")
                    }
                }
            }
        }
    }
}

アプリを実行すると、画面上をスクロールして最初の要素を通過したときに、ボタンが下部に表示されます。

11. 完了

これで、この Codelab は終了です。Jetpack Compose アプリにおける状態と副作用の API の高度なコンセプトを学習しました。

状態ホルダーの作成方法や、LaunchedEffectrememberUpdatedStateDisposableEffectproduceStatederivedStateOf などの副作用 API、Jetpack Compose でコルーチンを使用する方法について学習しました。

次のステップ

Compose パスウェイに関する他の Codelab や、Crane を含む他のコードサンプルをご確認ください。

ドキュメント

これらのトピックに関する詳細とガイダンスについては、以下のドキュメントをご覧ください。