1. はじめに
この Codelab では、Jetpack Compose の状態と副作用の API に関する高度なコンセプトについて学習します。ロジックが単純でないステートフル コンポーザブルの状態ホルダーを作成する方法、コルーチンを作成して Compose コードから suspend 関数を呼び出す方法、さまざまなユースケースを実現するために副作用をトリガーする方法について説明します。
この Codelab の学習を進める際のサポートとして、次の Code-Along 動画をご覧ください。
学習内容
- Compose コードからのデータのストリームを監視して UI を更新する方法。
- ステートフル コンポーザブルの状態ホルダーを作成する方法。
LaunchedEffect
、rememberUpdatedState
、DisposableEffect
、produceState
、derivedStateOf
などの副作用 API。rememberCoroutineScope
API を使用してコンポーザブルでコルーチンを作成し、suspend 関数を呼び出す方法。
必要なもの
- 最新の Android Studio
- ラムダを含む Kotlin 構文の使用経験。
- Compose に関する基本的な経験。この Codelab の前に Jetpack Compose の基本の Codelab を受講することを検討してください。
- 単方向データフロー(UDF)、ViewModel、状態ホイスティング、ステートレス / ステートフル コンポーザブル、スロット API、
remember
とmutableStateOf
の状態 API など、Compose の状態に関する基本的なコンセプト。この知識を得るには、状態と Jetpack Compose のドキュメント、または Jetpack Compose での状態の使用に関する Codelab をご覧ください。 - Kotlin コルーチンに関する基本的な知識。
- コンポーザブルのライフサイクルに関する基本的な知識。
作成するアプリの概要
この Codelab では、未完成のアプリである Crane マテリアル学習アプリをもとに、機能を追加してアプリを改善します。
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 の途中には、プロジェクトに追加する必要があるコード スニペットを記載しています。場所によってはコードを削除する必要もありますが、この部分はコード スニペットのコメントに明示的に記載されています。
コードに慣れ、サンプルアプリを実行する
プロジェクト構造を調べて、アプリを実行してみましょう。
main ブランチからアプリを実行すると、ドロワーやフライトの目的地の読み込みといった一部の機能が動作しないことがわかります。これについては、Codelab の次のステップで対応します。
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 つのステップが必要です。
このセクションでは、最初のステップを完了します。
アプリの優れたアーキテクチャは、適切な基本的システム設計のプラクティス(関心の分離やテストの容易性など)に基づいて、レイヤで構成されます。
UI 状態生成とは、アプリがデータレイヤにアクセスして、必要に応じてビジネスルールを適用し、UI 状態を公開して UI から使用できるようにするプロセスです。
このアプリのデータレイヤはすでに実装されています。ここからは、状態(目的地候補のリスト)を UI で使用できるように、その UI 状態を生成しましょう。
UI 状態の生成にあたっては、利用可能な API がいくつかあります。代替の方法については、状態生成パイプラインの出力タイプのドキュメントをご覧ください。一般的に、Kotlin の StateFlow
を使用して UI 状態を生成することをおすすめします。
UI 状態を生成する手順は次のとおりです。
home/MainViewModel.kt
を開きます。- 目的地候補のリストとして
MutableStateFlow
型のプライベート変数_suggestedDestinations
を定義し、空のリストを開始値に設定します。
private val _suggestedDestinations = MutableStateFlow<List<ExploreModel>>(emptyList())
StateFlow
型の 2 つ目の不変変数suggestedDestinations
を定義します。これは読み取り専用のパブリック変数で、UI から使用できるものです。可変変数を内部で使用するときには、読み取り専用変数を公開することをおすすめします。こうすることで、UI 状態はViewModel
を介さない限り変更ができなくなり、信頼できる唯一の情報源となります。拡張関数asStateFlow
によって、フローが可変から不変に変換されます。
private val _suggestedDestinations = MutableStateFlow<List<ExploreModel>>(emptyList())
val suggestedDestinations: StateFlow<List<ExploreModel>> = _suggestedDestinations.asStateFlow()
ViewModel
の init ブロックで、destinationsRepository
からの呼び出しを追加して、データレイヤから目的地を取得します。
private val _suggestedDestinations = MutableStateFlow<List<ExploreModel>>(emptyList())
val suggestedDestinations: StateFlow<List<ExploreModel>> = _suggestedDestinations.asStateFlow()
init {
_suggestedDestinations.value = destinationsRepository.destinations
}
- 最後に、このクラスにある内部変数
_suggestedDestinations
の使用方法のコメント化を解除し、UI からのイベントで適切に更新できるようにします。
これで最初のステップは完了です。ViewModel
が UI 状態を生成できるようになりました。次のステップでは、UI からこの状態を使用します。
4. ViewModel から Flow を安全に使用する
フライトの目的地のリストは、まだ空のままです。前のステップでは、MainViewModel
で UI 状態を生成しました。ここでは、MainViewModel
により公開される UI 状態を使用して UI に表示します。
home/CraneHome.kt
ファイルを開き、CraneHomeContent
コンポーザブルを確認します。
suggestedDestinations
の定義の上に TODO コメントがあり、記憶された空のリストに割り当てられています。これが画面に表示されている空のリストです。このステップでは、これを修正して、MainViewModel
が公開する目的地候補を表示します。
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
を割り当てる行を、ViewModel
の suggestedDestinations
プロパティにおける 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()
// ...
}
アプリを実行すると、目的地のリストが入力されていて、フライト利用者の人数をタップするたびにこのリストが変更されます。
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 秒後に消えます。
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()
}
}
)
}
}
アプリを起動してハンバーガー メニュー アイコンをタップすると、ナビゲーション ドロワーが開くことがわかります。
LaunchedEffect と rememberCoroutineScope の比較
このケースでは LaunchedEffect
を使用できませんでした。Composition の外部にある通常のコールバックで、コルーチンを作成するための呼び出しをトリガーする必要があったためです。
LaunchedEffect
を使用したランディング スクリーン ステップを振り返ると、LaunchedEffect
を使用する代わりに rememberCoroutineScope
を使用し、scope.launch { delay(); onTimeout(); }
を呼び出すことができたとも思われます。
そうすると、正しく機能しているように見えますが、正しくはありません。Compose の思想のドキュメントに記載されているように、コンポーザブルはいつでも Compose で呼び出すことができます。LaunchedEffect
は、コンポーザブルの呼び出しによって Composition に入ったときに副作用が実行されることを保証します。LandingScreen
の本文で rememberCoroutineScope
と scope.launch
を使用すると、LandingScreen
が Compose によって呼び出されるたびに、その呼び出しによって Composition に入ったかどうかにかかわらず、コルーチンが実行されます。その結果、リソースを浪費することになり、この副作用を制御された環境で実行することがなくなります。
7. 状態ホルダーの作成
[Choose Destination] をタップすると、フィールドを編集でき、入力した検索語句に基づいて都市をフィルタできます。また、お気づきのとおり、[Choose Destination] を変更するたびに、テキスト スタイルが変更されます。
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
}
クラスの特徴は次のとおりです。
text
はCraneEditableUserInput
の場合と同様、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
のカスタム実装を作成するのではなく、listSaver
や mapSaver
(保存する値を List
または Map
に格納する)などの既存の Compose API を利用することで、記述するコードの量を削減できます。
Saver
の定義は、使用するクラスの近くに配置することをおすすめします。静的にアクセスする必要があるため、EditableUserInputState
の Saver
を companion 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
メソッドの rememberSaveable
(remember
ではない)で、この 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
の記憶された状態は、プロセスとアクティビティの再作成後も維持されます。
状態ホルダーの使用
text
や isHint
の代わりに 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)
}
}
}
LaunchedEffect
と rememberUpdatedState
は前に使用したことがありますが、上のコードでは新しい API も使用しています。snapshotFlow
API は、Compose の State<T>
オブジェクトを Flow に変換します。snapshotFlow
内で読み取られた状態が変化すると、Flow は新しい値をコレクタに出力します。今回は、Flow 演算子を利用するために、状態を Flow に変換します。これにより、text
が hint
でない場合に 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()
}
}
次に、このオブザーバーを現在のライフサイクルに追加する必要があります。その際、現在の LifecycleOwner
を LocalLifecycleOwner
コンポジション ローカルで使用できます。ただし、オブザーバーを追加するだけでは不十分です。削除もできる必要があります。クリーンアップ コードを実行できるように、作用が 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 から出ると削除されます。DisposableEffect
の key
では、lifecycle
または mapView
が変更されると、オブザーバーが削除されて正しい lifecycle
に再び追加されます。
ここまでの変更により、MapView
は常に現在の LifecycleOwner
の lifecycle
に従い、その動作は 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() }
}
}
アプリを実行すると、都市の詳細が表示される前に読み込みスピナーがどのように表示されるかを確認できます。
10. derivedStateOf
Crane に対する最後の改善点として、フライトの目的地のリストをスクロールする際、画面上の最初の要素を通過した後に「一番上に移動」のボタンが常に表示されるようにします。このボタンをタップすると、リストの最初の要素に移動します。
このコードを含む base/ExploreSection.kt
ファイルを開きます。ExploreSection
コンポーザブルは、スキャフォールドの背景に表示されるものに対応します。
ユーザーが最初の要素を通過したかどうかを計算するには、LazyColumn
の LazyListState
を使用して、listState.firstVisibleItemIndex > 0
であるかどうかを確認します。
単純な実装は次のようになります。
// DO NOT DO THIS - It's executed on every recomposition
val showButton = listState.firstVisibleItemIndex > 0
showButton
を読み取るコンポーズ可能な関数の再コンポーズは firstVisibleItemIndex
の変更と同じ頻度となり、スクロール時に頻繁に発生するため、このソリューションは効率的ではありません。代わりに、true
と false
の間で条件が変わった場合にのみ関数を再コンポーズするようにします。
それができる 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
を使用して、条件付きで表示される Button
を ExploreList
の上に配置しています。さらに rememberCoroutineScope
を使用して、Button
の onClick
コールバック内で 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 の高度なコンセプトを学習しました。
状態ホルダーの作成方法や、LaunchedEffect
、rememberUpdatedState
、DisposableEffect
、produceState
、derivedStateOf
などの副作用 API、Jetpack Compose でコルーチンを使用する方法について学習しました。
次のステップ
Compose パスウェイに関する他の Codelab や、Crane を含む他のコードサンプルをご確認ください。
ドキュメント
これらのトピックに関する詳細とガイダンスについては、以下のドキュメントをご覧ください。