ベータコンピューティングの活動や技術、開発のこだわりなどを紹介するブログです。



Jetpack Composeの学習記録

こんにちは、Beta Computing株式会社で学生アルバイトをしていますAndroidアプリケーション開発担当です。
来年4月から正式に入社予定ですので、現在は研修期間としてAndroid開発の勉強を進めています。
今回はAndroid開発のツールキットであるJetpack Composeについて学習した内容を当記事にまとめたいと思います。

Jetpack Composeの公式サイトはこちらになります。 Jetpack Compose UI App Development Toolkit - Android Developers

Jetpack Composeとは?

Jetpack Composeとは、AndroidのネイティブUIを構築するためのツールキットです。
公式サイトでは、従来の方法に比べて簡素化されており、少ないコードでなおかつ高速に動作すると紹介されています。
この章では、従来のXMLベースのレイアウト記法を復習してから、Jetpack Composeの特徴を見ていこうと思います。

従来の方法(XML)

従来のUI構築にはXMLベースのレイアウト記法が採用されていました。 XMLベースのレイアウト記法の基本的な仕組みは、UIの構造をXMLファイルで定義し、それをアプリケーションのコードから読み込んで使用する方式です。
開発者はres/layoutディレクトリ内にXMLファイルを作成し、その中でViewやViewGroupを階層的に記述していきます。
各要素には属性を設定し、IDや幅、高さ、マージンなどの属性を指定します。

これらのXMLファイルは、ActivityやFragmentのKotlinまたはJavaコードからsetContentView()メソッドやinflateメソッドを使って読み込まれます。
読み込まれたレイアウトの個々の要素は、findViewById()などのメソッドを使ってコードから参照され、操作することができます。 この方法では、UIの構造とアプリケーションのロジックが分離されており、それぞれを独立して管理できるのが特徴です。
また、Android Studioのレイアウトエディタを使用すれば、XMLを直接編集せずに視覚的にUIを構築することも可能です。さらに、データバインディングやビューバインディングなどの技術を使用することで、XMLとコードの連携をより効率的に行うこともできます。

簡単な例を以下に示します。

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    android:padding="16dp">

    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="ログイン"
        android:textSize="24sp"
        android:layout_gravity="center_horizontal"
        android:layout_marginBottom="32dp" />

    <EditText
        android:id="@+id/username_input"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:hint="ユーザー名"
        android:inputType="text"
        android:layout_marginBottom="16dp" />

    <EditText
        android:id="@+id/password_input"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:hint="パスワード"
        android:inputType="textPassword"
        android:layout_marginBottom="24dp" />

    <Button
        android:id="@+id/login_button"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:text="ログイン" />

</LinearLayout>

この画面は以下のように表示されます。

画面の説明をします。 最上位の要素は垂直方向のLinearLayoutで、画面全体のコンテナとして機能しています。その中に、順に「ログイン」というテキストを表示するTextView、ユーザー名入力用のEditText、パスワード入力用のEditText、そして「ログイン」ボタンとしてのButtonが配置されています。EditTextには入力のヒントが設定され、パスワード用のものは入力内容が隠れるよう指定されています。

Jetpack Compose

XMLベースのレイアウト記法では、画面のデザインとプログラムを別々に書く必要がありましたが、 Jetpack Composeを使うと、すべてをKotlinというプログラミング言語で一緒に書くことができます。
Jetpack Composeでは、ボタンやテキスト、画像などの画面の部品を「関数」として作ります。これらの関数を組み合わせて、アプリの画面全体を作り上げていきます。
また、Composeは画面の変化を自動的に反映してくれます。例えば、ユーザーが何かを入力したり、ボタンを押したりしたときに、関連する部分だけが自動的に更新されます。
これにより、画面の更新について細かく指示を書く必要がなくなり、より簡単にアプリを作ることができます。

では、先ほどの画面をJetpack Composeを使って書き直してみます。

import androidx.compose.foundation.layout.Arrangement
// import文省略

@Preview(showBackground = true)
@Composable
fun LoginScreen() {
    var username by remember { mutableStateOf("") }
    var password by remember { mutableStateOf("") }

    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(16.dp),
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Text(
            text = "ログイン",
            fontSize = 24.sp,
            modifier = Modifier.padding(bottom = 32.dp)
        )

        OutlinedTextField(
            value = username,
            onValueChange = { username = it },
            label = { Text("ユーザー名") },
            modifier = Modifier
                .fillMaxWidth()
                .padding(bottom = 16.dp)
        )

        OutlinedTextField(
            value = password,
            onValueChange = { password = it },
            label = { Text("パスワード") },
            visualTransformation = PasswordVisualTransformation(),
            modifier = Modifier
                .fillMaxWidth()
                .padding(bottom = 24.dp)
        )

        Button(
            onClick = { /* ログイン処理 */ },
            modifier = Modifier.fillMaxWidth()
        ) {
            Text("ログイン")
        }
    }
}

この画面は以下のように表示されます。

先ほどの画面と同じ構成ですが、Jetpack Compose用にもう一度説明します。最上位の要素はColumnコンポーザブルで、画面全体のコンテナとして機能しています。その中に、順に「ログイン」というテキストを表示するText、ユーザー名入力用のOutlinedTextField、パスワード入力用のOutlinedTextField、そして「ログイン」ボタンとしてのButtonが配置されています。OutlinedTextFieldにはラベルが設定され、パスワード用のものは入力内容が隠れるよう指定されています。 このComposeレイアウトは、Kotlinコードとして記述され、Android開発環境で解釈されて実際の画面表示に変換されます。各要素の状態は変数として定義されており、これによってアプリケーションのコードから要素の状態を管理し、操作することが可能になっています。

Jetpack ComposeとXMLの違い

先ほど紹介した二つの記法は主に以下の様な違いがあります。

XML Jetpack Compose
アプローチ 命令型 宣言型
言語 マークアップ言語 Kotlin
UI構築 レイアウトを別のファイルで定義 コードで直接定義
反応性 手動での状態管理 状態に基づく自動更新

Jetpack Composeを採用するメリット

それでは、従来のXMLベースのレイアウト記法に代わって新たにJetpack Composeを導入するメリットを紹介します。 まずXMLベースのレイアウト機能には以下のようなデメリットがあります。

  • コードが冗長になりがち。
    • UIコンポーネントを詳細に定義する必要があるため。
  • 動的UIの管理が複雑。
    • 手動での状態管理やイベントハンドリングが必要であるため。
  • パフォーマンスの問題がある。
    • レイアウトが複雑になるとXMLの解析と描画に時間がかかるようになり、パフォーマンスに影響が出る可能性があります。
  • 編集が難しい。
    • レイアウトが複雑になると、直感的な編集が難しくなる場合があります。

これに対してJetpack Composeには以下のようなメリットがあります。

  • コードが簡潔である。
    • UIコンポーネントをKotlin関数として作成できるため、ボイラープレートコード(変更されることがなく、多くの箇所で書かれているのにも関わらず、プログラミング言語の仕様上省略が不可能なコード)を大幅に削減可能です。
    • 宣言的なアプローチにより、UIの構造がコードの構造と直接対応するため、可読性が向上します。
  • 状態管理が比較的簡単にできる。
    • 状態管理の仕組みが組み込まれているので、専用の関数で状態を管理できます。
  • パフォーマンスの向上が見込める。
    • 効率的なレンダリングエンジンを使用しており、必要な部分のみを再描画することができます。
    • レイアウトの階層が浅くなるため、複雑なUIでもパフォーマンスが向上します。
  • 編集が直感的にできる。
    • リアルタイムプレビュー機能により、コードの変更をすぐに視覚的に確認できます。
    • コンポーネントの再利用が容易になり、複雑なUIでも管理しやすくなります。

以上のような点から、Jetpack Composeを導入することを検討する開発者も増えています。 ただし、XMLベースのレイアウト記法にもメリットはあり、必要に応じて使い分ける必要があります。 例えば古いAndroidバージョンを使用している端末ではJetpack Composeが動作しなかったり、XMLベースの記法の方がライブラリやリソースが豊富だったりします。

Jetpack Composeの導入方法

Android Studioを使用していることを前提条件とします。 最新のAndroid Studioを利用していれば、新しいプロジェクトを作成するだけでJetpack Composeに対応したプロジェクトを作成できるようです。

もし既存のプロジェクトに追加する場合は、アプリのbuild.gradleファイルに次の定義を追加することで対応できます。

android {
    buildFeatures {
        compose true
    }
}

詳しくは公式サイトをご覧ください。
クイック スタート  |  Jetpack Compose  |  Android Developers

Jetpack Composeの使い方

それでは、Jetpack Composeの使い方について紹介します。 本章の内容は以下のサイトから学習した内容を記述しています。
Jetpack Compose を使ってみる  |  Android Developers

コンポーズ可能な関数

UIの記述にはコンポーズ可能な関数を使用します。コンポーズ可能な関数とは以下の特徴を持ちます。

  • @Composableアノテーションを持つ。
  • 他のコンポーズ可能な関数を呼び出せる。
  • 値を返さない。

この関数はUIの内容などを記述するために使用されます 。たとえば以下のような関数です。

@Composable
fun Greeting(name: String) {
    Text(text = "Hello $name!")
}

この関数は画面に「Hello (引数から受け取った名前)!」を表示します。 このような関数を組み合わせて画面を構築していきます。

コンポーネント

コンポーズ可能な関数によって作られる再利用可能なUI部品をコンポーネントと呼びます。コンポーネントには以下のような特徴があります。

  • 独立した機能単位として動作する。
  • 他のコンポーネントと組み合わせて使用できる。
  • データの入力を受け取りUIとして出力する。

コンポーズ可能な関数はコンポーネントを作るための手段であり、コンポーネントはコンポーズ可能な関数によって作られるものです。 つまり、先ほどのGreeting関数もコンポーネントとして機能します。これは単純なコンポーネントでしたが、より複雑なコンポーネントも作成できます。例えば以下のようなものです。

@Composable
fun UserCard(
    name: String,
    age: Int,
    onButtonClick: () -> Unit
) {
    Card(
        modifier = Modifier.padding(32.dp)
    ) {
        Column {
            Text(
                text = name,
                modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
            )
            Text(
                text = "Age: $age",
                modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
            )
            Button(
                onClick = onButtonClick,
                modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
            ) {
                Text("詳細を見る")
            }
        }
    }
}

このコンポーネントは以下のように表示されます。

このように、コンポーネントは様々な粒度で作成することが可能です。

Scaffoldの使い方

最上位のコンポーネントには「Scaffold」と呼ばれるものを使用します。(場合によってはカスタムレイアウトなどを目的に応じて使用することもあります。) Scaffoldを使用することで、Androidアプリの標準的なレイアウトパターンを実現したりすることや、マテリアルデザインを自動的に適用したりすることができます。

それでは試しに使ってみます。 Androidアプリのプロジェクトを作成して、「MainActivity.kt」ファイルに以下のコードを書きます。

// import文は省略

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            MainScreen()
        }
    }
}

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MainScreen() {
    Scaffold(
        topBar = {
            TopAppBar(
                title = { Text("シンプルな画面") }
            )
        }
    ) { paddingValues ->
        Column(
            modifier = Modifier
                .fillMaxSize()
                .padding(paddingValues),
            horizontalAlignment = Alignment.CenterHorizontally,
            verticalArrangement = Arrangement.Center
        ) {
            Text("Hello, Jetpack Compose!")
        }
    }
}

@Preview(showBackground = true)
@Composable
fun MainScreenPreview() {
    MainScreen()
}

以上のコードを実行すると以下の様な画面が表示されます。非常にシンプルな画面です。

では、先ほどのコードのそれぞれの役割について見てみようと思います。 まずは以下の部分です。

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            MainScreen()
        }
    }
}

ここではアプリ起動時に表示される画面を定義しています。今回は次に定義するMainScreenを起動時の画面として設定しています。 では次にMainScreenの定義を見てみましょう。

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MainScreen() {
    Scaffold(
        topBar = {
            TopAppBar(
                title = { Text("シンプルな画面") }
            )
        }
    ) { paddingValues ->
        Column(
            modifier = Modifier
                .fillMaxSize()
                .padding(paddingValues),
            horizontalAlignment = Alignment.CenterHorizontally,
            verticalArrangement = Arrangement.Center
        ) {
            Text("Hello, Jetpack Compose!")
        }
    }
}

ここでScaffoldが登場しています。Scaffoldには以下の様なパラメータを持っています。

  • topBar:ツールバーを指定する。
  • bottomBar:ボトムナビゲーションバーを指定する。
  • floatingActionButton:右下のボタン
  • …など他多数

詳しくは以下から確認することができます。
Jetpack Compose  |  Android Developers

今回はtopBarに「シンプルな画面」と表示するように指定しています。しかしよく見てみると、ScaffoldはtopBarのみを指定して、その後に以下の様に続けています。

Scaffold(
        topBar = {
            // ...内容...
        }
    ) { paddingValues -> // 急にラムダ式のような記述が始まる
        Column(
            modifier = Modifier
            // ...内容...
        )
    }

これはKotlinにおける特有?の書き方で、関数の最後の引数が関数型であれば以下のように書けるようです。

fun function(lambda: () -> Int) : Int { // 関数型を受け取る関数
    return lambda()
}

val result : Int = function { 1 } // 括弧でパラメータを指定
println(result) // 1と表示される

このような書き方を「トレーリングラムダ記法」と呼ぶようです。こちらの仕様については以下のサイトが大変勉強になりました。
Kotlin の trailing lambda は constructor にも使える話 - Qiita

さてここでScaffoldの実装を見てみると…

@Composable
fun Scaffold(
    modifier: Modifier = Modifier,
    topBar: @Composable () -> Unit = {},
    bottomBar: @Composable () -> Unit = {},
    snackbarHost: @Composable () -> Unit = {},
    floatingActionButton: @Composable () -> Unit = {},
    floatingActionButtonPosition: FabPosition = FabPosition.End,
    containerColor: Color = MaterialTheme.colorScheme.background,
    contentColor: Color = contentColorFor(containerColor),
    contentWindowInsets: WindowInsets = ScaffoldDefaults.contentWindowInsets,
    content: @Composable (PaddingValues) -> Unit
) {
// ... 以下実装
}

最後に「content: @Composable (PaddingValues) -> Unit」という引数を受け取っています。
詳しい仕様は省略しますが、この引数に画面のメインコンテンツを定義すれば良いようです。
ただし、必ずpaddingValuesを使用して適切な余白を確保する必要があるようです。

paddingValuesの仕様については以下のサイトが大変勉強になりました。
Jetpack Compose 1.2.0 では Scaffold の content に PaddingValues を必ず設定する - Infinito Nirone 7

Columnの使い方

次に、表示されるコンテンツの中身を見ていきましょう。

Column(
        modifier = Modifier
              .fillMaxSize()
              .padding(paddingValues),
          horizontalAlignment = Alignment.CenterHorizontally,
          verticalArrangement = Arrangement.Center
      ) {
          Text("Hello, Jetpack Compose!")
      }

ColumnはUI要素を縦方向に並べるための基本的なレイアウトコンポーネントです。横方向に並べるにはRowを使用します。
並べる数を増やすには以下の様に要素を追加するだけです。

Column(
        modifier = Modifier
              .fillMaxSize()
              .padding(paddingValues),
          horizontalAlignment = Alignment.CenterHorizontally,
          verticalArrangement = Arrangement.Center
      ) {
          Text("Hello, Jetpack Compose!")
          Text("Hello, Jetpack Compose!")
          Text("Hello, Jetpack Compose!")
      }

また、modifierを使用してサイズや配置を決定することができます。 modifierは多くのコンポーネントに使用できます。例えば先ほどのColumnにはfillMaxSize()が指定されています。
これはColumnが利用可能な画面スペースの最大サイズまで広がるように設定しています。
さらにpadding(paddingValues)によってScaffoldから提供されているpaddingValuesを用いて適切に余白を確保しています。 これによって他のUI要素と干渉することを防いでいます。

modifierには他にもサイズや配置を指定する方法があります。詳しくは以下のサイトが大変勉強になりました。
Jetpack Compose Modifier(修飾) - Qiita
Compose 修飾子  |  Jetpack Compose  |  Android Developers

また、modifier意外にも以下の二つのパラメータを使用してレイアウトを設定しています 。

  • horizontalAlignment = Alignment.CenterHorizontally
    • Column内の要素を横方向の中央に配置するための設定
  • verticalArrangement = Arrangement.Center
    • Column内の要素を縦方向の中脳に配置するための設定

Buttonの使い方

次は、画面にボタンを設置してみます。ボタンも同じくコンポーネントとして配置することができます。 例えば以下のようなコードを書いてみます。

@Composable
fun StyledButton() {
    Button(
        onClick = { /* クリックしたときの処理 */ },
        colors = ButtonDefaults.buttonColors(
            containerColor = Color.DarkGray
        ),
        modifier = Modifier.padding(16.dp)
    ) {
        Text(
            text = "スタイル付きボタン",
            color = Color.White
        )
    }
}

これは以下のように表示されます。

ボタンの背景は、Buttonの引数の「colors」でダークグレーを指定しています。
また、ボタンに表示される文字列はScaffoldと同じようにトレーリングラムダ記法で記述しています。

ボタンを押した時の処理は「onClick」の引数に渡します。 例えば、ボタンを押した際にダイアログを出現させるには以下の様に書きます。

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            CenteredButton()
        }
    }
}

@Composable
fun CenteredButton() {
    val context: Context = LocalContext.current;
    Column(
        modifier = Modifier.fillMaxSize(),
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Button(
            onClick = {

                Toast.makeText(context, "ボタンが押されました!", Toast.LENGTH_SHORT).show()
            }
        ) {
            Text("ここを押してください")
        }
    }
}

@Preview(showBackground = true)
@Composable
fun CenteredButtonPreview() {
    CenteredButton()
}

このように記述して画面中央のボタンを押すと、下から「ボタンが押されました!」と表示されます。 (これをトーストと呼びます。)

キーボードによる入力

キーボードによる入力もやってみます。まずは以下の様なコードを書きます。

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            VerySimpleInputFieldPreview()
        }
    }
}

@Composable
fun VerySimpleInputField() {
    var text by remember { mutableStateOf("") }

    Column(
        modifier = Modifier.fillMaxSize(),
        verticalArrangement = Arrangement.Center,
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        TextField(value = text, onValueChange = { text = it }, label = { Text("入力してください") })
    }
    
}

@Preview(showBackground = true)
@Composable
fun VerySimpleInputFieldPreview() {
    VerySimpleInputField()
}

これは以下のように表示されます。

入力欄をタップしてキーボードを入力することで文字を入力することができます。

さて、ここで以下のコードに着目してみます 。

 var text by remember { mutableStateOf("") }

(私にとっては)見慣れない構文が出てきました。まず「by」は委譲を行うための構文です。
変数「text」は別のクラスのメソッドから初期化されています。これを委譲プロパティと呼びます。

詳しくは以下のサイトが大変勉強になりました。
Kotlin Delegate Property について調べてみた - Qiita

次に気になるのは「remember」と「mutableStateOf」です。こちらについては次の章で取り扱います。
ちなみに先ほどのコードがないと(正確にはonValueChange = { text = it }のようにonValueChangeの指定が無いと)キーボードから文字列を入力できません。

状態の更新

状態とは、ユーザーの操作や時間によって変化する値を指します。
先ほどのキーボードによる入力も状態によって管理されています。
状態の更新について説明する前に、コンポーザブルのライフサイクルと再コンポーズについて説明します。

コンポーザブルのライフサイクル

コンポーザブル(@Composableアノテーションが付与された関数のこと)のライフサイクルについて説明します。
コンポーザブルが作成されると、コンポジションと呼ばれるものに配置されます。コンポーザブルはコンポジションによってツリー構造で関係性を管理されています。

詳しくは公式サイトで説明されています。
コンポーザブルのライフサイクル  |  Jetpack Compose  |  Android Developers

このコンポジション内でコンポーザブルが作成された後は、状態と呼ばれるものが更新されることで、0回以上再コンポーズが行われます。
再コンポーズとは、状態の変化によってコンポーザブルを再構築する工程のことです。
そして画面が切り替わったりUI要素が削除された時、対応しているコンポーザブルはコンポジションから退場します。

再コンポーズ

再コンポーズではコンポーザブルが再構築されますが、コンポジション内のコンポーザブル全てが再構築されるわけではありません。

例えば以下のコードを例に考えてみます。

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            RecomposeDemo()
        }
    }
}

@Composable
fun RecomposeDemo() {
    Log.d("Recompose", "RecomposeDemo composing")

    Column(modifier = Modifier.padding(16.dp)) {
        // トップレベルコンポーネント
        TopLevel {
            // 中間レベルコンポーネント
            MiddleLevel {
                // 最下層コンポーネント
                BottomLevel()
            }
        }
    }
}

@Composable
private fun TopLevel(
    content: @Composable () -> Unit
) {
    Log.d("Recompose", "TopLevel composing")

    var topCount by remember { mutableIntStateOf(0) }

    Column {
        Text(
            text = "Top Level (count: $topCount)",
            modifier = Modifier.padding(bottom = 8.dp)
        )

        Button(
            onClick = { topCount++ },
            modifier = Modifier.padding(bottom = 16.dp)
        ) {
            Text("Update Top")
        }

        content()
    }
}

@Composable
private fun MiddleLevel(
    content: @Composable () -> Unit
) {
    Log.d("Recompose", "MiddleLevel composing")

    var middleCount by remember { mutableIntStateOf(0) }

    Column(modifier = Modifier.padding(start = 16.dp)) {
        Text(
            text = "Middle Level (count: $middleCount)",
            modifier = Modifier.padding(bottom = 8.dp)
        )

        Button(
            onClick = { middleCount++ },
            modifier = Modifier.padding(bottom = 16.dp)
        ) {
            Text("Update Middle")
        }

        content()
    }
}

@Composable
private fun BottomLevel() {
    Log.d("Recompose", "BottomLevel composing")

    var bottomCount by remember { mutableIntStateOf(0) }

    Column(modifier = Modifier.padding(start = 32.dp)) {
        Text(
            text = "Bottom Level (count: $bottomCount)",
            modifier = Modifier.padding(bottom = 8.dp)
        )

        Button(
            onClick = { bottomCount++ }
        ) {
            Text("Update Bottom")
        }
    }
}

コンポジション内の構造は以下の通りです。

RecomposeDemo
└── Column
    └── TopLevel
        ├── Text ("Top Level (count: X)")
        ├── Button ("Update Top")
        └── MiddleLevel
            ├── Text ("Middle Level (count: Y)")
            ├── Button ("Update Middle")
            └── BottomLevel
                ├── Text ("Bottom Level (count: Z)")
                └── Button ("Update Bottom")

このコードは大きく分けて三層構造になっています。上から最上層、中間層、最下層があり、それぞれの層にボタンとボタンを押した回数を表示するテキストが設置されています。 また、それぞれの層に以下のようなコードが配置されています。

Log.d("Recompose", "MiddleLevel composing")

これはログを出力するコードです。再コンポーズが行われると必ずこのコードによってログが出力されます。
例えば最上位のコンポーザブルが再コンポーズされると以下のように出力されます。

2024-11-10 10:13:34.375 14056-14056 Recompose  com.example.myapplication  D  TopLevel composing

さらにそれぞれの層にはカウンターを状態として持っています。詳しくは次の章で取り扱います。

var middleCount by remember { mutableStateOf(0) }

この状態はそれぞれのボタンが押されることでインクリメントされます。

 Button(
            onClick = { middleCount++ }, // ここでインクリメント
            modifier = Modifier.padding(bottom = 16.dp)
        ) {
            Text("Update Middle")
        }

ボタンを押すと状態が更新されるので、再コンポーズが起こります。このとき、どの層で再コンポーズが起こってもそれぞれの層でしか再コンポーズが起こりません。
つまりその他の層では再コンポーズが起きず、余計な処理が走りません。

では実際に確かめてみましょう。まずは起動すると以下の様なログが出力されます。

2024-11-10 10:28:10.041 14318-14318 Recompose  com.example.myapplication   D  RecomposeDemo composing
2024-11-10 10:28:10.058 14318-14318 Recompose  com.example.myapplication   D  TopLevel composing
2024-11-10 10:28:10.133 14318-14318 Recompose  com.example.myapplication   D  MiddleLevel composing
2024-11-10 10:28:10.135 14318-14318 Recompose  com.example.myapplication   D  BottomLevel composing

最初は全て順番に作成されるので上の階層からコンポーザブルが構築されます。 ではここで最下層のボタンを押下して再コンポーズを発生させてみます。ログは以下のようになりました。

2024-11-10 10:28:10.135 14318-14318 Recompose  com.example.myapplication   D  BottomLevel composing

中間層や最上層のコンポーザブルでは再コンポーズが起こっていません。これは(自分的には)直感的です。 次に最上層を再コンポーズしてみます。直感的には中間層と最下層も再コンポーズしそうですが…

2024-11-10 10:28:10.058 14318-14318 Recompose  com.example.myapplication   D  TopLevel composing

ログは最上位のものしか表示されません。つまり中間層と最下層では再コンポーズは行われずそのままの状態を維持しています。
中間層で同じことを行っても、最上位層や最下層に影響はありません。

これがJetpack Composeにおける再コンポーズの特徴です。

状態の管理

状態の変化はライフサイクルと関係があることが分かりました。
それを踏まえて状態の管理について見てみます。

内容は以下の公式サイトに沿っています。
状態と Jetpack Compose  |  Android Developers

まず、状態を管理する方法として一番基本的なものは「remember」と「mutableStateOf」を使う方法です。 先ほどの再コンポーズを確かめるコードに以下のような記述があったと思います。

var middleCount by remember { mutableStateOf(0) }

この方法で作成された変数は変更が監視され、変更があるとそのコンポーザブルの再コンポーズが起こります。
先ほどのコードでも、ボタンが押されることでカウンターが更新されて再コンポーズが起こっていました。

@Composable
private fun TopLevel(
    content: @Composable () -> Unit
) {
    Log.d("Recompose", "TopLevel composing")

    var topCount by remember { mutableIntStateOf(0) } // これが更新されると再コンポーズされる

    Column {
        Text(
            text = "Top Level (count: $topCount)",
            modifier = Modifier.padding(bottom = 8.dp)
        )

        Button(
            onClick = { topCount++ },
            modifier = Modifier.padding(bottom = 16.dp)
        ) {
            Text("Update Top")
        }

        content()
    }
}

ただし、この方法では状態が一時的にしか保存されません。例えば画面が回転したり、プロセスが終了すると状態は初期化されてしまいます。
これは設定変更によってアクティビティと呼ばれるものをシステムが破棄するから起こるようです。

詳しくは公式サイトで説明されています。
アクティビティのライフサイクル  |  Android Developers

この状態の破棄を防ぐには「rememberSaveable」を使用します。先ほどのコードの最上層を以下のように書き換えてみます。

@Composable
private fun TopLevel(
    content: @Composable () -> Unit
) {
    Log.d("Recompose", "TopLevel composing")

    var topCount by rememberSaveable { mutableIntStateOf(0) } // ここを書き換える

    Column {
        Text(
            text = "Top Level (count: $topCount)",
            modifier = Modifier.padding(bottom = 8.dp)
        )

        Button(
            onClick = { topCount++ },
            modifier = Modifier.padding(bottom = 16.dp)
        ) {
            Text("Update Top")
        }

        content()
    }
}

// 以下は同じ

まずは起動して、それぞれのボタンを5回ずつ押下してみます。

この状態で画面を横にします。すると…

最上層のカウントは保持されますが、それ以外の層のカウントは0に戻ってしまいました。
以上で状態が保持されることが確認できたかと思います。

ここまで見ると、rememberよりもrememberSaveableを使った方が良さそうですが、場合によって使い分ける必要があります。
例えば、rememberSaveableの方がメモリ消費量が比較的多くなるため、 乱用すると不必要にメモリを圧迫してパフォーマンスが落ちることがあります。

状態ホイスティング

状態ホイスティングとは、状態を上位のコンポーネントに持ち上げるデザインパターンです。
この方法を用いることで、より柔軟で保守性の高いコンポーネントを作成できます。

例えば以下のコードを見てください。

// ステートフルな実装(ホイスティング前)
@Composable
fun NameInput() {
    var name by remember { mutableStateOf("") }
    TextField(
        value = name,
        onValueChange = { name = it }
    )
}

これを状態ホイスティングを使用して書き直したものが以下になります。

// ステートレスな実装(ホイスティング後)
@Composable
fun NameInput(
    name: String,
    onNameChange: (String) -> Unit
) {
    TextField(
        value = name,
        onValueChange = onNameChange
    )
}

// 状態を管理する親コンポーネント
@Composable
fun NameScreen() {
    var name by remember { mutableStateOf("") }
    NameInput(
        name = name,
        onNameChange = { name = it }
    )
}

このように実装するとどのようなメリットがあるのでしょうか。

まず、再利用性が向上します。
先ほどのコードで言えば、NameInputは複数の入力フィールドに使い回すことができます。

@Composable
fun UserProfileForm() {
    var firstName by remember { mutableStateOf("") }
    var lastName by remember { mutableStateOf("") }
    var nickname by remember { mutableStateOf("") }
    
    Column(modifier = Modifier.padding(16.dp)) {
        NameInput(
            name = firstName,
            onNameChange = { firstName = it }
        )
        
        NameInput(
            name = lastName,
            onNameChange = { lastName = it }
        )
        
        NameInput(
            name = nickname,
            onNameChange = { nickname = it }
        )
        
        // 入力値を使った処理が可能
        Text("Full name: $firstName $lastName ($nickname)")
    }
}

バリデーションを追加することも容易です。

@Composable
fun ValidatedNameForm() {
    var name by remember { mutableStateOf("") }
    var isError by remember { mutableStateOf(false) }
    
    Column {
        NameInput(
            name = name,
            onNameChange = { 
                name = it
                isError = it.length < 3
            }
        )
        
        if (isError) {
            Text(
                text = "名前は3文字以上必要です",
                color = MaterialTheme.colorScheme.error,
                modifier = Modifier.padding(start = 16.dp)
            )
        }
    }
}

他のフィールドと連携することもできます。

@Composable
fun LinkedNameFields() {
    var firstName by remember { mutableStateOf("") }
    var lastName by remember { mutableStateOf("") }
    var fullName by remember { mutableStateOf("") }
    
    Column {
        NameInput(
            name = firstName,
            onNameChange = { 
                firstName = it
                fullName = "$it $lastName"
            }
        )
        
        NameInput(
            name = lastName,
            onNameChange = { 
                lastName = it
                fullName = "$firstName $it"
            }
        )
        
        // 読み取り専用の結合フィールド
        NameInput(
            name = fullName,
            onNameChange = { }  // 変更不可
        )
    }
}

これらは、NameInputが内部に状態を持たないステートレスな実装になっているからです。 他にもテストが容易になるといった利点もあります。

詳しくは公式サイトをご覧ください。
状態と Jetpack Compose  |  Android Developers

最後に

今回はJetpack Composeの基本について学びました。宣言型の記述はFlutterを少し触っていたので無理なく学習を進めることができました。 また、再コンポーズの仕組みは複雑ではありますが、パフォーマンスが考慮されていている良い仕組みだと感じました。 今回学んだ内容を活かして、次回はJetpack Composeを用いて実際にアプリケーションを構築してみようと思います。