先週、「Jetpack Compose, React, Flutter, SwiftUIを比較する」という記事で宣言的UIの各ツールの比較を行いました。
その中で、Jetpack Compose特有の特徴としてコンポーネントに返り値がないことを紹介しました。
@Composable
fun Greeting(name: String) {
Text("Hello $name")
}
これは、React等で遵守されてきたコンポーネントは純粋関数として扱うというルールから逸脱したものとなります。
その理由について、Jetpack Composeの開発に携わるJim SprochさんからTwitterで以下のようにリプライを頂きました。SwiftUI
Four reasons: (1) more natural for conditionals and loops, where coalescing return values is troublesome (2) prevent people from reading and passing around VDOM nodes (3) VDOM is terrible for garbage collection (4) more optimizable by Compose Compiler static analysis.
— Jim Sproch (@JimSproch) September 8, 2021
今回はその内容について1つずつ深堀りしていきます。
(1) more natural for conditionals and loops, where coalescing return values is troublesome
これは前回の記事でも少し紹介していました。今回はより踏み込んで、以下のようなコンポーネントを考えます。
これを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では、JSXという記法を用意して上記の問題を緩和しています。
複数要素を並べて表示するのはかなり改善されますが、条件分岐やループは苦手な印象があります。
function List(value) {
return (
<div>
<Header />
{ value.isEmpty() && <Empty /> }
{ value.map((item) => <Item value={item} />) }
</div>
)
}
FlutterではDart 2.7から追加された、Collection ifとCollection forを使うことで、リストを扱いやすくなります。
@override
Widget build(BuildContext context) {
return Column(children: [
Header(),
if (value.isEmpty) Empty(),
for (var i in value) Item(i)
]);
}
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) 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) VDOM is terrible for garbage collection
値を返すということは、何かインスタンスを生成して返すことになります。
JVMで動いている限り、ガーベッジコレクションは避けては通れません。オブジェクトが大量に作られると、そのチェックと削除のコストは増えていくことが想像できます。
Jimさんによると、Reactでもパフォーマンスの最大のネックはガベージコレクションだったようです。
はい、私がReact.jsチームに所属していたとき、ガベージコレクションはパフォーマンスの最大のボトルネックでした。 Composeを設計するときにその問題を回避したかったのです。
— Jim Sproch (@JimSproch) September 8, 2021
Jetpack Composeではオブジェクトを生成することなく、直接Gap Bufferに値を書き込むことでTree構造を表現しているようでした。
Under the hood of Jetpack Compose — part 2 of 2 | by Leland Richardson | Android Developers | Medium
(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()
}
}
もう少し検証する必要があるかもしれませんが、大部分において大きな問題は生じないように思います。
本当ですが、原因と結果は重要です。 デザインが最初に来ました。 ギャップバッファは単なる実装の詳細であり、簡単に変更できます。
— Jim Sproch (@JimSproch) September 8, 2021
スレッドの議論の中で、興味深いコメントだったため、紹介させてください。
先程も紹介したとおり、Jetpack ComposeはUIの差分更新のためにGap Bufferという仕組みを取り入れています。一方で、これはあくまでも実装の詳細であり、Jetpack Composeのデザインはそれよりも先にあったと述べられています。そのため、異なる差分更新の仕組みを導入することも可能と言われています。
確かにJetpack Composeは値を返さないことで、より高度な最適化ができるようになっているように見えますが、それ以前に使いやすさ、書きやすさを重要視しているようでした。
これは全てのソフトウェアの設計に言えますが、公開されているInterfaceは利用しやすい形にし、実装の詳細は隠蔽することにより、詳細を容易に変更できる、強固で耐久性のある設計になります。
Jetpack Composeの設計や思想を読み解くことで、普段の設計にも活かせるのではないかと感じました。
今回はJetpack Compose特有の性質である、各コンポーネントは値を返さないという設計理由について深堀りを行いました。
値を返さないことにより、様々なことがシンプルになり、高度な最適化も可能であることがわかりました。
個人的には、宣言的UIの新たなパラダイムシフトではないかと感じており、今後様々なプラットフォームに影響を与える可能性もありそうです。
今回Twitterで返信を下さったJim Sprochさん、そして議論に参加して下さったからくりさん, つかもとたけしさんにお礼申し上げます。