Compose UI を設計する

Compose では UI は不変です。UI を描画した後で更新する手段はありません。制御できるのは UI の状態のみです。UI の状態が変化するたびに、Compose は UI ツリーの変化した部分を再作成します。コンポーザブルは、状態を受け取ってイベントを公開できます。たとえば、TextField は値を受け取って、コールバック ハンドラに値の変更をリクエストするコールバック onValueChange を公開します。

var name by remember { mutableStateOf("") }
OutlinedTextField(
    value = name,
    onValueChange = { name = it },
    label = { Text("Name") }
)

コンポーザブルは状態を受け取ってイベントを公開するので、単方向データフロー パターンは Jetpack Compose に適しています。このガイドでは、Compose で単方向データフロー パターンを実装する方法、イベントと状態ホルダーを実装する方法、Compose で ViewModel を操作する方法に焦点を当てます。

単方向データフロー

単方向データフロー(UDF)は、状態が下方に流れ、イベントが上方に流れる設計パターンです。単方向データフローに従うことで、UI に状態を表示するコンポーザブルを、アプリ内で状態を保存および変更する部分から分離できます。

単方向データフローを使用するアプリの UI 更新ループは、次のようになります。

  • イベント: UI の一部がイベントを生成して上方に渡します(たとえば、ボタンクリックが ViewModel に渡されて処理される場合)。または、アプリの他のレイヤからイベントが渡されます(たとえば、ユーザー セッションの有効期限が切れていることを示す場合)。
  • 状態の更新: イベント ハンドラが状態を変更します。
  • 状態の表示: 状態ホルダーが状態を下方に渡し、UI が状態を表示します。

単方向データフロー

Jetpack Compose を使用する際にこのパターンに従うと、次のような利点があります。

  • テストの容易性: 状態を表示する UI から状態を分離することで、両者を切り離して簡単にテストできます。
  • 状態のカプセル化: 状態が 1 か所でのみ更新され、コンポーザブルの状態に関して信頼できる情報源が 1 つだけになるため、状態の不整合によるバグが生じる可能性が低くなります。
  • UI の整合性: LiveDataStateFlow などのオブザーバブルな状態ホルダーを使用すると、すべての状態の更新が UI に即座に反映されます。

Jetpack Compose の単方向データフロー

コンポーザブルは、状態とイベントに基づいて動作します。たとえば、TextField は、その value パラメータが更新され、onValueChange コールバックを公開する(新しい値への変更をリクエストするイベントが発生する)場合にのみ、更新されます。Compose は State オブジェクトを値ホルダーとして定義し、状態の値が変化すると再コンポジションがトリガーされます。値を記憶する期間に応じて、remember { mutableStateOf(value) } または rememberSaveable { mutableStateOf(value) で状態を保持できます。

TextField コンポーザブルの値の型は String であるため、どこからでも取得できます。たとえば、ハードコードされた値、ViewModel、または親コンポーザブルから渡すことができます。State オブジェクト内に値を保持する必要はありませんが、onValueChange が呼び出されたときは値を更新する必要があります。

コンポーザブルのパラメータを定義する

コンポーザブルの状態パラメータを定義する際は、次の点に注意してください。

  • コンポーザブルはどの程度再利用可能か(柔軟性があるか)
  • 状態パラメータがコンポーザブルのパフォーマンスにどのように影響するか

分離と再利用を促進するため、個々のコンポーザブルで保持する情報はできる限り少なくする必要があります。たとえば、ニュース記事のヘッダーを保持するコンポーザブルを作成する場合は、ニュース記事全体ではなく、表示する必要がある情報のみを渡します。

@Composable
fun Header(title: String, subtitle: String) {
    // Recomposes when title or subtitle have changed.
}

@Composable
fun Header(news: News) {
    // Recomposes when a new instance of News is passed in.
}

個別のパラメータを使用すると、パフォーマンスが向上することもあります。たとえば、Newstitlesubtitle 以上の情報が含まれている場合、News の新しいインスタンスが Header(news) に渡されるたびに、titlesubtitle が変更されていなくてもコンポーザブルは再コンポーズされます。

受け渡すパラメータの数は慎重に検討してください。関数に渡すパラメータが多すぎると関数のエルゴノミクスが低下するため、その場合はパラメータを 1 つのクラスにまとめることをおすすめします。

Compose 内のイベント

アプリへのすべての入力は、イベント(タップやテキストの変更。タイマーなどの更新も含む)として表す必要があります。このようなイベントによって UI の状態が変化するため、ViewModel でイベントを処理して UI の状態を更新する必要があります。

UI レイヤは、イベント ハンドラの外部で状態を変更するべきではありません。アプリに不整合とバグが生じる可能性があるからです。

状態とイベント ハンドラのラムダに不変の値を渡すことを優先してください。この方法には次のメリットがあります。

  • 再利用性が向上します。
  • UI が状態の値を直接変更しないことが保証されます。
  • 状態が別のスレッドから変更されないことが保証されるため、同時実行の問題を回避できます。
  • 多くの場合、コードの複雑さが軽減されます。

たとえば、String とラムダをパラメータとして受け入れるコンポーザブルは、多様なコンテキストから呼び出すことができるため、再利用性が高くなります。アプリの上部アプリバーに、テキストが常に表示され、[戻る] ボタンがあるとします。この場合、テキストと [戻る] ボタンのハンドルをパラメータとして受け取る汎用的な MyAppTopAppBar コンポーザブルを定義できます。

@Composable
fun MyAppTopAppBar(topAppBarText: String, onBackPressed: () -> Unit) {
    TopAppBar(
        title = {
            Text(
                text = topAppBarText,
                textAlign = TextAlign.Center,
                modifier = Modifier
                    .fillMaxSize()
                    .wrapContentSize(Alignment.Center)
            )
        },
        navigationIcon = {
            IconButton(onClick = onBackPressed) {
                Icon(
                        Icons.Filled.ArrowBack,
                        contentDescription = localizedString
                    )
            }
        },
        // ...
    )
}

ViewModel、状態、イベントの例

ViewModelmutableStateOf を使用して、次のいずれかに該当する場合に、単方向データフローをアプリに導入することもできます。

  • UI の状態が、LiveData により、オブザーバブルな状態ホルダーの実装として公開される。
  • ViewModel が UI またはアプリの他のレイヤからのイベントを処理し、イベントに基づいて状態ホルダーを更新する。

たとえば、ログイン画面を実装する場合、ログインボタンがタップされると、アプリは進行状況スピナーとネットワーク呼び出しを表示する必要があります。ログインが成功した場合、アプリは別の画面にナビゲートします。エラーが発生した場合は、スナックバーを表示します。画面の状態とイベントをモデル化する方法を次に示します。

画面には次の 4 つの状態があります。

  • ログアウト: ユーザーがまだログインしていない状態。
  • 進行中: アプリがネットワーク呼び出しを実行して、ユーザーのログインを試行している状態。
  • エラー: ログイン中にエラーが発生した状態。
  • ログイン: ユーザーがログインしている状態。

これらの状態は、シールクラスとしてモデル化できます。ViewModel は状態を State として公開し、初期状態を設定して、必要に応じて状態を更新します。ViewModel は、onSignIn() メソッドを公開することにより、ログイン イベントも処理します。

sealed class UiState {
    object SignedOut : UiState()
    object InProgress : UiState()
    object Error : UiState()
    object SignIn : UiState()
}

class MyViewModel : ViewModel() {
    private val _uiState = mutableStateOf<UiState>(SignedOut)
    val uiState: State<UiState>
        get() = _uiState

    // ...
}

Compose は、mutableStateOf API に加えて、LiveDataFlowObservable拡張機能を提供します。これらをリスナーとして登録し、値を状態として表します。

class MyViewModel : ViewModel() {
    private val _uiState = MutableLiveData<UiState>(SignedOut)
    val uiState: LiveData<UiState>
        get() = _uiState

    // ...
}

@Composable
fun MyComposable(viewModel: MyViewModel) {
    val uiState = viewModel.uiState.observeAsState()
    // ...
}