⚙️

Jetpack Compose Internalsを読む【その1】

2024/03/28に公開

はじめに

「Jetpack Compose Internals」という本があります。Composeを書く者としてぜひ読んでおきたいと思って発売直後に購入していたものの、積ん読状態になっていました。最近モチベーションが高まってきたので、単に読むだけでは頭に入りきらなそうということと、日本語でまとめてくれている記事などあまり見かけないため、せっかくだからアウトプットしながら読もうと思いました。単に邦訳するだけだと著作権的によくなさそうなので、独自のまとめとしていきたいと思います。不定期で続編を書いていきます。

※コードスニペットも自作のものなのであしからず...

原典はこちらで購入できます。
https://leanpub.com/composeinternals

前書き(Prelude)

なぜこの本を読むか

Composeは不可避になってきてるので、中身を知っとくのは大事だよね。
ComposeのAndroid以外の用途も一応カバーしてるよ。

この本は何でないか

公式ドキュメントみたいなコンポーネントのカタログじゃないよ。
下記の本もオススメとのこと。
https://practicaljetpackcompose.com/

なぜ内部を知るのか

プラットフォームの内部を知ってるか知ってないかがプロっぽさの秘訣。

根源の情報に近づく

ソースコードが読めることは一番汎用性の高いスキルだよ。Androidのソースについては下記のサイトで網羅的に知れるよ。
https://cs.android.com/

コードスニペットと例

Compose自体はあらゆる種類のノードを持つ大規模コールグラフに使用できるけど、この本のスニペットはUI志向だよ。

1章 Composable関数

Composable関数の意味

Composable関数はComposeランタイムがメモリ上に展開する大きなツリー構造上の、ノードの集まりだとみなすことができる。

@Composableアノテーション(注釈)を普通の関数につけることで、データをツリー構造に登録するノードに変換することをコンパイラに宣言している。この登録は関数を実行するときの副作用の一種として行われる。

上記の作用はComposeでの専門用語でemitするとして知られている。
Composable関数の唯一の目的はメモリ内のツリー構造を更新すること。ツリーを読み書きできるし、ノードの追加や削除、順番の入れ替えもできる。

Composable関数の属性

@Composable注釈を関数に追加することで、関数に拘束が生まれて、並列再コンポーズや任意の順番での再コンポーズ、位置のメモ化といった最適化を、Composeランタイムができるようになる。

呼び出し文脈(Calling Context

Composable関数の属性のほとんどは、Composeコンパイラによって使えるようになる。
これはKotlinコンパイラのプラグインの一種(!)であるため、Kotlinコンパイラがアクセスできる全情報を使える。これによって全てのComposable関数の中間形態(IR)に介入できる。

この介入によって、Composable関数にはComposerという引数が最後尾に暗黙的に追加される。Composerインスタンスはランタイムに追加され、子要素に伝播されるため、全ての階層のComposable関数でアクセスできる。

例えば、こんなComposable関数は、

@Composable
fun SampleComposable(text: String, modifier: Modifier = Modifier) {
    Box(modifier) {
        SampleElement(text)
    }
}

ランタイム時はこうなる(引数に$composerが追加されている!)
(<*>は、ジェネリック型のプレースホルダー、$はただランタイムであることの強調の意味なのか?よく分からず)

@Composable
fun SampleComposable(text: String, modifier: Modifier = Modifier, $composer: Composer<*>) {
    Box(modifier, $composer) {
        SampleElement(text, $composer)
    }
}

Composer引数が追加されることで、Composable関数はComposable関数からしか呼べなくなる。これによって、ツリーがComposable関数のみで構成されることが保証される。

Composer引数は開発者とComposableランタイムの架橋役であり、Composable関数はこれを使ってメモリ内表現の形状の構築や更新を、ランタイムに知らせる。

冪(べき)等性

Composable関数は、生成するノードツリーについて冪等(同じ引数なら、いつ何時呼ばれても全く同じ返り値を返す)であることが期待される。再コンポーズとはComposable関数を再実行するこであり、これによってツリーが更新される。冪等性のおかげで、同じ引数値の関数はスキップしても良いことが保証され、処理の効率化に寄与する。

副作用を制御下に置く

外部要因の影響を受ける関数呼び出し(API呼び出しやキャッシュの読み出し等)は、引数値が同じでも同じ値を返すわけではない(冪等ではない)ので、副作用の一種である。

Composble関数は、安全のために短い時間に何度も連続で呼び出されることがあり、さらに異なるスレッドから無調整で実行されるため、副作用のある関数をComposable関数内にベタ書きしてしまうと、非常にリスキーである。

例えばこんな感じ(重い計算処理やAPI呼び出しをベタ書きしてしまっている)

@Composable
fun DangerousSideEffectComposable(someClass: SomeClass) {
    val heavyCalculationResult = someClass.heavyCalculation()
    val heavyApiCallResult = someClass.heavyApiCall()
    ChildComposable(heavyCalculationResult, heavyApiCallResult)
}

もう一点注意点として、他のComposable関数の返り値に他のComposable関数を依存させるのもよくない。記述する順番が拘束されるのは何としても避けるべきである。

例えばこんな感じ

@Composable
fun OrderDependentComposable() {
    val importantResult = FirstComposable()
    SecondComposable(importantResult) // FirstCompsableの実行結果に依存
    ThirdComposable() // SecondComposable内での計算結果に依存
}

Composable関数は基本的にはなるべく無状態(Stateless)であることが望ましい。どうしても副作用が必要なときは、なるべくツリーの一番親(root)に記述することが多い。

Composeの用意するエフェクトハンドラー(LaunchedEffectなど)は、副作用をComposeのライフサイクルに沿ったものにする。Composableがツリーから削除されると自動的に副作用をキャンセルさせたり、入力が変更されたら再度トリガーしたり、逆に再コンポーズ時に継続させたりできる。

再実行可能性

通常の関数は1回だけ呼ばれ、その内部にある子関数や孫関数が1回ずつ呼び出される。

Composable関数では、子関数や孫関数への参照を保持しているため、変更があった関数のみを何回も呼び出す(再コンポーズする)ことができる。孫だけ3回とか。Composable関数は、入力値が変わると4,5回再実行されることもある(!)

Composableコンパイラは、状態を読み取れる全てのComposable関数(状態を読み取らないものは無視される)を見つけ、ランタイムにどのように再実行するかを指示するコードを生成する。

高速な実行

Composable関数は、UIを構成して返す関数ではなく、メモリ内構造の更新をするだけの関数なので、驚くほど高速に動作するし、ランタイムに何度も連続で実行されても問題がない。例えばアニメーションなどは、毎フレーム実行される(!)が問題ない。

計算コストの高い処理はコルーチンに任せ、エフェクトハンドラーで包む形で実装することを開発者は意識する必要がある。

位置のメモ化

位置のメモ化(momoization, ※memorizationではない)は関数のメモ化の一種で、関数実行結果をキャッシュとして保持しておく仕組みである。これが成り立つためには、冪等性(同じ入力に同じ出力を返すことが約束される)が必要である。

なお関数のメモ化は、純粋な関数(冪等性があり、返り値を持たない関数)の構成物としてプログラムが定義される、関数型プログラミングのパラダイム特有のテクニックである。

関数のメモ化では、関数名・型・入力値の組み合わせに固有のIDを割り当てて、キャッシュ値の識別を行っている。Composable関数では、それに加えて自分を呼び出した親に基づいてランタイムが生成したIDも「位置情報(ソース内での位置)」として持っている。このComposable関数の自己同一性は、再コンポーズの際にも保持される。

しかし、forループなどで呼ばれるComposable関数は同じ位置情報になってしまう。Composeランタイムはこれを区別するべく、呼び出す順番を1つの識別子として利用する。この場合、後ろに要素を追加していく場合は問題なくとも、要素を挿入する場合などは大規模な再コンポーズを引き起こしてしまい、非効率である。

そこで活躍するのがkeyコンポーザブルである。これにより手動で識別子を明示的に与えられ、必要最小限の再コンポーズで済むようになる。

これを

@Composable
fun LargeListComposable(largeList: List<Item>) {
    largeList.forEach { item ->
        ListItemComposable(item)
    }
}

こうする。

@Composable
fun LargeListComposable(largeList: List<Item>) {
    largeList.forEach { item ->
        key(item.id) { // <- これのおかげで、変更のあった要素のみ選択的に再実行される
            ListItemComposable(item)
        }
    }
}

さらに、Composable関数内で何か値(負荷の重い計算結果など)を明示的にキャッシュしたいときに便利なのが、rememberコンポーザブルである。このキャッシュ値の識別は、呼び出された場所情報と内部の入力値で行う。なお、rememberコンポーザブルはノードツリーの状態を保持するメモリ内構造を読み書きできるComposable関数であり、位置メモ化の機構を開発者に公開したものと言える。(keyコンポーザブルのお仲間、というよりむしろ先輩)

メモ化で保持された情報は、いわばComposable関数のスコープに限定されたシングルトンのようなものである。同じComposable関数が異なる親から呼ばれた場合、異なるインスタンスが生成される。

Suspend関数との類似性

KotlinのSuspend関数も、Suspend関数からしか呼ばれない点で、呼び出し文脈(Calling Context)をもっている。全てのSuspend関数はランタイム環境を暗黙的に引数に渡しており、これがContinuation引数である。Kotlinのコルーチンにおいて、これはコールバックの一種であり、プログラムにどのように実行を続けるかを指示する。

例えば、こんな感じのsuspend関数は、

suspend fun SomeAsynchronousFunction(someThing: SomeThing): ResultThing {
    val result = AnotherAsynchronousFunction(someThing)
    ...
    return result
}

ランタイム時はこうなる(引数に実行結果を返すcallbackを持った手続き型関数になっている!)

fun SomeAsynchronousFunction(
    someThing: SomeThing,
    $callback: Cotinuation<ResultThing>
): Unit

Continuationには、Kotlinランタイムがプログラム内の様々な中断ポイントから実行を中断したり再開したりするための全情報が含まれている。呼び出し元のコンテクストを要求することによって、暗黙的な情報を実行ツリー全体で伝達することができる。

同様に、@Composable注釈も、普通の関数を再開可能にしたり、リアクティブに変換するKotlinの言語機能として理解することができる。

暗黙的引数であるContinuationは、Composerとよく似ているため、これを代わりに使えないかと思うかもしれない。しかし、Continuationはコールバックのインターフェースとして定義されており、関数の実行の中断や再開に特化している。一方Composerは、大規模なツリーのメモリ表現を構成するのに特化しており、ランタイムでの最適化のされ方が全く異なる。

Composable関数の色分け

GoogleのDart開発チームメンバーのBob Nystrom氏は2015年に、「関数の色分け」という概念をこちらの記事で提唱した(ちょろっと読んでみたところ、だいぶ言葉の鋭い記事ですが...)。元は同期関数(青色)と非同期関数(赤色)がそれぞれ相容れないという話であった。
これにより、多くのライブラリでPromisesとasync/awaitが導入された。

KotlinのSuspend関数も同様の「関数の色」がついている。Suspend関数はSuspend関数からしか呼べず、普通の関数から呼び出すにはコルーチンを発動させる必要があるし、実際の統合のされ方を開発者は知っているわけではない。同期関数と非同期関数は全く異なる性質をもつため、もはや別の言語のようなものである。

Composable関数も同じく色つきの関数で、通常の関数からは呼べず、もし呼ぼうとすると統合ポイント(例えばComposition.setContentなど)が必要になる。ノードツリーの更新に特化した関数なのだ。

しかし下記のような例で、Composable関数をforEachラムダの中で呼ぶことができるのはなぜだろうか?まるで普通の関数でComposable関数を呼べてしまっているように見える。

@Composable
fun MysteriousCallComposable(modifier: Modifier = Modifier, someList: List<SomeThing>) {
    Column(modifier) {
        someList.forEach { item ->
            MysteriouslyCalledComposable(item)
        }
    }
}

実はコレクション演算子は、inline関数として定義されているためである。
(inline関数は、こちらの記事の解説がわかりやすいですが、コンパイル先で関数の中身をベタ書きで展開する挙動をするため、forEach内のComposable関数は親のComposable関数内に展開される挙動になると理解できます。)

コンパイルすると実質こうなっている?

@Composable
fun MysteriousCallComposable(modifier: Modifier = Modifier, someList: List<SomeThing>, $composer: Composer<*>) {
    Column(modifier, $composer) {
        MysteriouslyCalledComposable(item0, $composer)
        MysteriouslyCalledComposable(item1, $composer)
        MysteriouslyCalledComposable(item2, $composer)
        ...
    }
}

ただの関数に、

  • suspendをつけるだけで、非同期かつ動作をブロックしないという性質
  • @Composable注釈をつけるだけで、再起動可能・スキップ可能・リアクティブな性質

を付与できてしまうのである。

Composable関数の型

文法的な観点からは、Composable関数は

@Composable (T) -> A

という型である。Aは、Unitでもいいし、他のどんな型でもいい。

さらに

@Composable Scope.() -> A

という型のものもある。

これは、BoxScopeColumnScopeのようにある特定の情報を持っているComposable関数を作りたいときに活用できる。例えば以下のように。

@Composable
fun ScopeSpecificComposable(
    content: @Composable ColumnScope.() -> Unit,
    modifier: Modifier = Modifier
) {
    Column(modifier) {
        content()
    }
}

型は、コンパイラに迅速なデータ検証をさせ、便利なコードを生成させ、ランタイムでのデータの使用を洗練させるのに役に立つ。@Composable注釈をつけることで、Composable関数は、ランタイムでの検証のされ方や使われ方が変わる点で、通常の関数とは異なる型をもつと言える。

1章のまとめ

  • Composable関数は、ノードツリーのメモリ内表現を更新するのに特化した関数。
  • Composable関数は、コンパイルされるとComposerという型の暗黙の引数を持つ関数に変換される。
  • Suspend関数は、コンパイルされるとContinuationというコールバック関数の一種を引数にとる手続き型関数に変換される。
  • Composerを親から子へと伝播させることで、ツリーの状態を全てのComposable関数が把握することが可能になる。
  • 何度も実行されるため、同じ引数には同じ結果を返す冪等性が求められ、副作用がある処理は別(エフェクトハンドラー)で行う。
  • キャッシュをうまく活用する「メモ化」を使って、無駄の少ない再実行をしている。

つづく...

何か間違って理解している箇所などございましたら、お手柔らかにご指摘いただけますと幸いです。

Discussion