markdown

はじめに (対象読者・この記事でわかること)

この記事は、iOS アプリ開発に興味がある中級者以上のプログラマーを対象としています。特に SwiftUI を使って UI を構築している方や、Firebase の Firestore からデータを取得・表示したい方に最適です。この記事を読むことで、以下のことができるようになります。

  • Firestore のデータ構造とリアルタイムリスナーの概念を理解する
  • SwiftUI で @ObservableObject@Published を組み合わせ、取得したデータを画面にバインドする方法が分かる
  • エラーハンドリングやオフライン対応のベストプラクティスを実装できる

執筆の背景は、社内プロジェクトでユーザー投稿をリアルタイムに表示させる必要が生じ、公式サンプルが少なく実装に時間がかかった経験からです。実践的なコード例とトラブルシューティング情報をまとめました。

前提知識

この記事を読み進める上で、以下の知識があるとスムーズです。

  • Swift の基本文法とオブジェクト指向の概念
  • SwiftUI のビュー構造(View, State, Binding など)
  • Firebase のプロジェクト作成と iOS アプリへの SDK 追加手順

SwiftUI と Firestore の概要・背景

モバイルアプリにおいて、サーバー側のデータをリアルタイムに反映させるケースは増加しています。特に SNS やチャット、タスク管理アプリでは、ユーザーが入力した情報が即座に他の端末に表示されることが求められます。Google が提供する NoSQL データベース Firestore は、リアルタイムリスニング機能とオフラインキャッシュを標準で備えているため、こうした要件に非常にマッチします。

一方、Apple が提唱する SwiftUI は宣言的 UI フレームワークで、データの変化を自動でビューに反映できる点が大きな魅力です。@ObservableObject@Published を組み合わせることで、モデル層の変更を UI に即座にバインドできます。Firestore のリアルタイムリスナーと SwiftUI のデータ駆動型アーキテクチャを組み合わせると、数行のコードで高度なリアルタイム UI が構築可能です。

この組み合わせで注意したい点は、スレッド管理メモリリーク です。Firestore のリスナーはバックグラウンドスレッドでコールバックを呼び出すため、UI の更新は必ずメインスレッドで行う必要があります。また、リスナーの解除(remove())を忘れると、ビューが解放されてもリスナーが残り続け、不要なネットワーク通信やクラッシュの原因になります。本稿では、これらの落とし穴を回避しつつ、実際に動くコード例を示します。

SwiftUI と Firestore の具体的な実装方法

以下では、シンプルな「TODO リスト」アプリを例に、Firestore からデータを取得しリアルタイムで表示するまでのフローを解説します。プロジェクト構成は次の通りです。

MyTodoApp/
 ├─ MyTodoAppApp.swift          // アプリエントリポイント
 ├─ Views/
 │   └─ TodoListView.swift      // メインビュー
 ├─ Models/
 │   └─ TodoItem.swift          // データモデル
 │   └─ TodoStore.swift         // Firestore との橋渡し
 └─ Resources/
     └─ GoogleService-Info.plist

ステップ 1: Firebase のセットアップ

  1. Firebase コンソールで新規プロジェクトを作成し、iOS アプリを追加。GoogleService-Info.plist をダウンロードして Xcode プロジェクトにドラッグ&ドロップ。
  2. Podfile に以下を追記し、pod install を実行。
Ruby
pod 'Firebase/Firestore'
  1. AppDelegate(または MyTodoAppApp.swift@main 構造体)で Firebase を初期化。
Swift
import FirebaseCore @main struct MyTodoAppApp: App { init() { FirebaseApp.configure() } var body: some Scene { WindowGroup { TodoListView() } } }

ステップ 2: データモデルと Firestore のマッピング

TodoItem.swift にデータモデルを定義し、Firestore のドキュメントと相互変換できるように Codable へ準拠させます。

Swift
import Foundation import FirebaseFirestoreSwift struct TodoItem: Identifiable, Codable { @DocumentID var id: String? // Firestore が自動生成する ID var title: String var completed: Bool var createdAt: Date = Date() }

ステップ 3: ObservableObject とリアルタイムリスナー

TodoStore.swiftObservableObject を実装し、Firestore のリスナーを管理します。ここが本稿の肝となります。

Swift
import Foundation import FirebaseFirestore import FirebaseFirestoreSwift import Combine final class TodoStore: ObservableObject { @Published var todos: [TodoItem] = [] private var db = Firestore.firestore() private var listener: ListenerRegistration? init() { startListening() } deinit { stopListening() } // MARK: - リスナー開始 func startListening() { // "todos" コレクションをリアルタイムで監視 listener = db.collection("todos") .order(by: "createdAt", descending: true) .addSnapshotListener { [weak self] snapshot, error in guard let self = self else { return } if let error = error { print("Firestore listen error: \(error)") return } // ドキュメントを TodoItem にデコード self.todos = snapshot?.documents.compactMap { doc in try? doc.data(as: TodoItem.self) } ?? [] } } // MARK: - リスナー停止 func stopListening() { listener?.remove() listener = nil } // MARK: - CRUD 操作 func addTodo(title: String) { let newTodo = TodoItem(title: title, completed: false) do { _ = try db.collection("todos").addDocument(from: newTodo) } catch { print("Failed to add todo: \(error)") } } func toggleComplete(_ todo: TodoItem) { guard let id = todo.id else { return } db.collection("todos").document(id).updateData(["completed": !todo.completed]) { error in if let error = error { print("Failed to toggle: \(error)") } } } func deleteTodo(_ todo: TodoItem) { guard let id = todo.id else { return } db.collection("todos").document(id).delete { error in if let error = error { print("Failed to delete: \(error)") } } } }

ポイント解説

  • addSnapshotListener はバックグラウンドスレッドで呼び出されますが、@Published プロパティへの代入は自動的にメインスレッドへ切り替わります(Combine が内部で DispatchQueue.main.async を行うため)。明示的に DispatchQueue.main.async を書く必要はありませんが、ロジックが複雑になる場合は自前で保証すると安全です。
  • deinit でリスナーを必ず解除することで、ビューが破棄されたときにメモリリークを防げます。
  • DocumentID プロパティラッパーにより、id が自動的に Firestore のドキュメント ID と同期します。

ステップ 4: ビュー側でデータをバインド

TodoListView.swift では @StateObjectTodoStore を注入し、List にバインドします。

Swift
import SwiftUI struct TodoListView: View { @StateObject private var store = TodoStore() @State private var newTitle: String = "" var body: some View { NavigationView { VStack { // 新規 TODO 入力欄 HStack { TextField("新しいタスク", text: $newTitle) .textFieldStyle(RoundedBorderTextFieldStyle()) Button(action: { guard !newTitle.isEmpty else { return } store.addTodo(title: newTitle) newTitle = "" }) { Image(systemName: "plus") } } .padding() // TODO リスト List { ForEach(store.todos) { todo in HStack { Image(systemName: todo.completed ? "checkmark.circle.fill" : "circle") .foregroundColor(todo.completed ? .green : .gray) .onTapGesture { store.toggleComplete(todo) } Text(todo.title) .strikethrough(todo.completed, color: .gray) } } .onDelete { indexSet in indexSet.map { store.todos[$0] }.forEach { store.deleteTodo($0) } } } } .navigationTitle("TODO リスト") } } }

UI のポイント

  • @StateObject はビューが生成されたときに一度だけインスタンス化され、ライフサイクルがビューに紐付くため、リスナーの開始・停止が自動的に管理されます。
  • onTapGesture で完了状態をトグルし、onDelete でスワイプ削除を実装しています。
  • Liststore.todos の変化を自動的に検知し、リアルタイムで UI が更新されます。

ステップ 5: ハマった点とエラー解決

1. 「Listener registration が解放されない」エラー

症状
画面遷移で TodoListView を抜けても、Firestore へのリッスンが継続し、コンソールに大量の listen ログが出続けた。

原因
TodoStore@ObservedObject で保持していたため、ビューが破棄されても TodoStore が残存した。

解決策
@StateObject に変更し、ビューのライフサイクルに合わせて deinit が呼ばれるようにした。さらに deinitstopListening() を必ず記述。

2. 「デコードエラー: missing required key 'title'」

症状
Firestore に手動でドキュメントを追加したが、アプリ側で TodoItem にマッピングできず、todos が空になる。

原因
title フィールドが必須 (let title: String) なのに、手動追加時にキーが抜けていた。

解決策
Firestore コンソールで必ず title フィールドを設定するか、モデル側をオプショナル (String?) に変更してデフォルト値を設定。

3. 「UI がフリーズする」

症状
大量のデータ(数千件)を持つ todos コレクションを監視すると、List の描画が遅くなり、スクロールがカクカクした。

原因
addSnapshotListener が変更ごとに全件を取得し、@Published が全体を再代入しているため、SwiftUI が全件再描画している。

解決策
クエリで limit(to:) を使用し、ページングを実装
差分更新を行うために FirestoreSwiftDocumentChange を活用し、追加・削除のみを反映させるロジックに変更

Swift
listener = db.collection("todos") .order(by: "createdAt", descending: true) .addSnapshotListener { [weak self] snapshot, error in guard let self = self, let snapshot = snapshot else { return } snapshot.documentChanges.forEach { diff in switch diff.type { case .added: if let todo = try? diff.document.data(as: TodoItem.self) { self.todos.insert(todo, at: Int(diff.newIndex)) } case .modified: if let todo = try? diff.document.data(as: TodoItem.self) { self.todos[Int(diff.newIndex)] = todo } case .removed: self.todos.remove(at: Int(diff.oldIndex)) } } }

この差分ハンドリングに置き換えるだけで、スクロールのスムーズさが大幅に改善します。

まとめ

本記事では、SwiftUI と Firestore を組み合わせてリアルタイムデータを取得・表示する実装手順 を解説しました。

  • Firestore のリアルタイムリスナーTodoStore に実装し、@Published で UI にバインド
  • @StateObjectdeinit によるライフサイクル管理でメモリリークを防止
  • エラーハンドリングと差分更新 によって、パフォーマンスと安定性を確保

これらを活用すれば、リアルタイム性が求められる iOS アプリをシンプルかつ安全に構築できます。今後は、認証付きマルチユーザー環境や、オフライン時のキューイング機構を組み込んだ高度な実装にも挑戦していく予定です。

参考資料