Zenn
Open6

SwiftUIとJetpack Composeで実現する最小再描画のUI構築

yunayuna

GPT o1 proの制作ですが、あまりに有用なのでそのままメモします。

私はwebフロントエンドでsolidjsの経験があり、このプロダクトの描画時に最低限の更新しか行わない機構に感動しています。swiftuiを使ったiOSアプリ開発と、jetpack composeとkotlinを使ったandroid開発においても、solidjsの考えを導入、前提に、極力再描画が発生する箇所を小さく、最低限にしたいと思うのですが、これを行うにあたり、それぞれの開発で便利なモジュールや、開発ベストプラクティスなどについて、リサーチと詳しいコード付きの解説をお願いします。

SolidJSのファイングレインなリアクティビティ(微粒度の再活性化)の思想を活かし、SwiftUI(iOS)とJetpack Compose(Android)でも極力再描画範囲を限定した効率的なUIを構築するためのベストプラクティスを解説します。ToDoアプリ規模(画像描画あり)を念頭に、状態管理からパフォーマンスチューニング、アニメーション、コンポーネント分割によるアーキテクチャまで、各プラットフォームの特徴を比較しつつ詳細に説明します。

yunayuna

状態管理 – 最小限の範囲で状態を保持

SwiftUIにおける状態管理:
SwiftUIでは、ビューの状態を管理するためにプロパティラッパー(@State@ObservedObjectなど)を適切に使い分けることが重要です。ローカルな状態@Stateで保持し、そのビュー内だけで利用します。@Stateはビュー固有の値に使い、変更時にそのビューのbodyを再評価します。逆に他のビューと共有する必要があるデータは、親ビューで@Stateとして持ち子ビューに@Bindingで渡すか、@ObservedObject(ObservableObjectに準拠したクラス)や@EnvironmentObjectを利用します。例えば子ビューと親ビューでトグル状態を共有する場合、親で@Stateを宣言し子に@Bindingで渡すのが理想です。

共有データをグローバルに管理したい場合は、@EnvironmentObjectが便利です。これは環境に注入されたObservableObjectへの参照で、どの子ビューからもアクセスできます。例えばグローバル設定をEnvironmentObjectとして持ち、複数ビューで参照することで、あるビューでの変更が他のビューにも反映されます。以下にテーマカラーを環境経由で共有する例を示します。

class GlobalSettings: ObservableObject {
    @Published var themeColor: Color = .blue
}
struct ContentView: View {
    @EnvironmentObject var settings: GlobalSettings
    var body: some View {
        VStack {
            Text("Theme Color").foregroundColor(settings.themeColor)
            ThemeSettingView() // 子ビュー側でもEnvironmentObjectを参照
        }
    }
}
struct ThemeSettingView: View {
    @EnvironmentObject var settings: GlobalSettings
    var body: some View {
        Button("Change Theme") {
            settings.themeColor = .green  // ボタン押下でテーマカラー変更
        }
    }
}

このようにEnvironmentオブジェクトを使うと、状態をグローバルに共有しつつ必要最小限のビューだけ更新できます。また、Observablesの粒度にも注意しましょう。ObservableObjectを大きな単位(アプリ全体の状態など)でまとめすぎると、その一部が変わるだけで多くのビューが更新されてしまいます​
fivestars.blog

SwiftUIでは状態を小さな単位に分割し、必要なビューだけが購読するように設計することがポイントです。例えば、アプリ全体の状態クラスを作るのではなく、タブ選択状態、ユーザ設定状態など機能ごとに別々のObservableObjectに分けます​
fivestars.blog

各小さなObservableObjectを環境に注入し、関係するビューだけ@ObservedObject@EnvironmentObjectで監視させることで、不要な再描画を防げます​
fivestars.blog

fivestars.blog

さらにデータモデルをEquatable/Hashableに準拠させるのも有効です。構造体などのデータがEquatableだと、SwiftUIは前回の値と比較して変化があった場合のみビューを更新できます。例えば以下のようにモデルをEquatableにすると、SwiftUIは効率的に変更を検知し、影響のあるビューだけ再描画します。

struct Task: Identifiable, Equatable {
    let id: UUID
    var title: String
    var done: Bool
    // Equatable 準拠により==自動合成(全プロパティ比較)
}

Jetpack Composeにおける状態管理:
Jetpack Composeでも「単一情報源(Single Source of Truth)」と状態リフト(State Hoisting)の原則があります。Composable関数内だけで完結する状態は、remember { mutableStateOf(...) }でローカルに保持します(これはSwiftUIの@Stateに相当します)。例えば、以下のようにComposeでカウンターの状態をrememberで保持し、内部でのみ使えば、そのComposable内だけが再コンポーズ対象になります。

@Composable
fun Counter() {
    var count by remember { mutableStateOf(0) }
    Column {
        Text("Count: $count")
        Button(onClick = { count++ }) {
            Text("Increment")
        }
    }
}

共有が必要な状態は状態リフト(State Hoisting)を行います。具体的には、状態と変更用の関数を呼び出し元(親Composable)に持たせ、子Composableにはプロパティ経由で値とイベントコールバックを渡します。こうすることで状態の「単一の情報源」が親側に保たれ、複数Composable間でのデータ不整合を防ぎます。大規模なComposeアプリでは、画面ごとにViewModelを用意し、MutableStateFlowLiveDataで状態を公開し、collectAsState()でCompose側に渡すアーキテクチャ(MVVM + UDF)が一般的です。この際も状態を一つの巨大なデータクラスにしすぎないことがポイントです。必要に応じて状態を複数のStateFlowに分割したり、Compose側で細かいステートに分解して購読することで、一部の変更が画面全体の再コンポジションを引き起こすのを防げます。例えばTodoリストであれば、リスト全体の状態(フィルタや並び順)と、各Todoアイテムの状態(完了フラグなど)を分け、それぞれ独立して監視・更新すると効率的です。

両プラットフォーム共通して、**「状態はできるだけ局所化し、共有が必要な場合のみ上位に持ち上げる」**という原則が再描画範囲の最小化につながります。SwiftUIではBindingや複数のObservableObject、ComposeではrememberやState Hoistingでこれを実現します。

yunayuna

パフォーマンスチューニング – 無駄な再計算・再描画を減らす

SwiftUIのパフォーマンス最適化:
SwiftUIでは、ビューのbody再評価コストを下げる工夫が重要です。前述のようにデータモデルをEquatableにしたり、ビュー自体をEquatableにすることで、変更検知と差分レンダリングを効率化できます。SwiftUIにはEquatableViewやビュー修飾子の.equatable()も用意されており、これを利用するとビューの差分判定ロジックをカスタマイズできます。例えば、重い計算結果を表示するビューをEquatableにしておけば、入力値が変わらない限り以前の結果を再利用し、新たな描画処理をスキップできます。

また、高コストな処理はできるだけ回避または遅延しましょう。ビューの描画時に重い計算やデータソート・フィルタリングを行うと、状態変化のたびに実行されパフォーマンスを損ねます。SwiftUIではonAppeartaskモディファイア、DispatchQueueを使って非同期に処理をしたり、結果を@Stateにキャッシュする手があります。例えば大量の画像を読み込む場合、AsyncImageを使えば非同期ロード+キャッシュが可能です。不要な再計算を減らすため、メモ化の発想も活用できます。Combineフレームワークを使ってPublisherから結果を得る際は.removeDuplicates()で同じ値での通知を省略したり、SwiftUIの.onChangeモディファイアで特定の値変化時にだけ処理を行うといった工夫が有効です。以下はonChangeの活用例です:カウンター値が変わったときだけ偶数/奇数のラベルを更新しています。この場合、カウンター表示自体のテキストは通常の@Stateで更新しますが、偶奇判定はonChangeで行うことで、副作用的な計算をUI更新から分離できます。

@State private var counter = 0
@State private var evenOddText = ""
VStack {
    Text("Counter: \(counter)")
    Button("Increment") {
        counter += 1
    }
    Text("Even or Odd: \(evenOddText)")
}
.onChange(of: counter) { newValue in
    evenOddText = newValue.isMultiple(of: 2) ? "Even" : "Odd"
}

上記ではカウンター増加に伴いcounterが変化すると、evenOddTextだけが更新されます。onChange内部で状態を書き換えても、それ自体はUIを直接再描画しないため、必要最低限の箇所だけが変化します。このように状態変化に応じた処理を明示的に切り離すことで、不要な再レンダリングを減らせます。

Jetpack Composeのパフォーマンス最適化:
Composeも不要な再コンポジション(Recomposition)を避ける工夫が多数あります。まず、状態のスコープを限定することはSwiftUIと同様です。状態読取りは「必要な場所でだけ行う」ことが原則で、親Composableで状態を取得してそのまま深い子Composableに渡すよりも、子の直前で取得した方が無駄な伝搬が減ります。例えば以下のように、親Composableが複数の子に異なる状態を渡す場合、それぞれを別々にrememberderivedStateOfで取り出し、子Composableを分割しておけば、片方の状態変化で無関係な子Composableが再実行されるのを防げます。

@Composable
fun TwoCounters() {
    var countA by remember { mutableStateOf(0) }
    var countB by remember { mutableStateOf(0) }
    // 子Composableごとに個別の状態を渡す
    CounterDisplay(count = countA, onInc = { countA++ })
    CounterDisplay(count = countB, onInc = { countB++ })
}

上記のようにCompose関数を小さく分割し、それぞれが独立した状態を持つようにすると、片方のカウントが変わって再コンポーズしても、もう片方の表示はスキップされます(前回と入力パラメータが変わらないためComposeが再評価を省略します)。この「スキップ」機構により、Composeは必要な部分だけを更新する最適化を内部で行っています。

さらに、Compose専用のAPIで再描画コストを抑えることも可能です。代表的なものを以下にまとめます。

  • rememberの活用: 計算コストの高い処理やオブジェクト生成はrememberブロックで結果を保持し、不要な再計算を避けます。例えばリストをソートして表示する場合、毎回Compose関数内でlist.sortedByすると再コンポーズの度にソートが走りますが、一度rememberにキャッシュすれば入力(元リストや比較方法)が変わらない限りソート処理をスキップできます。Google公式ドキュメントでも「高コスト計算はrememberを使って最小限に抑える」ことが推奨されています​

    developer.android.com

  • derivedStateOfの利用: 他の状態から計算できる派生状態はderivedStateOfでラップし、依存元が変わった時だけ再計算します。例えばリストの合計やフィルタ結果表示など、入力が変わらない限り更新不要なものはval derived = remember { derivedStateOf { ... } }とすることで、無駄な再コンポーズを避けられます。

  • リスト描画の最適化: LazyColumnなどのレイジーレイアウトを使う場合、各項目に安定したキーを与えましょう。LazyColumn { items(items, key = { it.id }) { ... } }のようにキー指定することで、要素の並び替えや増減時に、Composeは項目の対応付けを維持し、不変のアイテムまで再コンポーズするのを避けられます。またリスト要素のコレクションにはmutableStateListOfSnapshotStateListを使うと、要素追加・変更時にComposeがコレクションの差分を検知し、必要な部分だけ更新します。「Snapshot」状態のリストやマップ(mutableStateMapOf)は、通常のリストを丸ごと差し替えるより効率的で、要素単位の変更に強い仕組みです。

  • 再コンポーズのトレードオフ把握: Composeでは状態変化ごとに該当Composableが再実行されますが、先述のとおり不要な部分はスキップされます。Android StudioのLayout Inspectorには「Recomposition Counts」の表示機能があり、どのComposableが何回再コンポーズされたか可視化できます。パフォーマンスチューニング時にはこれを活用し、ボトルネックとなっている箇所(異常に再コンポーズ回数が多い、スキップされていない部分)を探すのがおすすめです。また、Composeのコード中でSnapshot.autoDisposeなど高度なAPIを使うと手動で制御もできますが、通常は上述のベストプラクティスで十分です。

両プラットフォームに共通する原則として、**「状態変更によるUI再評価を必要最小限の範囲にとどめる」**ことが肝心です。SwiftUIではEquatableな比較やonChangeの活用、ComposeではrememberderivedStateOf、安定キーの指定など、多彩な手段で不要な処理を避けられます。

yunayuna

アニメーション – 局所的かつスムーズに状態変化を表現

SwiftUIのアニメーション:
SwiftUIは宣言的UIらしく、状態変化に基づくアニメーションを簡潔に記述できます。withAnimationブロックやビューに対する.animationモディファイアを使うことで、状態の変更にアニメーション効果を付与できます。アニメーション実装時も**「できるだけ狭い範囲に効果を限定する」**ことを念頭に置きます。例えば大きなコンテナ全体の表示非表示をアニメーションするよりも、内部の小さなパーツ(ボタンやアイコン)の変化だけをアニメーションする方が、不要な再描画が減り滑らかになります。SwiftUIではUI要素ごとに異なるアニメーションを適用できるため、必要な部分だけ.animation.transitionを指定しましょう。

以下は、タップで色とサイズが変わるシンプルなSwiftUIアニメーション例です。

struct AnimatedBox: View {
    @State private var toggled = false
    var body: some View {
        RoundedRectangle(cornerRadius: 10)
            .fill(toggled ? .blue : .gray)
            .frame(width: 100, height: 100)
            .scaleEffect(toggled ? 1.2 : 1.0)
            // 状態変化にアニメーションを適用
            .animation(.easeInOut(duration: 0.3), value: toggled)
            .onTapGesture { toggled.toggle() }
    }
}

@Stateの変化に対して.animation(..., value: toggled)を指定することで、このビューの色やサイズの変更が0.3秒のイージングアニメーションで自動的に補間されます。ポイントは、アニメーションさせるビューをできるだけ単機能にすることです。この例では色付きの四角形だけがアニメーション対象であり、他の親ビューには影響しません。SwiftUIでは.transition(.slide).opacityを組み合わせて要素の挿入削除をアニメーションさせることもできますが、これも対象ビュー(例えばリストの行要素)単位で行うことで、UI全体の再描画を伴わずスムーズになります。

アニメーションによるパフォーマンス低下が気になる場合、withAnimationを使って状態変更箇所をまとめる、あるいはTransactionを調整して複数の変更を一度に描画させるテクニックもあります。例えばリストの並び替えで大量のビューが再描画される際も、アニメーションdurationを短くしたり変更を分割することでフレーム落ちを防止できます。さらに、Metalレンダリングを活用するdrawingGroup()で負荷をGPUに逃がす、高FPSが求められる部分はあえてUIKitやCoreAnimationを併用する、といった選択肢もあります(SwiftUIはUIKitとの共存が可能です)。

Jetpack Composeのアニメーション:
Composeもまた多彩なアニメーションAPIを備えており、状態ベースでUI要素を滑らかに変化させられます。基本はanimate*AsState系の関数(例えばanimateFloatAsStateanimateColorAsState)を使い、状態値をアニメーションで補間した値としてUIに適用します。Composeでもアニメーションはできるだけ小さなComposable単位でかけるのが原則です。大きなレイアウト全体のアニメーション(例えば画面全体のテーマ変更に伴う再構成)より、要素単位(ボタンの色変化、カードの展開/折り畳み等)で行った方が、再コンポーズ範囲が限定されパフォーマンスが安定します。

次はComposeで先ほどのSwiftUIと類似の動きを実現する例です(タップで色とサイズが変化)。

@Composable
fun AnimatedBox() {
    var toggled by remember { mutableStateOf(false) }
    // 状態に応じて補間される値を取得
    val scale by animateFloatAsState(
        targetValue = if (toggled) 1.2f else 1.0f,
        animationSpec = tween(durationMillis = 300, easing = FastOutSlowInEasing)
    )
    val color by animateColorAsState(
        targetValue = if (toggled) Color.Blue else Color.Gray,
        animationSpec = tween(durationMillis = 300)
    )
    Box(modifier = Modifier
            .size(100.dp)
            .graphicsLayer(scaleX = scale, scaleY = scale)
            .background(color)
            .clickable { toggled = !toggled }
    )
}

Composeではフレームごとに再コンポーズが走るわけではなく、上記のようなanimate*AsStateが内部で効率よく値を更新し、必要な箇所だけ描画を更新します。AnimatedVisibilityを使えば要素の表示/非表示自体もアニメーションできますし、低レベルにはCanvasAPIで細かな描画アニメーションを制御することも可能です。重要なのは、アニメーション中に同時発生する他の状態変化に注意することです。例えばアニメーションしている最中にリスト全体を再構成するような状態変更が起きるとカクつきが発生しやすくなります。ComposeではsnapshotFlowLaunchedEffectでアニメーション状態を監視して別処理を同期させる、高頻度更新が必要な箇所はLaunchedEffect内でタイマー制御するといった手段で、アニメーションと他の処理の干渉を減らせます。

両者のアニメーションはいずれもGPUレンダリングを活用しており、シンプルなUI変化であれば滑らかに動作します。共通のベストプラクティスは「広範囲を動かしすぎない」ことです。必要な部分だけを動かし、他は静止させておくことで、結果的に再描画コストも最低限に抑えられます。

yunayuna

コンポーザブルなアーキテクチャ – 小さな部品に分割して管理

UIを部品単位で分割し、それぞれが独立した状態とロジックを持つように設計することで、再描画の影響範囲を狭めることができます。これを支えるアーキテクチャとして、近年はコンポーズ可能なアーキテクチャが注目されています。各プラットフォームで主に使われるパターンと、その中で再描画最小化につながる工夫を見ていきましょう。

SwiftUIの推奨アーキテクチャと部品管理:
SwiftUIではMVVM(Model-View-ViewModel)パターンが広く使われます。ViewModelはObservableObjectとして定義し、Viewはそれを@StateObject(所有者として保持)または@ObservedObject(外部から受け取る)で監視します。このとき、ViewModelを画面ごと(またはコンポーネントごと)に細かく分けることがポイントです。1つの巨大なViewModelに全画面の状態を詰め込むと、その一部が変わる度に画面全体が更新されてしまいます。代わりに、機能単位・UI部品単位にViewModel/ObservableObjectを用意し、それぞれをView階層内の必要な箇所だけで監視するようにします​

fivestars.blog

。例えばToDoアプリなら、リスト全体を管理するViewModelとは別に、各ToDoアイテム用の小さなObservableObject(タイトルや完了フラグを持つ)を用意し、リスト表示ではそれらをForEachで展開する形が考えられます。

具体例としてリスト内の項目ビューの分割を考えます。各タスクを表すモデルTaskObservableObjectクラスにし、@Publishedなtitledoneプロパティを持たせます。リスト(親ビュー)側では@State[Task]の配列(または@ObservedObjectでList用ViewModel)を保持し、ForEachTaskRowビューに各タスクを渡します。TaskRowビューは自分自身で@ObservedObject var task: Taskを持ち、UIを構築します。こうすると、あるタスクのdoneが更新された場合、そのTaskRowだけが再描画され、他の行や親のリストビューには影響しません。SwiftUIは子ビューごとに状態変化を検知して更新できるため、UIコンポーネントを細かく分けるほど無駄な再描画が減ります。

加えて、前述したコンテナ/レンダリングビューの分離も有効です。コンテナビューは状態やビジネスロジックを担い、子としてピュアな描画用ビュー(レンダリングビュー)を呼び出します。レンダリングビューには状態計算やロジックを書かないためEquatableにしやすく、View構造体にそのままEquatable準拠させたり.equatable()修飾子を付けることで、入力(プロパティ)が同じなら再描画しない最適化を適用できます。この手法はPoint-Free社の**Composable Architecture (TCA)**でも取り入れられており、状態とビューを厳密に分離しつつ小さなStore単位でUIを更新するアプローチとして知られています。TCA自体はReduxライクな全体アーキテクチャですが、考え方の本質は「状態を細かく分割し、ビューも小さく構成する」点にあります。小規模なToDoアプリでそこまで厳密にする必要はありませんが、アプリが大きくなっても見通しよく、再描画の無駄が少ない設計として参考になります。

Jetpack Composeの推奨アーキテクチャと部品管理:
Composeでも基本はMVVM(+Repository層)で、UIはできるだけステートレスな関数の集合として書き、状態はViewModelや上位コンポーネントから渡すのが理想です。Compose特有の考え方として、**「すべてのUIは関数である」**という点があります。つまり、大きな画面を構成する関数をさらに小さなComposable関数に分割し、それぞれが必要なパラメータ(状態)だけを受け取るように設計できます。これにより、ある部分の状態が変わって親Composableが再実行されても、他の独立した部分はパラメータが変わらなければ再コンポーズをスキップできます。UIコンポーネントを関数単位で小さく分割するのはComposeでのパフォーマンス最適化にも直結します。

例えばToDoリスト画面では、全体を管理するTodoListScreenコンポーザブルの中に、リスト部分を描画するTodoList、入力フィールド部分を描画するInputFieldなどに分けます。TodoListは内部でLazyColumnitemsを使い各TodoItemViewを描画しますが、items関数にキーを指定しておけば並び替えや追加時にも差分更新されます。各TodoItemViewは引数に個々のToDoデータだけを受け取り、完了チェックのコールバックを提供します。こうしておけば、あるアイテムの完了状態が変わったとき、そのアイテムに対応するComposable関数(TodoItemView)だけが再実行され、他のアイテムはkeyによる一致とパラメータ不変により再コンポーズをスキップします。

Composeでは状態ホスティングと組み合わせて、**一方向データフロー(Unidirectional Data Flow)**を維持することも大切です。イベント(ユーザ操作)はViewModelなどで状態変更に変換され、それが再度UIに反映される流れを作ります。各コンポーネントはイベントのコールバックを上位に渡し、状態は下位に渡す構造にすると、データの流れが明確になり、結果としてバグも減ります。これはSolidJSのリアクティブシグナルにおける「書き換えは一箇所」の考えにも通じます。

最後に、両プラットフォーム共通で言えるアーキテクチャ提案のまとめです。ToDo規模であれば複雑な設計は不要ですが、**「状態を単一の巨大なものにせず、UI部品ごとに責務を分ける」ことが肝心です。SwiftUIならViewごとに@State@ObservedObjectを持ち、必要に応じて環境オブジェクトを小さく分割して使う​**

fivestars.blog

****。ComposeならComposable関数を小さく分け、ViewModelも画面やコンポーネントごとに用意し、状態フローを細かく管理する。こうした設計により、SolidJSのような「微粒なリアクティブUI」すなわち状態変化が極小範囲のUI更新につながる実装が可能になります。

yunayuna

部品設計と再描画最小化のポイント – まとめ

  • 状態のスコープ最小化: 不要に広域な状態にせず、ビュー(またはComposable関数)単位で必要なものだけ状態として持つ。共有が必要な場合のみ上位へ引き上げ、子へはBindingやコールバックで渡す。

  • コンポーネント分割: 大きなUIを小さな部品に分割し、それぞれ独立した入力(プロパティ)だけで動作するようにする。SwiftUIでは小ビューに分けて子ビューは必要データだけObservedObject/Bindingで取得​

    fivestars.blog

    。Composeでは小さなComposable関数を積み重ね、関数間の引数として最小限のデータを渡す。

  • 差分更新の活用: データモデルやビューをEquatableにする(SwiftUI)、リストにはstable IDを付与する(Compose)など、フレームワークの差分アルゴリズムが最大限効くよう設計する。

  • 適切なAPIの利用: SwiftUIのonChange, .transition, .animation、Composeのremember, derivedStateOf, animate*AsState等、用意されたAPIで「この変化に対してここだけ更新/アニメーションしてほしい」という意図をコード上に表現する。

  • アーキテクチャの整備: アプリが大きくなる場合はMVVMやReduxライクなアーキテクチャを採用し、状態管理を一元化しつつ細分化する。例えばPoint-FreeのTCA(SwiftUI)やMVIパターン(Compose)では、サブ状態ごとにサブビューを対応させることで、特定の部分だけ更新する流れを実現している。小規模でも考え方を取り入れて、状態とUI更新の対応関係を明確にしておくと良い。

以上のように、SwiftUIとJetpack Composeそれぞれの特徴的な機能とパターンを活かせば、SolidJSにヒントを得た最小限の再描画を実現できます。細かな状態管理と部品設計により、ユーザー体験の滑らかさとコードの見通しの良さを両立させましょう。

References: 状態管理とパフォーマンス最適化に関する公式ドキュメントや有志記事の知見​

fivestars.blog

を参照しました。各プラットフォームの最新ガイドラインに沿って開発することで、長期的にも保守しやすい高パフォーマンスなUIが構築できるはずです。

ログインするとコメントできます