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

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

学習内容

必要なもの

作成するアプリの概要

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

1fb85e2ed0b8b592.gif

コードを取得する

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

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

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

ZIP をダウンロード

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

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

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

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

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

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

37d39b9ac4a9d2fa.png

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

1fb85e2ed0b8b592.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/googlecodelabs/android-compose-codelabs

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

最終版のコードをダウンロード

よくある質問

main ブランチからアプリを実行したときに気づいたかもしれませんが、フライトの目的地のリストは空です。何が起きているのか、home/CraneHome.kt ファイルを開いて CraneHomeContent コンポーザブルを確認してみましょう。

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

9cadb1fd5f4ced3c.png

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

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

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

import androidx.compose.runtime.collectAsState

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

アプリを実行すると、目的地のリストが入力されており、旅行者数をタップするたびに変更されることがわかります。

4ec666a2d1ac0903.gif

プロジェクトには、今は使用されていない 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(modifier: Modifier = Modifier, onTimeout: () -> Unit) {
    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 が変更されても作用が再開しないようにします。

このコンポーザブルのライフサイクル中に副作用を 1 回だけトリガーするには、定数をキーとして使用します(例: LaunchedEffect(true) { ... })。しかし、今は onTimeout の変更に対して保護されていません。

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

// home/LandingScreen.kt file

import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState

@Composable
fun LandingScreen(modifier: Modifier = Modifier, onTimeout: () -> Unit) {
    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(true) {
            delay(SplashWaitTime)
            currentOnTimeout()
        }

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

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

アプリを開いたときにランディング スクリーンを表示する必要があります。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 秒後に消えます。

fda616dda280aa3e.gif

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

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 から出ると自動的にキャンセルされます。このスコープを使用すると、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()
                }
            }
        )
    }
}

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

ad44883754b14efe.gif

LaunchedEffect と rememberCoroutineScope の比較

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

LaunchedEffect を使用したランディング スクリーン ステップを振り返ってみて、LaunchedEffect を使用するのではなく、rememberCoroutineScope を使用して scope.launch { delay(); onTimeout(); } を呼び出せますか。

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

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

99dec71d23aef084.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)

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

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

  • text は、CraneEditableUserInput の場合と同様、String 型の可変状態です。Compose が値の変更を追跡し、変更が発生したら再コンポーズするように、mutableStateOf を使用することが重要です。
  • textvar であるため、クラスの外部から直接変更できます。
  • クラスは、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 メソッドの(remember ではなく)rememberSaveable で、この 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.text = 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 の呼び出し元に通知する機能がありません。アプリの構造上、FlySearchContent などの他のコンポーザブルをこの状態に結合するため、階層内で EditableUserInputState を上位にホイストしたくありません。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 のこのステップに見た目の変更はありませんが、コードのこの部分の品質が向上しました。アプリを実行すると、すべてが以前と同様に動作していることがわかります。

目的地をタップすると詳細画面が開き、地図上でその都市の位置を確認できます。このコードは 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 が適切にレンダリングされることを確認してみましょう。このステップに見た目の変更はありません。

このセクションでは、詳細画面の起動方法を改善します。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 が既知の collectAsState() 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() }
    }
}

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

18956feb88725ca5.gif

Crane の最後の改善点は、フライトの目的地のリストをスクロールして最初の要素が画面から消えた後、スクロール中に「最初へ移動」ボタンが常に表示されるようにすることです。ボタンをタップすると、リストの最初の要素に移動します。

59d2d10bd334bdb.gif

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

動画で見た動作を実装するためのソリューションは、驚くようなものではありません。しかし、まだ見ていない新しい API、derivedStateOf API があり、これがこのユースケースでは重要です。

derivedStateOf は、別の State から派生した Compose State が必要な場合に使用します。この関数を使用すると、計算で使用される状態のいずれかが変化したときにのみ計算が行われることが保証されます。

listState を使用してユーザーが最初の要素を通過したかどうかを計算することは簡単で、listState.firstVisibleItemIndex > 0 をチェックするのみです。ただし、firstVisibleItemIndexmutableStateOf API にラップされているため、監視可能な Compose の State となっています。UI を再コンポーズしてボタンを表示することからこの計算も Compose の State である必要があります。

単純で非効率的な実装は、次の例のようになります。これをプロジェクトにコピーしないでください。正しい実装は、後で画面の残りのロジックとともにプロジェクトにコピーされます。

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

より適切かつ効率的な代替策として、listState.firstVisibleItemIndex が変更された場合にのみ showButton を計算する derivedStateOf API を使用する方法があります。

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

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

// 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 com.google.accompanist.insets.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!")
                    }
                }
            }
        }
    }
}

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

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

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

次のステップ

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

ドキュメント

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