Jetpack Composeを扱う上で最低限押さえておきたい4つのポイントを語ってみた話

エクストーン Androidエンジニアの市橋です。 今回はAndroid開発のUIツールとして近年スタンダードになりつつあるJetpack Composeを使用する上で重要なポイントを簡潔に説明していきたいと思います。

自分自身Compose案件に携わる中で、従来のXML形式によるUI開発と比較して全く異なる技術であると身をもって感じました。Composeの基礎知識を理解することこそがComposeを適切に扱う上での近道になると考え、個人的に大切だと感じた4つの知識に焦点を当ててまとめていきます。

Jetpack Composeとは

そもそもJetpack Composeとは、AndroidのネイティブUIを構築するための新しい宣言型UIフレームワークのことを言います。全てKotlinで記述され、コンポーネント(Composable関数)を組み合わせてUIを構築していきます。

UIの状態を変更するとその変更が自動的に画面に反映されます。従来のView形式でもLiveDataやDatabinding等でも似たようなことは実現できていましたが、ComposeではUIの構造・レイアウト・デザイン・状態管理を一つのフレームワークとして提供しています。

これにより、UIコードがシンプルで読みやすく、保守性も容易になるというメリットがあります。また、コードの量を削減し、パフォーマンスの高いUI構築に貢献します。

詳細は公式ドキュメントを参照してみて下さい。

developer.android.com

重要なポイント

① 宣言的UI

まずはUI開発におけるコンセプトについてです。

そもそも宣言的UIとは、画面上に何を表示するかをコードで指定する方式です。俗に言う『UIをコードで書く』ってやつです。この方法は、従来のView形式である命令型UIとは大きく異なる概念になります。

命令型UIでは、UIを構築するために具体的な操作のステップを記述します。つまり、UIの各要素をどのように表示し、どのように変更するかを1つずつ手動で指定していきます。

// 命令型UIの例
val button: Button = findViewById(R.id.my_button)
val textView: TextView = findViewById(R.id.my_textview)

button.setOnClickListener {
    textView.text = "Hello"
}

上記のコードでは、「ボタンとテキストビューを取得し、ボタンがクリックされたらテキストビューを更新する」という具体的な操作を一つずつ指定していきます。

一方で、宣言的UIでは、UIがどのように見えるべきか、またどのように振る舞うべきかを宣言します。具体的な操作のステップは記述せず、UIの状態とその表示を定義します。つまり、完成形のUIを記述するのです。

// 宣言的UIの例
@Composable
fun GreetingButton() {
    var text by remember { mutableStateOf("World") }

    Column {
        Button(onClick = { text = "Hello" }) {
            Text("Press Me")
        }
        Text(text)
    }
}

この例では、「ボタンとテキストがあり、ボタンが押されたらテキストが更新される」というボタンとテキストの関係(UIの状態とその表示)を宣言しています。こちらの方がUIのイメージが付きやすく、開発者フレンドリーになっていますね。

この宣言的UIのアプローチを基本とするComposeは、開発者がUIの動作や見た目により集中できるようにし、具体的な操作の詳細をシステム側に任せることで、よりシンプルで効率的なコードを可能にしています。

② Recomposition

次にComposeの描画方法についてです。

Recomposition(再構成)とは、Jetpack ComposeにおいてUIが効率よく更新される仕組みのことです。状態が変わると、その変更に依存するUIの部分だけが「再構成」されます。

@Composable
fun Counter() {
    var count by remember { mutableStateOf(0) }
    Button(onClick = { count++ }) {
        Text("Count is: $count")
    }
}

上記の例ではremember関数を用いています。このrememberは、状態を保持するために非常に重要で、rememberがないと、Recompositionが行われるたびに状態も初期化されてしまい、期待する動作になりません。

ボタンがクリックされると、countがインクリメントされ、Textコンポーズが自動的にRecompositionされます。しかし、Buttonやその他のUIは影響を受けません。

以下のように従来のView形式では、状態が変わると、その変更を反映するためにUI要素を手動で更新する必要がありました。

// XML
<Button android:id="@+id/my_button" android:text="Count is: 0" />

// Kotlin
val myButton: Button = findViewById(R.id.my_button)
var count = 0
myButton.setOnClickListener {
    count++
    myButton.text = "Count is: $count"
}

Recompositionは非常に効率的な面がありますが、考慮すべきパフォーマンスの問題も存在します。頻繁にRecompositionされると計算コストが積み重なりパフォーマンスに影響を及ぼしてしまいます。例えば、リスト内で各アイテムに複雑な計算が含まれる場合が該当します。

なので、「何かもっさりするな」と思ったら、まずはLayout Inspector等でRecompositionした回数を確認してみるのが良いかと思います。

③ 状態ホイスティング

そしてComposeのコードを記述する上でのテクニックについてです。

状態ホイスティングは、状態(State)をコンポーズ関数の外部に「持ち上げる(hoist)」設計パターンです。これによって、状態をより上位のコンポーズ関数で管理し、下位のコンポーズ関数はその状態を参照または操作できるようになります。

ここで、状態ホイスティングを使用したパターンと使用しないパターンを比較していきましょう。

状態ホイスティングを用いない例
@Composable
fun CounterWithoutHoisting() {
    var count by remember { mutableStateOf(0) }
    Button(onClick = { count++ }) {
        Text("Count is: $count")
    }
}

上の例では、CounterWithoutHoistingコンポーズ関数内でcountの状態を管理しています。

状態ホイスティングを用いる例
@Composable
fun CounterWithHoisting(count: Int, updateCount: (Int) -> Unit) {
    Button(onClick = { updateCount(count + 1) }) {
        Text("Count is: $count")
    }
}

@Composable
fun Parent() {
    var count by remember { mutableStateOf(0) }
    CounterWithHoisting(count, { newCount -> count = newCount })
}

この例では、CounterWithHoistingは自身の状態を持たず、countupdateCountをパラメータとして受け取ります。状態はParent関数で管理されます。

メリットは主に以下の3つです。

  1. 再利用性が向上: 状態を内部に持たないため、他のコンポーズ関数でも容易に使用できます。
  2. テストしやすい: 状態が外部から注入されるので、テストがしやすくなります。
  3. コンポーズ関数間での状態共有が容易: 複数のコンポーズ関数が同じ状態を参照できます。

ただし、すべての状態をホイスティングする必要はありません。状態がそのコンポーズ関数内で完結する場合、ホイスティングは不要です。

④ Modifier関数

最後にComposeで表示や動作を制御する方法についてです。

Jetpack ComposeのModifier関数は、Composable関数(例:TextButton)に対して、スタイルや動作を設定するための拡張関数です。幅や高さ、背景色、クリックイベントなど、ほぼすべてのUI関連の設定が可能です。

Text(
    "Hello, World!",
    modifier = Modifier
        .padding(16.dp)
        .background(Color.Green)
        .border(2.dp, Color.Black)
)

上記の例では、Text コンポーネントに対して以下のModifierを設定しています。

  1. padding(16.dp): 内側の余白(パディング)を16dpに設定
  2. background(Color.Green): 背景色を緑色に設定
  3. border(2.dp, Color.Black): 黒色の境界線を設定、その太さは2dp

また、Modifier関数は各パラメータをチェーンできるという特性がありますが、この順番が非常に重要です。実際には上から順に処理されるので、適用順を意識して記述していく必要があります。

つまり、Modifierの順番は例えるとレイヤーを重ねるようなイメージです。先に書いたModifierが一番下のレイヤーで、次に書いたModifierがその上に重なります。

例えば、

Modifier
    .background(Color.Green)
    .padding(16.dp)

この順番だと、緑色の背景を最初に設定して、その上に余白を設定するので、余白の内側まで緑色になります。

逆に、

Modifier
    .padding(16.dp)
    .background(Color.Green)

この順番だと、まず余白を設定してからその上に緑色の背景がくるので、余白が緑色に染まらず、緑色の外側に余白ができます。

おわりに

業界の盛り上がりからしても、Jetpack ComposeはAndroid開発の未来を切り開く新しいツールであると感じてます。

しかし、流行っているからと言って「なんとなく使う」だけでは真の力を発揮することはできないと思うので、まずはComposeの基本的な知識を的確に理解することが第一歩だと考えます。正式版のリリースから2年以上経った今だからこそ初心にかえることが大切だと思い、今回は執筆しました。

自分もまた弊社でComposeをより正しく扱えるように粛々と励んでいきたいと思います。ありがとうございました。