Android
 Computer >> コンピューター >  >> システム >> Android

初心者向け Jetpack Compose チュートリアル – コンポーザブルと再構成(リコンポジション)の基本を理解しよう

このチュートリアルでは、AndroidのJetpack Compose UIライブラリに関する基本的な概念と用語をわかりやすく解説します。

本記事はComposeの初心者向けガイドですが、Android開発自体の初心者向けガイドではありません。そのため、少なくとも1〜2個程度はアプリケーションを開発した経験があること(必ずしもComposeを使った開発である必要はありません)を前提としています。

始める前にひとつだけ補足します。当初は、より経験豊富な開発者向けの続編記事を書くつもりでしたが、Leland Richardson氏による2部構成の記事シリーズに出会い、方針を転換しました。Leland氏はJetpack Composeチームで活躍するソフトウェアエンジニアであるだけでなく、非常に優れたライターでもあります。

本記事はJetpack Composeの基礎入門として単体でも十分に役立つ内容ですが、ある程度Composeでの実践経験を積んだ後には、ぜひ彼の記事も読んでみることを強くおすすめします(最初から深い内容で学びたい方は、今すぐ読み始めても問題ありません)。

本記事で解説する重要な用語・概念

  • 旧ViewシステムとView階層のおさらい
  • コンポーザブルとは何か、Viewとの関係性
  • 再構成(リコンポジション)の仕組みと、やってはいけないアンチパターン

コンポーザブルとは?

このセクションでは、Jetpack Composeライブラリの最も根幹となる部分について説明します。ベテランのAndroid開発者の方は、「コンポーザブルはViewなのか?」というサブセクションまでスキップしても構いません。

まだViewシステムに馴染みがない方は、まず次のセクションを読むことをおすすめします。コンポーザブルとは何かを理解するうえで必要な知識だからです。

View階層とは

Android SDK(このプラットフォーム上でUIを構築するために使うライブラリ群)の文脈における「View」とは、アプリケーションに構造とスタイルを与えるための要素です。

ViewはUI(ユーザーインターフェース)を構成する最も基本的な部品であり、それぞれの部品は主に以下のような情報を持っています。

  • X座標・Y座標の開始位置と終了位置:デバイス画面上のどこにViewを描画するかを決定する
  • 色やアルファ値(透明度)
  • フォント情報、テキスト、記号、画像
  • ユーザーの操作(クリックなど)やアプリデータの変化といったイベントに応じた振る舞い(詳細は後述)

ここで重要なのは、Viewはボタンのようなウィジェットであることもあれば、画面全体・画面の一部、あるいは他の子Viewを格納するコンテナにもなり得るという点です。

こうしたコンテナは、文脈によって「レイアウト」や「ViewGroup」と呼ばれます。ウィジェットとほぼ同じ種類の情報を持ちながら、それに加えて、内部にネストされた他のViewをどのように配置・表示するかという情報も持っています。

それを踏まえると、Viewシステムのおさらいで最も重要なポイントはView階層(View Hierarchy)です。Web開発者の皆さんなら、View階層は「Android版のDOM(Document Object Model)」だと考えると分かりやすいでしょう。

Android開発者の皆さんにとっては、XMLファイルやJava/Kotlinコードで定義したすべてのViewを仮想的に表現したものがView階層だとイメージしてください。

具体例として、次のようなXMLファイルを見てみましょう(細部まで覚える必要はありません。名前だけ確認してください)。その後、デバッガやステッパーツールを使って、このファイルをインフレート(展開)したFragmentのメモリ空間がどうなっているかを見てみます。

fragment_hour_view.xml:

<?xml version=”1.0" encoding=”utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android=”https://schemas.android.com/apk/res/android"
android:layout_width=”match_parent"
android:layout_height=”match_parent"
android:id=”@+id/root_hour_view_fragment"
xmlns:app=”https://schemas.android.com/apk/res-auto"
>
<androidx.compose.ui.platform.ComposeView
android:id=”@+id/tlb_hour_view"
//...
 />
<com.wiseassblog.samsaradayplanner.ui.managehourview.HourToggleView
android:id=”@+id/vqht_one”
//...
/>
<com.wiseassblog.samsaradayplanner.ui.managehourview.HourToggleView
android:id=”@+id/vqht_two”
//...
/>
<com.wiseassblog.samsaradayplanner.ui.managehourview.HourToggleView
android:id=”@+id/vqht_three”
//...
/>
<com.wiseassblog.samsaradayplanner.ui.managehourview.HourToggleView
android:id=”@+id/vqht_four”
//...
/>
</androidx.constraintlayout.widget.ConstraintLayout>

(Fragment)HourView.kt のメモリ空間:

初心者向け Jetpack Compose チュートリアル – コンポーザブルと再構成(リコンポジション)の基本を理解しよう
View階層の様子

デバッガやステッパーツールは、各種ライブラリのコードが内部で何をしているのかを学ぶのに私が最も好んで使う手段のひとつです。ぜひ一度試してみてください。

このXMLファイルと、それがプロセス(プロセスとは、デバイス上で実行されているプログラムそのものを指します)の中でどうなるかをお見せした目的は、XML内にネストされたViewが、実行時にはネストされたView階層として変換されることを示すためでした。

旧システムのシンプルかつ具体的なモデルを頭に入れておけば、新しいシステムとの比較がしやすくなるはずです。

コンポーザブルはViewなのか?

これは私がComposeを使い始めた頃に真っ先に抱いた疑問のひとつで、辿り着いた答えは「YesでもありNoでもある」というものです。

Yesである理由は、コンポーザブルが旧システムにおけるViewと概念的に同じ役割を果たしているからです。コンポーザブルはボタンのようなウィジェットにも、ConstraintLayoutのようなコンテナにもなり得ます(ちなみにConstraintLayoutにはComposable実装が用意されています)。

Noである理由は、UIがもはやView階層として仮想的に表現されなくなったからです(相互運用性に関わるケースを除きます)。とはいえ、Composeが魔法を使ってUIを仮想表現し追跡しているわけではありません。つまり、View階層に概念的に相当する何かが必ず存在するはずです。

その「何か」を少しだけ覗いてみましょう。次はsetContent {…}関数を使って、Activityにコンポーザブルを紐付けている例です。

ActiveGameActivity.kt:

class ActiveGameActivity : AppCompatActivity(), ActiveGameContainer {
private lateinit var logic: ActiveGameLogic
override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    val viewModel = ActiveGameViewModel()
    setContent {
        ActiveGameScreen(
            onEventHandler = {
                logic.onEvent(it)
            },
            viewModel
        )
    }
    logic = buildActiveGameLogic(this, viewModel, applicationContext)
}
//…
}

ActiveGameScreen.kt:

@Composable
fun ActiveGameScreen(
    onEventHandler: ((ActiveGameEvent) -> Unit),
    viewModel: ActiveGameViewModel
) {
    //...

    GraphSudokuTheme {
        Column(
            Modifier
                .background(MaterialTheme.colors.primary)
                .fillMaxHeight()
        ) {
            ActiveGameToolbar(
                clickHandler = {
                    onEventHandler.invoke(
                        ActiveGameEvent.OnNewGameClicked
                    )
                }
            )

            Box {
              //content
            }
        }
    }
}

Composeでは、View階層はこのActivityのmWindowフィールドの奥深くまで掘り下げると見つかるものに置き換えられています。そこにあるのが、View階層の概念的な代替物、すなわちComposer(コンポーザー)とそのslotTable(スロットテーブル)です。

初心者向け Jetpack Compose チュートリアル – コンポーザブルと再構成(リコンポジション)の基本を理解しよう

ComposerとslotTableの詳細な概要を知りたい方は、やはりLeland氏の記事(第2部で詳しく解説されています)を読むことをおすすめします。Compose階層はComposerとslotTableだけではありませんが、入門としてはこれで十分でしょう。

一般的に言えば、Jetpack Composeは「Compose階層」と呼べるものを使用しています(ComposerやslotTableなどによって構成・管理されています)。

これもView階層と同じ概念、つまりメモリ空間上のオブジェクト群がまとめてUIを表現するという仕組みですが、実装方法はまったく異なります。

ただし、技術的には理解しづらいものの、原理としては簡単に理解できる重要な違いがあります。それが、Compose階層への更新を処理する仕組み、再構成(Recomposition)です。

再構成(リコンポジション):Compose UIを更新する仕組み

英語学習中の方のために補足すると、「Compose」という言葉はラテン語のcomponere(「組み合わせる」「組み立てる」という意味)に由来します。音楽を作る人は「Composer(作曲家)」と呼ばれますが、これは複数の楽器から生まれる音符をひとつの楽曲へと組み上げる人と捉えられます。

「組み立てる」ということは、個々のパーツが存在することを意味します。優れたソフトウェア開発者のほとんどが、コードを妥当な範囲で最小の単位に分割するよう努めているのは、このためです。

あえて「妥当な範囲で」と言ったのは、DRY(Don't Repeat Yourself)のような原則は、作り出す問題より解決する問題が多い場合にのみ従うべきだと考えているからです。

この概念(一般にモジュール化、私の好みで言えば「関心の分離(Separation of Concerns:SOC)」と呼ばれるもの)には多くのメリットがあります。「Leland氏の記事の受け売りだ」と思われるかもしれませんが、私は長年、SOCをソフトウェアアーキテクチャの黄金律として提唱してきました。

この原則がComposeで活きる場面は、人気のJavaScriptライブラリReactで見られるのと同じ考え方です。正しく実装すれば、Composeは再描画が必要なコンポーザブル(UIの部品)だけを「再構成」(再描画・再レンダリング・更新など)します。

これはアプリのパフォーマンスにおいて極めて重要です。旧ViewシステムであれComposeであれ、UIの再描画はシステムリソースを大きく消費するからです。

ご存じない方のために付け加えると、旧RecyclerView(2016年に私が初めてチュートリアルを書いた題材です!)の存在意義そのものが、ViewHolderパターンをデータのリストに適用することで、リストアイテムごとに毎回新しいViewをインフレート(生成)する手間を省くことにありました。

本記事では理論を中心に扱いましたが、今後数ヶ月で実践的なコンテンツもたくさん公開する予定です。とはいえ、記事の締めくくりとして、私自身の直接体験から得たエピソードを紹介します。再構成の仕組みと、「やってはいけないやり方」をさらに深く理解できるはずです。

ストップウォッチの実装例

初めてフルスケールのComposeアプリを作るとき、私は数独(Sudoku)を選びました。理由はいくつかありますが、ひとつはUIが極端に複雑にならないプロジェクトが欲しかったこと、もうひとつはグラフのデータ構造とアルゴリズムを深く掘り下げる機会が欲しかったことで、どちらも数独パズルにぴったりでした。

その際、ユーザーがパズルを解くのにかかった時間を計測するストップウォッチが必要でした。

初心者向け Jetpack Compose チュートリアル – コンポーザブルと再構成(リコンポジション)の基本を理解しよう
Graph Sudoku パズル

この業界ではよくあることですが、タイマーの追加は実際よりもはるかに簡単だと思い込んでいました。AndroidのChronometerクラスやJavaのTimerクラスを試しましたが、どちらも異なるものの、いずれもアプリを破綻させる問題を引き起こしました。

最終的に一歩引いて考えて気づきました。「自分はKotlinで書いているのだから」と。そこでプレゼンテーションロジッククラス(結果的にそこが最も理にかなっていました)にコルーチンベースのタイマーを設置し、毎秒ViewModelを更新するようにしました。

Class ActiveGameLogic(…):…{
//…
inline fun startCoroutineTimer(
    delayMillis: Long = 0,
    repeatMillis: Long = 1000,
    crossinline action: () -> Unit
) = launch {
    delay(delayMillis)
    if (repeatMillis > 0) {
        while (true) {
            action()
            delay(repeatMillis)
        }
    } else {
        action()
    }
}
private fun onStart() =
launch {
    gameRepo.getCurrentGame(
    { puzzle, isComplete ->
        viewModel.initializeBoardState(
            puzzle,
            isComplete
    )
        if (!isComplete) timerTracker = startCoroutineTimer {
            viewModel.updateTimerState()
        }
    },{
        container?.onNewGameClick()
    })
}
//…
}

ViewModel(AACのものではなく自作しています。ただし、見た限りComposeはAACのViewModelとも良好な相互運用性を持っています)はコールバック関数への参照を公開しており、これを使ってコンポーザブルを更新します。

class ActiveGameViewModel {
    //…
    internal var subTimerState: ((Long) -> Unit)? = null
    internal var timerState: Long = 0L
    //…
    internal fun updateTimerState(){
        timerState++
        subTimerState?.invoke(timerState)
    }
//…
}

ここからが重要なポイントです! remember関数などのComposeの機能を使うことで、Compose階層の再構成をトリガーできます。

var timerState by remember {
    mutableStateOf("")
}

参考までに言うと、これらの機能はrememberで保持した状態をslotTableに保存します。簡単に言えば、ここでの「状態(state)」とはデータの現在の状態を意味し、初期値は空文字列です。

そして、ここで私は失敗しました。 シンプルなタイマーコンポーザブルを独立した関数に切り出し(SOCの適用)、timerStateをパラメータとして渡していたのです。

ところが、先ほどのコード断片はタイマーの親コンポーザブルの中に置かれていました。親はUIの中で最も複雑な部分(9×9の数独盤面には大量のウィジェットが必要です)のコンテナだったのです。

@Composable
fun GameContent(
    onEventHandler: (ActiveGameEvent) -> Unit,
    viewModel: ActiveGameViewModel
) {
    Surface(
        Modifier
            .wrapContentHeight()
            .fillMaxWidth()
    ) {
        BoxWithConstraints(Modifier.background(MaterialTheme.colors.primary)) {
            //…
            ConstraintLayout {
                val (board, timer, diff, inputs) = createRefs()
                var isComplete by remember {
                    mutableStateOf(false)
                }
                var timerState by remember {
                    mutableStateOf("")
                }
                viewModel.subTimerState = {
                    timerState = it.toTime()
                }
                viewModel.subIsCompleteState = { isComplete = it }
            //…Sudoku board
            //Timer
                Box(Modifier
                    .wrapContentSize()
                    .constrainAs(timer) {
                        top.linkTo(board.bottom)
                        start.linkTo(parent.start)
                    }
                    .padding(start = 16.dp))
                {
                    TimerText(timerState)
                }
            //…difficulty display
            //…Input buttons
            }
        }
    }
}
@Composable
fun TimerText(timerState: String) {
    Text(
        text = timerState,
        style = activeGameSubtitle.copy(color = MaterialTheme.colors.secondary)
    )
}

このせいで、顕著なラグと操作の無反応が発生しました。デバッガを駆使して原因を突き止めました。timerState変数が親コンポーザブルの中で生成・更新されていたため、UI全体のその部分の再構成がトリガーされていたのです。毎秒毎秒、毎回。

該当するコードをTimerTextコンポーザブルの中へ移動したところ、動作は非常に滑らかになりました。

@Composable
fun TimerText(viewModel: ActiveGameViewModel) {
    var timerState by remember {
        mutableStateOf("")
    }

    viewModel.subTimerState = {
        timerState = it.toTime()
    }

    Text(
        text = timerState,
        style = activeGameSubtitle.copy(color = MaterialTheme.colors.secondary)
    )
}

この記事を通じて、再構成の仕組みと、典型的な失敗パターンのひとつについて、実用的な理解が得られたなら幸いです。

不要な再構成を避けることは、パフォーマンスの観点から極めて重要です。現時点では、SOCを徹底的に適用し、rememberによる状態保持を独立したコンポーザブルの中に分離するところまで行き着くのが、標準的なプラクティスになるべきだと感じています。

リソース&サポート

この記事が気に入ったら、SNSでのシェアや、freeCodeCampでの他の記事のチェックもお願いします。数百本のチュートリアルを公開しているYouTubeチャンネルも運営しており、さまざまなプラットフォームで執筆活動も行っています。

SNSでつながる

InstagramとTwitterでも活動していますので、ぜひフォローしてください。

最後に、私がJetpack Composeを始めるにあたって唯一頼りにしたリソースを紹介します。それは「優れた開発者による動くコードサンプル」です。

  1. Androidの可視性リスナー(Visibility Listener)の使い方と導入すべき理由

    AndroidのUIはViewを組み合わせて構築されており、通常のアプリケーションには複数のViewが存在します。ユーザーが現在どのViewを見ているのかを把握するには、可視性リスナー(Visibility Listeners)を実装する必要があります。 この記事では、Viewの表示状態を検知するためのさまざまな手法について詳しく解説します。 Viewを画面に表示させる方法 リスナーを正しく機能させるには、まず対象のViewがレイアウト階層内に存在していることを確認する必要があります。これには2つの方法があります。 XMLファイルで定義されたレイアウトに、あらかじめViewが組み込まれている

  2. Kotlinで実装するAndroidアニメーションのアクセシビリティ対応ガイド

    初めてのAndroidコントリビューション向けにサンプルコードを調査していたところ、Kotlinで書かれたアニメーションの事例はほとんど存在せず、ネイティブなアニメーションにおけるアクセシビリティへの配慮を示すコード例も同様に少ないことが分かりました。そこで本記事では、Kotlinでネイティブな「展開(expand)」アニメーションを作成する方法と、TalkBackや拡大文字を利用しているユーザーを支援するための工夫について解説します。すべてのコードはサンプルリポジトリで公開しており、アニメーションするビューを含む単一のアクティビティで構成されています。元となるコードはCalum Turner