15
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

SwiftWednesdayAdvent Calendar 2023

Day 22

SwiftUIとJetpack Composeを比較してみよう

Last updated at Posted at 2023-12-23

こちらはSwiftWednesday Advent Calendarの22日目になります。

こんにちは。Androidエンジニアのclockvoidです。

近年、宣言的UIという言葉とともにモバイルアプリ開発文脈ではAppleがSwiftUI、GoogleがJetpack Composeを出して話題になっています。
この2つのUIフレームワークはどちらも宣言的UIにカテゴラズされており、非常に似通った性質を持っているものであると考えられていることが多いと思います。
そこで、本記事ではそれぞれのフレームワークのAPIの違いを中心に、双方の比較を行います。
この記事を読むことで、普段触っていないフレームワークを学習する足がかかりとなれば幸いです。

なお、筆者はAndroidエンジニアであるため、SwiftUIに関しては調べながら書いた記述も多く含まれます。間違いがある可能性もありますので、間違いを発見されましたら、コメント等で優しく教えていただけますと嬉しいです。

Viewのつくりかた

まずは基本的なViewの作り方を見ていきます。

Hello, world

とりあえず、画面の中央に「Hello, world」という文字列を出力させてみようと思います。

そのようなレイアウトは、

SwiftUIでは、

struct Content: View {
    var body: some View {
        Text("Hello, world")
    }
}

Jetpack Composeでは、

@Composable
fun Content() {
    Box(
        contentAlignment = Alignment.Center,
        modifier = Modifier.fillMaxSize()
    ) {
        Text(text = "Hello, world")
    }
}

のように書きます。
この時点で2つを見比べてもすでに大きな違いがあることがわかります。

SwiftUIではViewプロトコルに準拠したstructを実装することでViewを構成しています。Viewプロトコルはbodyというcomputed propertyの実装を要求しています。bodyは実行されるとViewプロトコルを実装した型のインスタンスを作ります。computed propertyを再帰的に計算することで、最終的にView treeを作ることができるという構造になっているようです。このbodyはユーザからの入力やシステムイベントに応答して何度も計算される可能性があるため、bodyの計算コストについては常に意識して実装を行う必要があります。

一方で、Jetpack Composeでは、Viewは@Composableというアノテーションを付けた関数(以下、Composable関数と呼びます)によって構成されています。Compose runtimeはComposable関数を実行することでView treeを構築します。一見するとSwiftUIのbodyプロパティと同じものように見えますが、Composable関数は返り値がUnitです。Jetpack Composeでは、Compose compilerというKotlinのCompiler pluginがComposable関数をもとに、View treeを生成するための関数を自動生成するため、返り値がUnitでもUIを構築することができるようになっています。ただ、bodyプロパティと同様に、UIの再構成時に何度も呼ばれるため、計算コストは常に意識して実装を行う必要があります。

Modifier

ここで、Hello, worldにボーダーを付けてみることにします。
例えば今回は赤色のボーダーを付けてみましょう。

そのようなレイアウトは、

SwiftUIでは、

struct Content: View {
    var body: some View {
        Text("Hello, world")
            .border(Color.red)
    }
}

Jetpack Composeでは、

@Composable
fun Content() {
    Box(
        contentAlignment = Alignment.Center,
        modifier = Modifier.fillMaxSize()
    ) {
        Text(
            text = "Hello, world",
            modifier = Modifier.border(
                width = 1.dp,
                brush = SolidColor(value = Color.Red),
                shape = RectangleShape
            )
        )
    }
}

のように書きます。

SwiftUIの場合、ViewはViewプロトコルに準拠したstructでした。ViewプロトコルではViewの見た目を調整することができるmodifierを提供しています。modifierの返り値はそのmodifierを適用したあとのViewのため、メソッドチェーンでmodifierを繋げて書くことができます。modifierをメソッドチェーンでつなげる特性上、順番には気をつける必要があります。例えば、Textの真下に.opacity(0.2)をつけると、Hello, worldという文字列のみが透過されるのに対して、.borderの下に.opacityをつけるとボーダーも透過されます。

一方で、Jetpack Composeの場合、Viewは返り値のない関数で構築されていました。返り値が存在しないため、当然メソッドチェーンで見た目の調整を行うことができません。そこで、Jetpack Composeではmodifierを関数の引数として渡すようになっています。Jetpack ComposeのModifierはメソッドチェーンでModifierインターフェースのインスタンスを生成するようにできています。こちらもメソッドチェーンでmodifierを作る関係上、modifierを適用する順番には気をつける必要があります。

レイアウトシステム

さて、Hello, worldの時点でお気づきになった方もいらっしゃると思いますが、Jetpack ComposeのHello, worldにはSwiftUIのものには存在しない余分な要素があることがわかります。
あらためて、両者のコードを掲載してみます。

SwiftUI:

struct Content: View {
    var body: some View {
        Text("Hello, world")
    }
}

Jetpack Compose:

@Composable
fun Content() {
    Box(
        contentAlignment = Alignment.Center,
        modifier = Modifier.fillMaxSize()
    ) {
        Text(text = "Hello, world")
    }
}

Jetpack ComposeのほうがBoxというコンポーネントと、その引数であるcontentAlignmentmodifierが余計にかかっています。
Hello, worldの要件を思い出すと、画面の真ん中にHello, worldを出したい、という要件だったかと思います。Androidエンジニアの私としては面白いのですが、SwiftUIのVStackHStackZStackはデフォルトのalignmentがセンター揃えになっています。一方で、Jetpack ComposeではColumnRowBoxのデフォルトのalignmentは左上になっています。そのため、Jetpack Composeでは画面の真ん中という要件を満たすために、余計にalignmentの設定が必要になったというからくりです。

ここで、VStackHStackZStackという要素が出てきましたが、これらは子Viewをある並べ方で並べるような機能を持ったViewです。こういったViewはレイアウトコンテナと呼ばれています。
Jetpack Composeにも同じようなComposable関数が用意されており、VStackに該当するものがColumnHStackに該当するものがRowZStackに該当するものがBoxです。これら以外にも、標準のレイアウトコンテナは用意されていますが、ここでは割愛します。
SwiftUIやJetpack Composeを使ったことがある方ならわかるかと思いますが、多くのレイアウトは標準で提供されているレイアウトコンテナの組み合わせで実現できます。細かいレイアウトコンテナの使い方の違いや、Viewのinitializer(SwiftUIならstructのinit()、Jetpack ComposeならComposable関数です)の使い勝手の違いはあるため、都度ドキュメントは参照する必要がありそうですが、メンタルモデルは非常に似ているといっていいと思います。

あまりないことではありますが、用意されたレイアウトコンテナの組み合わせだけでは表現しきれないようなレイアウトを作りたい場合はどのようにしたらいいでしょうか。Jetpack ComposeではConstraintLayoutを使うという手法もありますが、それでも思ったような表現ができない場合は、自分でレイアウトを作るのがいいでしょう。SwiftUIとJetpack Composeはともにカスタムレイアウトを作る方法を提供しています。ここで、カスタムレイアウトの作り方を見ていきます。

SwiftUIでは、Layoutプロトコルに準拠したstructを作ります。具体的には以下のようにします。

struct BasicLayout : Layout {
    // このレイアウトコンテナの大きさを計算する関数
    func sizeThatFits(
        proposal: ProposedViewSize,
        subviews: Subviews,
        cache: inout ()
    ) -> CGSize {
    }
    
    // subviewをどのように配置するか決める関数
    func placeSubviews(
        in bounds: CGRect,
        proposal: ProposedViewSize,
        subviews: Subviews,
        cache: inout ()
    ) {
    }
}

このようなstructは、デフォルトで提供されているレイアウトコンテナと同じような使い方ができます。すなわち、

BasicLayout {
    Text("Hello")
    Text("world")
}

のような具合です。
おそらく一番難しいのはsizeThatFitsplaceSubviewsのロジックを書く部分ですが、ここでは詳しくは説明しません。

一方で、Jetpack Composeでは、LayoutというComposable関数を使います。具体的には以下のようにします。

@Composable
fun BasicLayout(modifier: Modifier, content: @Composable () -> Unit) {
    Layout(
        content = content,
        measurePolicy = MeasurePolicy { measurables, constraints ->
            // ここで子Viewの大きさから自分自身の大きさを計算します

            layout(width, height) {
                // ここで実際に子Viewを配置します
            }
        },
        modifier = modifier
    )
}

SwiftUIと同じように、子Viewの大きさから自分自身の大きさを計算する部分と、実際に子Viewを配置するコードを書くような構造になっています。
このあたりはわりとノウハウが共有できるかもしれません。

リスト

さて、ここまでで基本的なUIは構築できるようになりました。しかし、スマホアプリのよくあるユースケースの一つである、リストの実現方法についての説明が不足しています。
リストを実現するためにはパフォーマンス観点で、画面に表示さているぶんだけViewのインスタンスをView treeに参加させるような仕組みが必要です。iOSのUIKitではUICollectionView、AndroidのView systemではRecyclerViewが実装していたような機能ですね。

まずはそういった機能を使わずに、今まで紹介していた、VStackColumnを使って固定長のリストを表示するようなコードを書いてみましょう。

まず、SwiftUIでは、

struct Content: View {
    var body: some View {
        ScrollView {
            VStack {
                ForEach(
                    1...100,
                    id: \.self
                ) {
                    Text("hello\($0)")
                }
            }
        }
    }
}

Jetpack Composeでは、

@Composable
fun Content() {
    Column(
        modifier = Modifier
            .fillMaxSize()
            .verticalScroll(rememberScrollState()),
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        (1..100).forEach {
            Text("hello $it")
        }
    }
}

のように書きます。
非常に似通っていますが、SwiftUIではスクロール可能にするためにVStackScrollViewで囲んでいますが、Jetpack ComposeではverticalScrollというModifierをつけることで、そのView自体がスクロール可能になるというように、スクロール可能にするためのAPIが少し違っています。前節でも少し話題に出しましたが、このようなちょっとしたAPIの違いが出てくるため、メンタルモデルは似通っていますが実際に作るとなるとドキュメントを読みながら進める必要がありそうです。

表示する要素が少なければこれでも問題ないですが、ここで書いた実装ではfor文で生成したViewが常にすべてView treeに存在する状態になります。すべてのViewをView treeに乗せてしまうことの問題点は、不要なView要素が常にメモリに乗ってしまうことによるメモリ効率の悪さもそうですが、特にJetpack Composeでは、表示すべき要素が多くなるとfirst frameを生成するのに時間がかかってしまうのも気になるところです。(SwiftUIでは、表示するViewの数を増やして実行してみても特にfirst flameのレンダリングのパフォーマンス低下は体感できませんでした。理由はよくわかりません。)

そこで、画面に表示されているものだけをView treeに入れるようにしてみましょう。

SwiftUIでは、

struct Content: View {
    var body: some View {
        ScrollView {
            LazyVStack {
                ForEach(
                    1...100,
                    id: \.self
                ) {
                    Text("hello\($0)")
                }
            }
        }
    }
}

Jetpack Composeでは、

@Composable
fun Content() {
    LazyColumn(
        modifier = Modifier.fillMaxSize(),
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        items((1..100).toList()) {
            Text("hello $it")
        }
    }
}

のように書きます。
SwiftUIでは元のコードのVStackLazyVStackに変えただけです。
Jetpack Composeでは、ColumnLazyColumnに変えて、ModifierのverticalScrollを消しています。LazyColumnは自分自身の大きさが、リストの要素全体よりも小さかった場合は必ずスクロールするようなコンポーネントになっています。また、Jetpack ComposeではColumnのコンテンツは普通のComposable関数で行っていましたが、リストの中身はComposable関数ではない、リストのアイテムを定義するための関数を記述します。これらの関数には、一つだけの要素を定義するためのitem()や、リストを入力して複数のViewを生成するitems()といった関数があります。

基本的にはSwiftUIもJetpack Composeもここで上げたlazy系のレイアウトコンテナを使うことで常に必要最低限の要素をメモリに確保するようなリストを作ることができます。
しかし、SwiftUIにはListという特別なViewが用意されています。例えば、次のように使います。

struct Content: View {
    var body: some View {
        List {
            ForEach(
                1...100,
                id: \.self
            ) {
                Text("hello\($0)")
            }
        }
    }
}

先程のLazyVStackの例とは違い、ScrollViewが不要になっています。
このViewを表示してみると、アイテムにAppleの設定画面のようなデザインが適用されたViewが表示されます。このListは、見た目以外にも、要素を選択する挙動やグルーピング、Navigationとの統合といった機能も実装されており、LazyVStackよりも日常使いが意識されたViewになっていそうです。

差分更新システム

さて、ここまでで基本的な使い方の比較はできたと思いますが、ここで気になってくるのは差分更新のメカニズムがどのように違うかというポイントだと思います。
宣言的UIフレームワークが従来の「命令型」UIフレームワークと大きく違っているところは、Viewオブジェクトに直接状態とイベントハンドラを設定するのではなく、ある程度の塊に対して状態を入力するとフレームワークが自動的にデータをバインドしてUIを構築するというところにあると思います。
フレームワークが自動的にデータをバインドするということは、状態の更新によって発生する再描画も、フレームワークが自動的に計算して行うということです。

SwiftUIとJetpack Composeはともに、こういった差分更新のメカニズムを備えていますが、それぞれどのような違いがあるか、見ていきます

SwiftUIの差分更新システム

SwiftUIの差分更新システムは以下の動画で説明されています。
https://developer.apple.com/videos/play/wwdc2021/10022/

SwiftUIではViewをstructで表現していました。Classで作られるわけではないため、インスタンスの同一性判定を値比較で行うことしかできません。しかし、Viewが持っている値そのものが同じであっても、同一のViewであるとは限りません。こういった問題を解決するために、SwiftUIではstructで作られたView一つ一つにIdentityという識別子を付与しています。
IdentityはView.id(_:)というメソッドを使って自分でつけることもできますし、何もしなければSwiftUIがViewツリーの構造を見て自動的に付与します。前者のようなIdentityことをExplicit identity、後者のようなIdentityのことをStractual identityと呼びます。

SwiftUIのViewはstructのため、以下のように、値を取ることも可能です。

struct MyText: View {
    var text: String
    
    var body: some View {
        Text(text)
    }
}

このとき、View treeの構造が変わったり、意図的に別のIdentityをつけたりといったことをしない限り、MyTextに割り振られるIdentityは同一です。しかし、MyText型の値は新しく作り直されます。つまり、Viewの値とIdentityはライフタイムが違います。
では、ただの値ではなく、@Stateを入れたらどうなるでしょうか。

struct MyText: View {
    @State var text: String
    
    var body: some View {
        Text(text)
    }
}

この場合、MyTextが作り直されたとしても、Identityが同じならtextの値も保持されるという挙動をします。すなわち、StateのライフタイムはIdentityと同じになるということです。
このStateは以下のようにして子Viewにバインドして変更検知させることができます。

struct Counter: View {
    @State var count: Int
    
    var body: some View {
        CountMonitor(count: $count)
        Button(action: {count += 1}, label: {
            Text("count up")
        })
    }
}

struct CountMonitor: View {
    @Binding var count: Int
    
    var body: some View {
        Text("count is \(count)")
    }
}

@StateはCombine等のストリームからつなげることもできます。このあたりの詳細については、ObservableObjectやSwift 5.9から導入された、Observationに付いて調べてみてください。

Jetpack Composeにおける差分更新

まずJetpack Composeでの差分更新を考えるためには、Jetpack ComposeがViewを構築する流れを理解する必要があります。
Jetpack Composeは、以下の3フェーズでViewを構築します。

  1. Composition
  2. Layout
  3. Drawing

まず、CompositionというフェーズでComposable関数を実行し、View treeを構築します。このとき、Compose compilerはCompositionというオブジェクトにView treeの情報などを構築します。フェーズの名前と構築するオブジェクトの名前が一緒で分かりづらいですね。

Compositionができたら、その情報をもとにLayoutフェーズで実際にUIを配置し、最後にDrawingフェーズでCanvasにUIを描画します。実はLayoutフェーズ以降のみ、Drawingフェーズのみを実行することも可能です。つまり、Compositionフェーズから再描画をすべて実行すると、一番大きなコストが掛かるということです。

Composition内では、各Composable関数にSwiftUIで言うところのStractual identityのような情報が付与されます。Stractual identityではなく、自分で識別子を付与したい場合にはkey()というComposable関数を使用します。key()はComposable関数のため、Modifierで適用するのではなく、以下のようにして識別子を付けたいComposable関数を囲みます。

data class Item(val id: Int, val title: String)

@Composable
fun Content(items: List<Item>) {
    Column(
        modifier = Modifier
            .fillMaxSize()
            .verticalScroll(rememberScrollState())
    ) {
        items.forEach {
            key(it.id) {
                Text("Hello ${it.title}")
            }
        }
    }
}

このようにすることで、itemsのどこかのアイテムのタイトルが変わったとしても、別のアイテムの無駄なCompositionを抑制することができます。

SwiftUIでは@Stateをつけた変数を@Bindingでバインドすることでデータの変更を通知していましたが、Jetpack Composeではまた違った状態管理と変更通知のメカニズムが導入されています。
まず、Jetpack Composeでは構成要素が関数なのにどのようにして値の保持を行うのか、という疑問が生まれるかと思います。
値の保存には、remember()というComposable関数を使用します。Jetpack Composeでは先程説明した通り、Compositionと呼ばれているオブジェクトでComposable関数の実行結果を保持しています。remember()は値を生成するためのラムダ関数を受け取り、そのラムダ関数の実行結果をCompositionに保存します。remember()関数はまた、keyを引数に取ることもでき、前のCompositionの実行からkeyの値が変わっていた場合、値の生成用のラムダ関数を再実行し、新しい値をCompositionオブジェクトに保存します。
値の更新通知には、Snapshotというシステムが使われています。SnapshotはComposeのライブラリに内包されて提供されていますが、事実上Composeじゃない環境でも使えるように作られています。
Snapshotシステムが保持している値の更新を、Compose runtimeが自動的に監視することで、利用者が明示的にデータバインディングをすることなく自動的にデータバインディングが行われる仕組みになっています。
具体的には、以下のように使います。

@Composable
fun Counter() {
    var count by remember { mutableStateOf(0) }

    Column {
        CountMonitor(count = count)
        Button(onClick = { count++ }) {
            Text(text = "count up")
        }
    }
}

@Composable
fun CountMonitor(count: Int) {
    Text(text = "count is $count")
}

remember()関数の引数のラムダ式で実行している、mutableStateOf()がSnapshotシステムのStateオブジェクトを生成している部分です。
データをバインドするときにはComposable関数にただ引数として入れるだけで自動的にCompose runtimeが変更を検知し、再度実行すべきComposable関数を決定します。

SnapshotシステムもSwiftUIの@Stateと同様、RxJavaやCoroutines FlowといったStreamから変換することも可能です。詳しくはAndroid developersの記事を参照ください。

まとめ

最後の方の状態更新の説明はだいぶ駆け足になってしまいましたが、SwiftUIかJetpack Composeのどちらかを触った人がもう片方のフレームワークに入門するときに役に立つ記事ができたのではないか思います。

総じて言えることは、SwiftUIはstruct、Jetpack Composeは関数を使用しているため、使い方やView treeの作り方といった仕組みの部分は大きく違うものの、結局は入力が変わったら関数が実行されてフレームワーク側がViewの同一性判定や、View treeの再生成まで面倒を見て最適化しているという部分は共通していると思います。classコンポーネントの場合、入力が変わってもViewを構成するためのオブジェクトは再生性されないようなフレームワークもあると思いますが、structの場合は入力が変わったら値は再生性されてしまうため、関数コンポーネントとだいたい同じなんじゃないかという感想を持ちました。

UIアーキテクチャについても、色々考えているところがあるため、今後どこかの機会で発表できれば幸いです。

最後まで読んでいただきありがとうございました。

15
7
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
15
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?