Blog

Jetpack Composeのコンポーネントはなぜ返り値がないのか

先週、「Jetpack Compose, React, Flutter, SwiftUIを比較する」という記事で宣言的UIの各ツールの比較を行いました。

その中で、Jetpack Compose特有の特徴としてコンポーネントに返り値がないことを紹介しました。

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

これは、React等で遵守されてきたコンポーネントは純粋関数として扱うというルールから逸脱したものとなります。

その理由について、Jetpack Composeの開発に携わるJim SprochさんからTwitterで以下のようにリプライを頂きました。SwiftUI

今回はその内容について1つずつ深堀りしていきます。

1. 複雑な条件文やループで、戻り値の合成がより自然に記述できる

(1) more natural for conditionals and loops, where coalescing return values is troublesome

これは前回の記事でも少し紹介していました。今回はより踏み込んで、以下のようなコンポーネントを考えます。

  • ヘッダとリストを表示する
  • リストが空の場合、ヘッダとEmptyを表示する

これをJetpack Composeで記述するとこのようになります。

@Composable
fun List(value: List<Data>) {
    Column {
        Header()
        if (value.isEmpty()) {
            Empty()
            return@Column
        }
        value.forEach {
            Item(it)
        }
    }
}

では、仮にComposableは返り値にする必要があったとします。仮定ではあるので擬似的なコードですが、以下のような記述になるでしょう。

@Composable
fun List(value: List<Data>) = Column {
    val composables = mutableListOf<Composable>()
    composables.add(Header())
    if (value.isEmpty()) {
        composables.add(Empty())
        return@Column composables
    }
    composables.addAll(value.map { Item(it) })
    composables
}

もう少し良い書き方はあるかもしれませんが、圧倒的に前者の方が書きやすく、読みやすいですね。

Reactの解決方法

Reactでは、JSXという記法を用意して上記の問題を緩和しています。

複数要素を並べて表示するのはかなり改善されますが、条件分岐やループは苦手な印象があります。

function List(value) {
    return (
        <div>
            <Header />
            { value.isEmpty() && <Empty /> }
            { value.map((item) => <Item value={item} />) }
        </div>
    )
}

Flutterの解決方法

FlutterではDart 2.7から追加された、Collection ifCollection forを使うことで、リストを扱いやすくなります。

@override
Widget build(BuildContext context) {
  return Column(children: [
    Header(),
    if (value.isEmpty) Empty(),
    for (var i in value) Item(i)
  ]);
}

SwiftUIの解決方法

SwiftUIはViewBuilder等の仕組みを導入し、Viewの生成を支援します。

var body: some View {
    VStack {
        Header()
        if value.isEmpty {
            Empty()
        }
        ForEach(value) { item in
            Item(item)
        }
    }
}

ViewBuilder の実装の都合上、VStack内に並べられる上限は10までという制約があります。

また、ForEach はSwiftUI用に用意されたstructだったりと、若干Swiftの純粋な記法とは異なっています。


Jetpack Composeの記法は、純粋なKotlinの記法に従い、シンプルに記述できるメリットがありそうです。

2. 仮想DOMを読んだり渡したりすることを防ぐ

(2) prevent people from reading and passing around VDOM nodes

仮想DOMとはReactで使われている技術で、実際にレンダリングするために必要な情報を持っています。

ここではコンポーネントの返り値となるものを指してると考えられます。

その返り値を直接書き換えることはできませんし、読み取ることも基本的に行いません。

const component = <Sample />;
// 何かを書き込んだり変更することはできない
component.bar = "Hello, World!"
// 読み取ることも通常行わない
const foo = component.foo

また、パフォーマンスの都合や複雑なコンポーネントを作成する際、返り値を保持することはありますが、これには一定の複雑さを伴います。

const component = <Sample />;
Cache.push(component)

Jetpack Composeでは、こういった記法を避け、よりシンプルに扱うことができるようになってると思われます。

ちなみに、返り値を保持する代わりに、Composableメソッドを保持することは可能です。

val component: @Composable () -> Unit = {
    Text("Hello, World!")
}

3. 仮想DOMはガーベッジコレクションと相性が悪い

(3) VDOM is terrible for garbage collection

値を返すということは、何かインスタンスを生成して返すことになります。

JVMで動いている限り、ガーベッジコレクションは避けては通れません。オブジェクトが大量に作られると、そのチェックと削除のコストは増えていくことが想像できます。

Jimさんによると、Reactでもパフォーマンスの最大のネックはガベージコレクションだったようです。

Jetpack Composeではオブジェクトを生成することなく、直接Gap Bufferに値を書き込むことでTree構造を表現しているようでした。

Under the hood of Jetpack Compose — part 2 of 2 | by Leland Richardson | Android Developers | Medium

4. Compose Compilerの静的解析による最適化

(4) more optimizable by Compose Compiler static analysis.

ここは多くを語られていないので詳細は不明ですが、Compose Compilerはコンパイル時に多くの最適化を実行しています。

例えば、Recompose(再レンダリング時)にComposableを実行する必要があるかないかを判断できる、必要最低限のキャッシュ機構のコードを生成します。

Jetpack Composeは値を返さないため、各Composableを完全に独立させることになります。そういった背景が、より高度な最適化を可能にしているのかもしれません。

この独立性を活かし、並列に配置されたComposableは並列に実行されたりと、柔軟に動作します。(参考)

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

純粋関数でなくなるデメリット

ツイート内では述べられていませんでしたが、値を返さないことによるデメリットについても考えてみたいと思います。

値を返さず、直接Treeを操作してると捉えられるため、Composable関数は純粋関数ではなく、副作用を持つメソッドとして考えられます。これによって生じる問題はないのでしょうか?

純粋関数のメリットとして、メンテナンスの容易性やテストの容易性等が挙げられると思います。

まずメンテナンスの容易性について考えます。Composableは副作用を持つメソッドと書きましたが、独自の方法で副作用を持たせることは許されません。副作用自体はCompose Compilerにより実装され、実装者は副作用を記述することも、意識することもほとんどありません。そういった背景から、メンテナンス性が損なわれることは考えにくいでしょう。

次にテストの容易性ですが、以下のように入力に対する出力のテストはできなくなります。

val actual = add(1, 2)
assertThat(actual).isEqual(3)

一方で、仮想DOMに対して検証のテストを書くことはほとんどないように思います。Jetpack Composeでも一度レンダリングを行った後にテストを書くようになっています。(参考)

class MyComposeTest {

    @get:Rule
    val composeTestRule = createComposeRule()

    @Test
    fun MyTest() {
        composeTestRule.setContent {
            MyAppTheme {
                MainScreen(uiState = fakeUiState, /*...*/)
            }
        }

        composeTestRule.onNodeWithText("Continue").performClick()

        composeTestRule.onNodeWithText("Welcome").assertIsDisplayed()
    }
}

もう少し検証する必要があるかもしれませんが、大部分において大きな問題は生じないように思います。

デザインが先、実装が後

スレッドの議論の中で、興味深いコメントだったため、紹介させてください。

先程も紹介したとおり、Jetpack ComposeはUIの差分更新のためにGap Bufferという仕組みを取り入れています。一方で、これはあくまでも実装の詳細であり、Jetpack Composeのデザインはそれよりも先にあったと述べられています。そのため、異なる差分更新の仕組みを導入することも可能と言われています。

確かにJetpack Composeは値を返さないことで、より高度な最適化ができるようになっているように見えますが、それ以前に使いやすさ、書きやすさを重要視しているようでした。

これは全てのソフトウェアの設計に言えますが、公開されているInterfaceは利用しやすい形にし、実装の詳細は隠蔽することにより、詳細を容易に変更できる、強固で耐久性のある設計になります。

Jetpack Composeの設計や思想を読み解くことで、普段の設計にも活かせるのではないかと感じました。

まとめ

今回はJetpack Compose特有の性質である、各コンポーネントは値を返さないという設計理由について深堀りを行いました。

値を返さないことにより、様々なことがシンプルになり、高度な最適化も可能であることがわかりました。

個人的には、宣言的UIの新たなパラダイムシフトではないかと感じており、今後様々なプラットフォームに影響を与える可能性もありそうです。

今回Twitterで返信を下さったJim Sprochさん、そして議論に参加して下さったからくりさん, つかもとたけしさんにお礼申し上げます。

人気の記事

kotlin coroutinesのFlow, SharedFlow, StateFlowを整理する

Jetpack ComposeとKotlin Coroutinesを連携させる

Layout Composableを使って複雑なレイアウトを組む【Jetpack Compose】

テスト用Dispatcherの使い分け【Kotlin Coroutines】

Flow.combineの内部実装がすごい話

Jetpack ComposeのRippleエフェクトを深堀り、カスタマイズも