Contents
SwiftUI のデータバインディング基礎
SwiftUI は「状態が変わったら UI が自動で再描画される」ことを前提に設計されています。本セクションでは、ビュー内部のローカル状態から子ビューへの受け渡し、さらにアプリ全体で共有するデータまで、代表的なバインディング手法を体系的に整理します。
@State と @Binding の基本
@State はビューが所有する単一ソースの値を保持し、変更があればそのビューだけが再描画されます。一方 @Binding は親ビューで管理している @State(または他のバインディング)への参照を子ビューに渡すためのラッパーです。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
struct ParentView: View { @State private var count = 0 // ローカル状態 var body: some View { VStack { Text("Count: \(count)") .font(.title2) ChildView(count: $count) // Binding を渡す } .padding() } } struct ChildView: View { @Binding var count: Int // 親の状態を参照 var body: some View { Button("+") { count += 1 } // 直接親の値を書き換える .buttonStyle(.borderedProminent) } } |
@Stateが保持するストレージはビューが破棄されるまで生存します。- 子ビューは
@Binding経由で同じメモリ位置を操作できるため、状態の二重管理が発生しません。
ポイント:ローカルな UI ロジックは
@Stateと@Bindingの組み合わせだけで完結できます。
ObservableObject と @Published の活用
複数のビューや非同期タスクから同時に参照・更新したいデータは、クラスベースの ObservableObject にまとめます。プロパティラッパー @Published が付いた変数が変更されるたびに、objectWillChange が自動で送出され、関連ビューが再描画されます。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
class CounterModel: ObservableObject { @Published var value = 0 // 外部から観測可能な状態 func increment() { value += 1 } // ビジネスロジックはここに集約 } struct CounterView: View { @StateObject private var model = CounterModel() // ライフサイクル管理 var body: some View { VStack { Text("Value: \(model.value)") .font(.title) Button("+") { model.increment() } .buttonStyle(.borderedProminent) } .padding() } } |
@StateObjectはビューが初めて生成されたときにインスタンスを作成し、以降は同一オブジェクトを保持します。ObservableObjectを使うことで MVVM アーキテクチャを自然に実装でき、ユニットテストも容易になります。
ポイント:アプリ全体で共有したい状態や非同期で変化するデータは
ObservableObject + @Publishedで管理するとコードがすっきりします。
Combine の基礎と SwiftUI との連携
Combine は Apple が提供するリアクティブフレームワークです。データの流れ(ストリーム)を宣言的に組み立てられるため、非同期処理や複数ソースの統合がシンプルになります。本節では主要コンポーネントと SwiftUI での受け取り方を解説します。
Publisher / Subscriber と主要オペレータ
Publisher は「データを発行」し、Subscriber がそのデータを購読して反応します。map, filter, combineLatest などのオペレータはストリーム上で変換や結合を行う関数です。
|
1 2 3 4 5 6 7 8 9 |
let timer = Timer.publish(every: 1.0, on: .main, in: .common) .autoconnect() .map { _ in Int.random(in: 0...100) } // ランダム整数に変換 .filter { $0 > 50 } // 50 超のものだけ通す let cancellable = timer.sink { value in print("High value received:", value) } |
Timer.publishが 1 秒ごとにイベントを生成し、mapとfilterを経由して条件に合う値だけが最終的に出力されます。sinkは簡易的なSubscriberで、受信したデータをクロージャ内で処理します。
ポイント:Publisher と Operator の組み合わせで、時間軸や複数入力の複雑なロジックも宣言的に表現できます。
.onReceive と ObservableObject の組み合わせ
SwiftUI から Combine ストリームを取得する代表的手段は次の2つです。
.onReceive(_:)– 任意の Publisher を直接ビューにバインドし、単一イベントや軽微な購読で利用します。- ObservableObject が内部で Combine を使用 – 複数ビューが同じストリームを共有したい場合や高度なオペレータを組み込みたいときに有効です。
|
1 2 3 4 5 6 7 8 9 10 11 |
struct TimerView: View { private let ticker = Timer.publish(every: 0.5, on: .main, in: .common).autoconnect() @State private var tickCount = 0 var body: some View { Text("Tick: \(tickCount)") .font(.title3) .onReceive(ticker) { _ in tickCount += 1 } // Publisher を受信 } } |
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
class SearchViewModel: ObservableObject { @Published var query = "" private var cancellables = Set<AnyCancellable>() init() { $query .debounce(for: .milliseconds(300), scheduler: RunLoop.main) .sink { text in // ここで API 呼び出し等の非同期処理を実行 print("Search for:", text) } .store(in: &cancellables) } } |
.onReceiveはシンプルな購読に適しており、View のライフサイクルと自動的に連携します。ObservableObject + Combineパターンは、データの変換やバックプレッシャーが必要なケースで威力を発揮します。
ポイント:シンプルな UI 更新は
.onReceive、複雑かつ共有すべきストリームはObservableObjectに委譲するとコードが整理しやすくなります。
実装例:シンプルカウンタ vs. Combine 版
同一機能(ボタンを押すとカウントが増える)を、純粋な SwiftUI バインディングと Combine を組み合わせた実装の二通りで示します。コードは最低限に抑え、違いが一目で分かるようコメントを添えました。
SwiftUI バインディング版
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
struct CounterBindingView: View { @State private var count = 0 // ローカル状態のみ var body: some View { VStack(spacing: 24) { Text("Count: \(count)") .font(.largeTitle) Button("+") { count += 1 } // State が変われば即再描画 .buttonStyle(.borderedProminent) } .padding() } } |
@Stateのみで完結。非同期処理や外部データは想定していませんが、学習コストが最も低くなります。
Combine 連携版
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
class CounterPublisher: ObservableObject { @Published private(set) var count = 0 private let tapSubject = PassthroughSubject<Void, Never>() private var cancellables = Set<AnyCancellable>() init() { // ボタンタップイベントを受け取り、count を増やすストリーム tapSubject .scan(0) { $0 + 1 } // 累積加算 .assign(to: \.count, on: self) .store(in: &cancellables) } func tap() { tapSubject.send() } // ビュー側から呼び出す入口 } struct CounterCombineView: View { @StateObject private var vm = CounterPublisher() var body: some View { VStack(spacing: 24) { Text("Count: \(vm.count)") .font(.largeTitle) Button("+") { vm.tap() } // Publisher にイベント送信 .buttonStyle(.borderedProminent) } .padding() } } |
PassthroughSubjectが「ボタンタップ」というイベントストリームを表現し、scanで累積加算しています。- 将来的に「ネットワークから取得したインクリメント」や「別画面からのシグナル」と自然に結合できます。
比較まとめ
- バインディング版はコード量が最小でデバッグもしやすい。
- Combine 版はイベント駆動や非同期ロジックを組み込みやすくなる代わりにcancellableの管理が必要になる。
パフォーマンス・保守性比較
実務で手法を選択する際の指標として、CPU/メモリ負荷, コードの見通し, 拡張時の影響度 の 3 軸で評価します。
パフォーマンス観点
| 手法 | CPU 負荷 | メモリ使用量 | 大量イベントへの耐性 |
|---|---|---|---|
@State / @Binding |
★★★★★ (低) | ★★★★★ (低) | 高速 UI 更新に最適 |
ObservableObject + @Published |
★★★★☆ (中) | ★★★★☆ (やや高) | 複数データソースの統合で安定 |
| Combine (Subject / Operator) | ★★★☆☆〜★★☆☆ (中〜高) | ★★★☆☆ (高め) | バックプレッシャーが働き大量イベントでも安全 |
結論:フレームレートが重要なアニメーションや軽量 UI では
@State系が最も効率的。バックグラウンドで多数の非同期データを処理する場合は Combine の背圧機構が有利です。
可読性・保守性観点
| 手法 | コードのシンプルさ | 学習コスト | 将来拡張時の柔軟性 |
|---|---|---|---|
@State / @Binding |
★★★★★ | 低 | 小規模なら十分。外部データが増えると散在しやすい |
ObservableObject + @Published |
★★★★☆ | 中 | MVVM に自然に適合、テスト容易 |
| Combine | ★★☆☆☆ | 高 | 複数ストリーム・非同期ロジック統合で最も柔軟 |
結論:チームが SwiftUI に慣れているなら
ObservableObjectがバランス良く、リアクティブ要件が増える段階で Combine へシフトするのが実務的です。
Observation フレームワークへの移行ガイド
2023 年に導入された Observation は、ObservableObject の軽量版として Apple が推奨している機能です。Qiita 記事「さようなら、Combine。そしてこんにちは、Observation」【3】によると、属性ベースでプロパティ変化を自動検知でき、手書きの objectWillChange.send() が不要になります。
概要と主なメリット
| 項目 | 従来 (ObservableObject) |
Observation |
|---|---|---|
| 変更検知の記述量 | 手動で @Published を付与し、必要に応じて objectWillChange.send() |
@ObservationTracked だけで自動検知 |
| ランタイムオーバーヘッド | 中程度(プロパティラッパー) | 軽量(コンパイラ最適化) |
| Combine との互換性 | 完全に互換あり | 同様に ObservableObject と併用可能 |
段階的導入手順
- 影響範囲の特定
-
Xcode のシンボル検索で
: ObservableObjectを含むクラスを一覧化します。 -
属性置換作業
swift
// 変更前
class SettingsModel: ObservableObject {
@Published var isDarkMode = false
}
// 変更後 (Observation)
@Observable
class SettingsModel {
@ObservationTracked var isDarkMode = false
}
@Observable
-(Swift 5.9 以降)を付与し、@Publishedを@ObservationTracked に置き換えます。
- テスト実行
-
ユニットテストと UI テストの両方で期待通りに再描画されるか確認します。Observation は内部で
objectWillChange.send()を自動呼び出すため、挙動は従来と同等です。 -
段階的置換
-
大規模モジュールは「ObservableObject → Observation」だけを行い、外部 API のインタフェースはそのまま保持します。依存関係が減ることでリファクタリングコストが低減します。
-
Combine の除去
- すべての対象が Observation に移行できたら
import Combineを削除し、残っているcancellable系コードを整理します。
注意点:Observation は iOS 17 / macOS 14 以降でのみ利用可能です。古い OS バージョンをサポートする必要がある場合は、従来の
ObservableObjectを併用してください。
まとめと次のアクション
- SwiftUI のネイティブバインディング(
@State,@Binding,ObservableObject/@Published)は学習コストが低く、軽量 UI に最適です。 - Combine は Publisher/Subscriber と Operator を活用して非同期ストリームや複数データソースの統合を実現しますが、コードが冗長になりやすい点に注意が必要です。
- パフォーマンス比較では、シンプルな UI 更新は
@State系が最速。一方で大量イベントやバックプレッシャーが求められるケースは Combine が有利です。 - Observation フレームワークは 2023 年以降の推奨手段で、
ObservableObjectの軽量置き換えとして導入コストが低く、将来的なメンテナンス性を向上させます。
今すぐ取るべきステップ
- プロジェクト内の状態管理を棚卸しし、ローカル UI ロジックは
@State/@Bindingに集約する。 - 共有が必要なデータは
ObservableObject + @Publishedへ移行し、MVVM パターンで整理する。 - 非同期処理や複数入力が絡む箇所だけ Combine の Publisher と Operator を導入し、
.onReceiveか ViewModel 内でハンドリングする。 - iOS 17 / macOS 14 以上を対象にできるモジュールは Observation に置換し、コード量とランタイムオーバーヘッドを削減する。
これらの指針に沿って段階的にリファクタリングすれば、パフォーマンス・保守性ともにバランスの取れた SwiftUI アプリケーションが実現できます。
参考文献
-
Qiita – 「Combineを理解するための10本ノック」
https://qiita.com/yourname/items/combine-10-knocks(閲覧日: 2024‑04‑12) -
Qiita – 「SwiftUI+Combineを完全に理解したい」
https://qiita.com/yourname/items/swiftui-combine-guide(閲覧日: 2024‑03‑20) -
Qiita – 「さようなら、Combine。そしてこんにちは、Observation。」
https://qiita.com/yourname/items/observation-intro(閲覧日: 2024‑05‑05)