🔥

SwiftUI + Firebase 実践構成 — @Observable × Firestore リアルタイムリスナー

に公開

はじめに

「かえたお」というタオルの交換タイミングを管理するiOSアプリを個人開発しています。

https://10half.jp/kaetao.html

バックエンドは Firebase(Auth / Firestore / Storage)で、UI は SwiftUI です。状態管理には iOS 17 で導入された Observation フレームワーク(@Observable を使っています。

SwiftUI + Firebase の組み合わせ自体はよくありますが、@Observable + Firestore リアルタイムリスナーの実践例はまだ少ないと感じたので、このアプリのアーキテクチャを紹介します。

全体構成

┌──────────────────────────────────────────────────┐
│  Views (SwiftUI struct)                           │
│  ContentView → TabView                            │
│    ├─ TowelListView                               │
│    │    └─ TowelDetailView                        │
│    └─ SettingsView                                │
├──────────────────────────────────────────────────┤
│  ViewModels (@Observable final class)             │
│  TowelListViewModel  ← TowelListView             │
│  TowelDetailViewModel ← TowelDetailView          │
├──────────────────────────────────────────────────┤
│  Services (Singleton, @Observable @MainActor)     │
│  AuthService.shared        — Firebase Auth        │
│  FirestoreService.shared   — Firestore CRUD       │
│  StorageService.shared     — Firebase Storage     │
│  GroupService.shared       — グループ管理          │
├──────────────────────────────────────────────────┤
│  Models (Codable struct)                          │
│  Towel, ExchangeRecord, ConditionCheck            │
└──────────────────────────────────────────────────┘

依存方向は View → ViewModel → Service → Firestore の単方向です。View が Service を直接参照するケースもありますが、書き込みは必ず Service 経由にしています。

なぜ @Observable なのか

iOS 17 以前は ObservableObject + @Published + @StateObject / @ObservedObject の組み合わせが定番でした。@Observable を使う理由は3つあります。

1. プロパティ単位の更新追跡

ObservableObjectobjectWillChange でオブジェクト全体の変更を通知します。@Observable はプロパティ単位で追跡するため、関係ないプロパティの変更で View が再描画されないです。

2. ボイラープレートの削減

@Published を全プロパティに付ける必要がなくなり、@StateObject / @ObservedObject の使い分けも不要です。View 側では @State で受け取るだけ。

3. Service をシンプルに保てる

Singleton の Service クラスに @Observable を付けるだけで、View から参照したプロパティが変更されると自動で UI に反映されます。Combine の sinkassign を書く必要がありません。

Model 層: Codable struct

struct Towel: Codable, Identifiable, Hashable {
    @DocumentID var id: String?
    var name: String = ""
    var location: String = ""
    var iconName: String = "hand.raised.fill"
    var exchangeIntervalDays: Int = 3
    @ServerTimestamp var createdAt: Date?
    @ServerTimestamp var updatedAt: Date?
    var lastExchangedAt: Date?

    // Firestore には保存しない — サブコレクションから取得
    var records: [ExchangeRecord] = []
    var conditionChecks: [ConditionCheck] = []

    enum CodingKeys: String, CodingKey {
        case id, name, location, iconName, exchangeIntervalDays
        case createdAt, updatedAt, lastExchangedAt
    }
}

ポイントは3つ。

1. @DocumentID@ServerTimestamp

FirebaseFirestore が提供するプロパティラッパーで、Codable と連携してドキュメント ID やサーバー側タイムスタンプを自動マッピングしてくれます。

2. サブコレクションデータは CodingKeys から除外

recordsconditionChecks は Firestore のサブコレクションから取得するデータです。CodingKeys に含めないことで、親ドキュメントのデコード時に無視されます。

3. 計算プロパティでビジネスロジックを表現

var status: TowelStatus {
    let remaining = Calendar.current.dateComponents(
        [.day], from: .now, to: nextExchangeDate
    ).day ?? 0
    if remaining < 0 {
        return .overdue
    } else if remaining <= 1 {
        return .soon
    } else {
        return .ok
    }
}

ステータス計算のような純粋ロジックは Model に置いています。ViewModel や Service に依存しない、テストしやすいコードです。

Service 層: @Observable Singleton

アプリの中核は FirestoreService です。@Observable な Singleton として、Firestore のリアルタイムリスナーとデータの保持を担います。

@Observable
@MainActor
final class FirestoreService {
    static let shared = FirestoreService()

    var towels: [Towel] = []
    var isLoading = true
    var errorMessage: String?

    private let db = Firestore.firestore()
    private var towelListener: ListenerRegistration?
    private var recordListeners: [String: ListenerRegistration] = [:]
    private var conditionCheckListeners: [String: ListenerRegistration] = [:]

    private init() {}
}

リアルタイムリスナーで自動同期

addSnapshotListener を使って Firestore の変更を購読します。データが更新されると towels プロパティが書き換わり、@Observable の追跡により 参照している全 View が自動で再描画されます。

func startListening() {
    guard let collection = towelsCollection() else { return }

    stopListening()
    isLoading = true

    towelListener = collection
        .order(by: "createdAt", descending: true)
        .addSnapshotListener { [weak self] snapshot, error in
            guard let self else { return }

            guard let documents = snapshot?.documents else {
                self.towels = []
                self.isLoading = false
                return
            }

            var newTowels = documents.compactMap { doc -> Towel? in
                try? doc.data(as: Towel.self)
            }

            // 既存のサブコレクションデータを引き継ぐ
            for i in newTowels.indices {
                if let existing = self.towels.first(where: { $0.id == newTowels[i].id }) {
                    newTowels[i].records = existing.records
                    newTowels[i].conditionChecks = existing.conditionChecks
                }
            }

            self.towels = newTowels
            self.isLoading = false
        }
}

ここで重要なのがサブコレクションデータの引き継ぎです。親ドキュメントのリスナーが発火するたびに Towel が再生成されますが、サブコレクション(交換記録、診断結果)のデータは親のスナップショットに含まれません。既存のデータを新しい配列にコピーすることで、サブコレクションリスナーとの整合性を保っています。

サブコレクションリスナーの遅延起動

サブコレクション(交換記録、診断結果)のリスナーは、タオル一覧の表示時には不要です。詳細画面を開いたときに初めて起動し、閉じたときに停止します。

// 詳細画面を開いたとき
func startSubcollectionListeners(towelId: String) {
    subscribeToRecords(towelId: towelId)
    subscribeToConditionChecks(towelId: towelId)
}

// 詳細画面を閉じたとき
func stopSubcollectionListeners(towelId: String) {
    recordListeners[towelId]?.remove()
    recordListeners.removeValue(forKey: towelId)
    conditionCheckListeners[towelId]?.remove()
    conditionCheckListeners.removeValue(forKey: towelId)
}

サブコレクションリスナーの結果は、towels 配列内の対応する Towel に直接書き込みます。

private func subscribeToRecords(towelId: String, limit: Int? = subcollectionPageSize) {
    guard recordListeners[towelId] == nil,
          let collection = towelsCollection() else { return }

    var query: Query = collection.document(towelId).collection("records")
        .order(by: "exchangedAt", descending: true)
    if let limit {
        query = query.limit(to: limit)
    }

    let listener = query
        .addSnapshotListener { [weak self] snapshot, _ in
            guard let self, let documents = snapshot?.documents else { return }
            let records = documents.compactMap { try? $0.data(as: ExchangeRecord.self) }
            if let index = self.towels.firstIndex(where: { $0.id == towelId }) {
                self.towels[index].records = records
            }
        }

    recordListeners[towelId] = listener
}

towels[index].records = records と書くだけで、@Observable がプロパティの変更を検知し、その records を参照している View だけが再描画されます。

リスナーのライフサイクル管理

リスナーの開始と停止のタイミングは明確に管理する必要があります。

アプリ起動

ContentView の .task

GroupService.loadGroupForCurrentUser()  ← グループID取得

FirestoreService.startListening()       ← タオル一覧リスナー開始

[ユーザーがタオル詳細画面を開く]

startSubcollectionListeners()           ← 交換記録・診断リスナー開始

[ユーザーが詳細画面を閉じる]

stopSubcollectionListeners()            ← 交換記録・診断リスナー停止

[サインアウト]

FirestoreService.stopListening()        ← 全リスナー停止

削除されたタオルのリスナーも自動でクリーンアップしています。

// 親リスナーのコールバック内
let currentIds = Set(newTowels.compactMap(\.id))
for key in self.recordListeners.keys where !currentIds.contains(key) {
    self.recordListeners[key]?.remove()
    self.recordListeners.removeValue(forKey: key)
}

ViewModel 層: ロジックの分離

ViewModel は View のロジックを分離する役割です。Service の towels 配列を直接参照し、フィルタリングやソートを行います。

@Observable
final class TowelListViewModel {
    var searchText = ""
    var errorMessage: String?

    func filteredTowels(_ towels: [Towel]) -> [Towel] {
        guard !searchText.isEmpty else { return towels }
        return towels.filter { towel in
            towel.name.localizedCaseInsensitiveContains(searchText) ||
            towel.location.localizedCaseInsensitiveContains(searchText)
        }
    }

    func sortedByStatus(_ towels: [Towel]) -> [Towel] {
        towels.sorted { lhs, rhs in
            let lhsOrder = statusOrder(lhs.status)
            let rhsOrder = statusOrder(rhs.status)
            if lhsOrder != rhsOrder {
                return lhsOrder < rhsOrder
            }
            return lhs.daysSinceLastExchange > rhs.daysSinceLastExchange
        }
    }
}

ViewModel は @Observable ですが Singleton ではありません。View ごとにインスタンスが生成されます。データの保持は Service が担い、ViewModel はその加工に徹します。

View 層: Service を直接参照

View から Service の @Observable プロパティを直接参照するのが、このアーキテクチャの特徴です。

struct ContentView: View {
    @State private var firestoreService = FirestoreService.shared

    var body: some View {
        TabView {
            NavigationStack {
                TowelListView()
            }
            // ...
        }
        .task {
            await GroupService.shared.loadGroupForCurrentUser()
            firestoreService.startListening()
        }
        .onChange(of: firestoreService.towels) { _, towels in
            NotificationService.shared.rescheduleAllNotifications(for: towels)
        }
    }
}

@State private var firestoreService = FirestoreService.shared で Singleton への参照を保持し、firestoreService.towels の変更を onChange で検知して通知をリスケジュールしています。

詳細画面の ViewModel 連携

詳細画面では ViewModel がサブコレクションリスナーのライフサイクルを管理します。

@Observable
@MainActor
final class TowelDetailViewModel {
    let towelId: String

    var towel: Towel? {
        FirestoreService.shared.towels.first(where: { $0.id == towelId })
    }

    func startListening() {
        FirestoreService.shared.startSubcollectionListeners(towelId: towelId)
    }

    func stopListening() {
        FirestoreService.shared.stopSubcollectionListeners(towelId: towelId)
    }
}

towel が computed property なのがポイントです。FirestoreService.shared.towels が変更されるたびに再評価されるため、リアルタイムリスナーの更新が自動で ViewModel → View に伝播します。

デュアルパス: ソロ / グループの自動切替

このアプリには家族グループ共有機能があります。グループに参加すると、タオルデータの保存先が /users/{uid}/towels から /groups/{groupId}/towels に切り替わります。

private func towelsCollection() -> CollectionReference? {
    guard let userId else { return nil }

    if let groupId = GroupService.shared.groupId {
        return db.collection("groups").document(groupId).collection("towels")
    }
    return db.collection("users").document(userId).collection("towels")
}

全ての CRUD 操作とリスナーがこの towelsCollection() を経由するため、呼び出し側はソロ / グループを意識する必要がありません。パスの切替は1箇所に集約されています。

このアーキテクチャの良いところ / 注意点

良いところ

  • コード量が少ない — Combine の sink / assign が不要。@Published も不要
  • リアクティブ — Firestore の変更が自動で UI に反映される。手動リフレッシュ不要
  • デバッグしやすい — データは FirestoreService.shared.towels に集約。1箇所見れば全体像がわかる
  • SwiftData 不要 — ローカル永続化なしで、Firestore をそのまま Single Source of Truth として使える

注意点

  • リスナーの停止忘れstopListening() を呼ばないとリスナーがリークする。サインアウト時に全停止を確実に行うこと
  • Singleton の密結合 — テスタビリティは犠牲になる。個人開発では許容しているが、チーム開発なら Protocol で抽象化すべき
  • サブコレクションの引き継ぎ — 親リスナー発火時にサブコレクションデータをコピーする処理を忘れると、詳細画面で表示が消える

まとめ

  • @Observable + Firestore addSnapshotListener の組み合わせで、リアクティブな UI がシンプルに実現できる
  • Service を @Observable Singleton にすることで、どの View からでもデータにアクセスできる
  • サブコレクションリスナーは遅延起動(詳細画面を開いたときだけ)でコストを抑える
  • デュアルパス(ソロ / グループ)は towelsCollection() 1箇所に集約して、呼び出し側の複雑さを排除
  • 個人開発アプリの規模なら、この構成で十分にスケールする

Firebase を使った SwiftUI アプリのアーキテクチャに悩んでいる方の参考になれば幸いです。

アプリ「かえたお」は App Store で公開中です。
https://10half.jp/kaetao.html

Discussion