Compose における考え方

Jetpack Compose は Android 向けの最新の宣言型 UI ツールキットです。Compose では、フロントエンド ビューを強制的に変更することなくアプリの UI をレンダリングできる宣言型 API を提供することで、アプリの UI の記述とメンテナンスが簡単になります。この用語については説明が必要ですが、この影響はアプリの設計にとって重要です。

宣言型プログラミング パラダイム

これまで、Android のビュー階層は UI ウィジェットのツリーとして表現されていました。ユーザー操作などによってアプリの状態が変わると、現在のデータを表示するために UI 階層を更新する必要があります。UI を更新する方法としては、findViewById() などの関数を使用してツリーをたどってから、button.setText(String)container.addChild(View)img.setImageBitmap(Bitmap) などのメソッドを呼び出してノードを変更する方法が最も一般的です。こうしたメソッドは、ウィジェットの内部状態を変更します。

ビューを手動で操作すると、エラーが発生しやすくなります。データの一部が複数の場所でレンダリングされている場合、それを示すビューのうちいずれかの更新を忘れがちです。また、2 つの更新が予期せず競合すると、不正な状態が作成されやすくなります。たとえば、UI から削除されたばかりのノードの値を設定しようとすることがあります。一般に、更新を必要とするビューの数が多いほど、ソフトウェアのメンテナンスの複雑さは増します。

ここ数年、業界全体が宣言型 UI モデルに移行し始め、ユーザー インターフェースの構築や更新に関連するエンジニアリングは大幅に簡素化されています。この手法は、概念的に画面全体をゼロから再生成し、必要な変更のみを適用することで機能します。このアプローチでは、ステートフルなビュー階層を手動で更新する複雑さを回避できます。Compose は宣言型 UI フレームワークです。

画面全体を再生成する際の課題は、時間、コンピューティング能力、電池使用量という点で、コストが高くなる可能性があることです。このコストを軽減するために、Compose は常に、UI のうち再描画する必要がある部分をインテリジェントに選択します。これは、再コンポーズで説明するように、UI コンポーネントの設計方法に影響する可能性があります。

シンプルでコンポーズ可能な関数

Compose を使用すると、データを取り込み UI 要素を出力するコンポーズ可能な関数のセットを定義することで、ユーザー インターフェースを作成できます。簡単な例としては、Greeting ウィジェットがあります。これは String を受け取り、挨拶メッセージを表示する Text ウィジェットを出力します。

テキストが表示されているスマートフォンのスクリーンショット

図 1. データを渡し、それを使用して画面にテキスト ウィジェットをレンダリングする、シンプルでコンポーズ可能な関数。

この関数で注目すべき点は次のとおりです。

  • この関数には @Composable アノテーションが付けられています。コンポーズ可能な関数はすべて、このアノテーションが必要です。このアノテーションは、この関数がデータを UI に変換するためのものであることを Compose コンパイラに伝えます。

  • この関数はデータを取り込みます。コンポーズ可能な関数は、アプリのロジックで UI を記述できるようにするパラメータを受け入れることができます。この場合、ウィジェットは String を受け入れるため、名前でユーザーに挨拶できます。

  • この関数は UI にテキストを表示します。そのために、実際にテキスト UI 要素を作成するコンポーズ可能な関数 Text() を呼び出します。コンポーズ可能な関数は、他のコンポーズ可能な関数を呼び出すことで UI 階層を生成します。

  • この関数は何も返しません。UI を出力する Compose 関数は、UI ウィジェットを作成するのではなく目的の画面状態を記述するため、何も返す必要はありません。

  • この関数は高速な冪等であり、副作用はありません。

    • この関数は、同じ引数で複数回呼び出されても同じように動作します。グローバル変数などの他の値、または random() への呼び出しは使用しません。
    • この関数は、プロパティやグローバル変数の変更などの副作用なしで UI を記述します。

    一般に、コンポーズ可能な関数はすべて、再コンポーズで説明している理由から、こうしたプロパティを持たせて作成する必要があります。

宣言型パラダイム シフト

命令型オブジェクト指向 UI ツールキットの多くは、ウィジェットのツリーをインスタンス化することで UI を初期化します。そのためには通常、XML レイアウト ファイルをインフレートします。各ウィジェットは自身の内部状態を維持し、アプリのロジックでウィジェットを操作できるようにするゲッター メソッドとセッター メソッドを公開します。

Compose の宣言型アプローチでは、ウィジェットは比較的ステートレスであり、setter または getter 関数は公開されません。実際、ウィジェットはオブジェクトとして公開されません。同一のコンポーズ可能な関数を異なる引数で呼び出すことにより、UI を更新します。これにより、アプリ アーキテクチャ ガイドで説明されているように、ViewModel などのアーキテクチャ パターンに状態を簡単に提供できます。その後、コンポーザブルは、監視可能なデータが更新されるたびに現在のアプリ状態を UI に変換する役割を担います。

Compose UI における上位のオブジェクトから子までのデータの流れを示す図。

図 2. アプリロジックは、最上位のコンポーズ可能な関数にデータを提供します。この関数はデータを使用して、他のコンポーザブルを呼び出すことで UI を記述し、そのコンポーザブルに適切なデータを渡して、階層を下ります。

ユーザーが UI を操作すると、UI で onClick などのイベントが発生します。こうしたイベントはアプリロジックに伝えられ、これによりアプリの状態を変更できます。状態が変更されると、コンポーズ可能な関数が新しいデータで再度呼び出されます。これにより、UI 要素が再描画されます(このプロセスを再コンポーズといいます)。

アプリロジックで処理されるイベントをトリガーすることで、UI 要素が操作にどのように応答するかを示した図。

図 3. ユーザーが UI 要素を操作すると、イベントがトリガーされます。アプリロジックがイベントに応答すると、必要に応じ、コンポーズ可能な関数が新しいパラメータで再度自動的に呼び出されます。

動的コンテンツ

コンポーズ可能な関数は XML ではなく Kotlin で記述されているため、他の Kotlin コードと同様に、動的なものにできます。たとえば、ユーザーのリストに対し挨拶をする UI を作成するとします。

@Composable
fun Greeting(names: List<String>) {
    for (name in names) {
        Text("Hello $name")
    }
}

この関数は名前のリストを受け取り、各ユーザーに対する挨拶を生成します。コンポーズ可能な関数は、非常に高度なものにもできます。if ステートメントを使用して、特定の UI 要素を表示するかどうかを決定できます。ループを使用したり、ヘルパー関数を呼び出したりすることもできます。基となる言語に対して高い柔軟性があり、この充実した機能と柔軟性が、Jetpack Compose の主な利点です。

再コンポーズ

命令型 UI モデルでウィジェットを変更するには、ウィジェットでセッターを呼び出して内部状態を変更します。Compose で、コンポーズ可能な関数を新しいデータで再度呼び出します。すると、関数が再コンポーズされます。関数によって出力されるウィジェットは、必要に応じて新しいデータで再描画されます。Compose フレームワークでは、変更されたコンポーネントのみをインテリジェントに再コンポーズできます。

たとえば、ボタンを表示するコンポーズ可能な関数について考えてみます。

@Composable
fun ClickCounter(clicks: Int, onClick: () -> Unit) {
    Button(onClick = onClick) {
        Text("I've been clicked $clicks times")
    }
}

ボタンがクリックされるたびに、呼び出し元は clicks の値を更新します。Compose は Text 関数でラムダを再度呼び出し、新しい値を表示します。このプロセスを再コンポーズといいます。値に依存しない他の関数は再コンポーズされません。

すでに説明したように、UI ツリー全体を再コンポーズすると、コンピューティングのコストがかかり、コンピューティング能力と電池寿命が消費されます。Compose は、このインテリジェントな再コンポーズによってこの問題を解決します。

再コンポーズとは、入力が変更されたときにコンポーズ可能な関数を再度呼び出すプロセスです。これは、関数の入力が変更された場合に発生します。新しい入力に基づいて再コンポーズされると、Compose は変更された可能性のある関数またはラムダのみを呼び出し、残りはスキップします。パラメータを変更しない関数またはラムダをすべてスキップすることで、Compose で効率的に再コンポーズできます。

関数の再コンポーズはスキップされる可能性があるため、コンポーズ可能な関数の実行による副作用に依存しないでください。その場合、アプリで予期しない異常な動作が発生することがあります。副作用とは、アプリの他の部分に反映される変更です。たとえば、次のようなアクションはすべて危険な副作用です。

  • 共有オブジェクトのプロパティへの書き込み
  • ViewModel で監視可能なデータの更新
  • 共有設定の更新

コンポーズ可能な関数は、アニメーションをレンダリングするときなど、フレームごとに何度も再実行されることがあります。アニメーション中にジャンクが発生しないように、コンポーズ可能な関数は高速にする必要があります。コストの高い操作(共有設定からの読み取りなど)が必要な場合は、バックグラウンド コルーチンで行い、その値をパラメータとしてコンポーズ可能な関数に渡します。

たとえば、このコードでは、SharedPreferences の値を更新するコンポーザブルが作成されます。コンポーザブルは、共有設定からの読み取りや書き込みを行いません。代わりに、このコードはバックグラウンド コルーチンの ViewModel に対して読み取りと書き込みを行います。アプリロジックは、現在の値をコールバックで渡し、更新をトリガーします。

@Composable
fun SharedPrefsToggle(
    text: String,
    value: Boolean,
    onValueChanged: (Boolean) -> Unit
) {
    Row {
        Text(text)
        Checkbox(checked = value, onCheckedChange = onValueChanged)
    }
}

このドキュメントでは、Compose を使用する際の注意点をいくつか説明します。

  • コンポーズ可能な関数は任意の順序で実行できる。
  • コンポーズ可能な関数は並行して実行できる。
  • 再コンポーズは、可能な限り多くのコンポーズ可能な関数とラムダをスキップする。
  • 再コンポーズは厳密なものではなく、キャンセルされる場合がある。
  • コンポーズ可能な関数は、アニメーションのフレームごとに何度も実行される場合がある。

以下のセクションでは、再コンポーズをサポートするようにコンポーズ可能な関数を作成する方法について説明します。いずれの場合でも、コンポーズ可能な関数は、高速かつ冪等で副作用のないものにすることをおすすめします。

コンポーズ可能な関数は任意の順序で実行できる

コンポーズ可能な関数のコードを見ると、コードが書かれているとおりの順序で実行されるように思われるかもしれません。しかし、これは必ずしもそのとおりであるとは限りません。あるコンポーズ可能な関数に、他のコンポーズ可能な関数の呼び出しが含まれている場合、これらの関数は任意の順序で実行されます。Compose には、一部の UI 要素の優先順位が他よりも高いことを認識し、先に描画するというオプションがあります。

たとえば、3 つの画面を 1 つのタブレイアウトに描画する次のようなコードがあるとします。

@Composable
fun ButtonRow() {
    MyFancyNavigation {
        StartScreen()
        MiddleScreen()
        EndScreen()
    }
}

StartScreenMiddleScreenEndScreen の呼び出しは、任意の順序で発生します。つまり、たとえば StartScreen() でグローバル変数を設定し(副作用)、その変更を MiddleScreen() で活用することはできません。代わりに、各関数を自己完結させる必要があります。

コンポーズ可能な関数は並行して実行できる

Compose では、コンポーズ可能な関数を並行して実行することで、再コンポーズを最適化できます。これにより Compose は複数のコアを活用し、画面上にないコンポーズ可能な関数を、より低い優先度で実行します。

この最適化は、コンポーズ可能な関数がバックグラウンド スレッドのプール内で実行される可能性があることを意味します。コンポーズ可能な関数が ViewModel で関数を呼び出す場合、Compose では、その関数を複数のスレッドから同時に呼び出す可能性があります。

アプリを正しく動作させるには、コンポーズ可能な関数すべてで副作用をなくす必要があります。代わりに、UI スレッドで常に実行される onClick などのコールバックから副作用をトリガーします。

コンポーズ可能な関数が呼び出されるとき、その呼び出しは、呼び出し元の別のスレッドで発生することがあります。つまり、コンポーズ可能なラムダで変数を変更するコードは避ける必要があります(そのようなコードはスレッドセーフではなく、また、コンポーズ可能なラムダの許容されない副作用であるためです)。

リストとそのカウントを表示するコンポーザブルの例を次に示します。

@Composable
fun ListComposable(myList: List<String>) {
    Row(horizontalArrangement = Arrangement.SpaceBetween) {
        Column {
            for (item in myList) {
                Text("Item: $item")
            }
        }
        Text("Count: ${myList.size}")
    }
}

このコードは副作用がなく、入力リストを UI に変換します。これは小さなリストを表示する場合に適したコードです。ただし、関数がローカル変数に書き込む場合、このコードはスレッドセーフではなく、正しくありません。

@Composable
@Deprecated("Example with bug")
fun ListWithBug(myList: List<String>) {
    var items = 0

    Row(horizontalArrangement = Arrangement.SpaceBetween) {
        Column {
            for (item in myList) {
                Text("Item: $item")
                items++ // Avoid! Side-effect of the column recomposing.
            }
        }
        Text("Count: $items")
    }
}

この例で、items は再コンポーズのたびに変更されます。アニメーションのフレームごとに、またはリストの更新時に発生する可能性があります。どちらの場合も、UI に誤ったカウントが表示されます。このため、Compose ではこのような書き込みはサポートされていません。このような書き込みを禁止することにより、フレームワークがスレッドを変更してコンポーズ可能なラムダを実行できるようになります。

再コンポーズは可能な限りスキップする

UI の一部が無効な場合、Compose は、更新が必要な部分のみを再コンポーズしようとします。つまり、1 つのボタンのコンポーザブルを再実行するために、UI ツリー内で上または下にあるコンポーザブルを実行せず、スキップすることがあります。

コンポーズ可能な関数とラムダはすべて、それ自体で再コンポーズすることがあります。リストをレンダリングするときに再コンポーズでどのように一部の要素がスキップされるかについての例を次に示します。

/**
 * Display a list of names the user can click with a header
 */
@Composable
fun NamePicker(
    header: String,
    names: List<String>,
    onNameClicked: (String) -> Unit
) {
    Column {
        // this will recompose when [header] changes, but not when [names] changes
        Text(header, style = MaterialTheme.typography.bodyLarge)
        Divider()

        // LazyColumn is the Compose version of a RecyclerView.
        // The lambda passed to items() is similar to a RecyclerView.ViewHolder.
        LazyColumn {
            items(names) { name ->
                // When an item's [name] updates, the adapter for that item
                // will recompose. This will not recompose when [header] changes
                NamePickerItem(name, onNameClicked)
            }
        }
    }
}

/**
 * Display a single name the user can click.
 */
@Composable
private fun NamePickerItem(name: String, onClicked: (String) -> Unit) {
    Text(name, Modifier.clickable(onClick = { onClicked(name) }))
}

これらのスコープはそれぞれ、再コンポーズの際に実行される唯一のものである可能性があります。header が変更された場合、Compose は親を実行せず Column ラムダにスキップすることがあります。また Column を実行するとき、Compose は names が変更されていない場合に LazyColumn のアイテムをスキップすることがあります。

繰り返しになりますが、コンポーズ可能な関数またはラムダの実行はすべて、副作用がないようにしてください。副作用が必要な場合は、コールバックからトリガーします。

再コンポーズは厳密なものではない

コンポーザブルのパラメータが変更された可能性があることを Compose が認識するたびに、再コンポーズが開始されます。再コンポーズは厳密なものではありません。つまり Compose は、パラメータが再度変更される前に再コンポーズが終わると想定します。再コンポーズが終わるより前にパラメータが変更される場合、Compose は再コンポーズをキャンセルし、新しいパラメータで再開することがあります。

再コンポーズをキャンセルした場合、Compose は再コンポーズから UI ツリーを破棄します。表示されている UI に依存する副作用がある場合、その副作用は、コンポーズがキャンセルされても適用されます。これにより、アプリの状態に一貫性がなくなる可能性があります。

厳密でない再コンポーズを処理する際には、コンポーズ可能な関数とラムダがすべて冪等であり、副作用がないことを確認してください。

コンポーズ可能な関数は何度も実行されることがある

場合によっては、コンポーズ可能な関数が UI アニメーションのフレームごとに実行されることがあります。関数がコストの高いオペレーション(デバイス ストレージからの読み取りなど)を行う場合、関数によって UI ジャンクが発生することがあります。

たとえば、ウィジェットがデバイス設定を読み取ろうとすると、その設定が 1 秒間に数百回読み取られ、アプリのパフォーマンスに深刻な影響を及ぼす可能性があります。

コンポーズ可能な関数でデータが必要な場合は、データ用のパラメータを定義する必要があります。すると、コストの高い処理をコンポーズ外の別のスレッドに移動でき、mutableStateOf または LiveData を使用してデータを Compose に渡せます。

詳細

Compose 関数とコンポーズ可能な関数の考え方について詳しくは、以下の参考情報をご確認ください。

動画