SwiftUI + Firebase 実践構成 — @Observable × Firestore リアルタイムリスナー
はじめに
「かえたお」というタオルの交換タイミングを管理するiOSアプリを個人開発しています。
バックエンドは 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. プロパティ単位の更新追跡
ObservableObject は objectWillChange でオブジェクト全体の変更を通知します。@Observable はプロパティ単位で追跡するため、関係ないプロパティの変更で View が再描画されないです。
2. ボイラープレートの削減
@Published を全プロパティに付ける必要がなくなり、@StateObject / @ObservedObject の使い分けも不要です。View 側では @State で受け取るだけ。
3. Service をシンプルに保てる
Singleton の Service クラスに @Observable を付けるだけで、View から参照したプロパティが変更されると自動で UI に反映されます。Combine の sink や assign を書く必要がありません。
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 から除外
records と conditionChecks は 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+ FirestoreaddSnapshotListenerの組み合わせで、リアクティブな UI がシンプルに実現できる -
Service を
@ObservableSingleton にすることで、どの View からでもデータにアクセスできる - サブコレクションリスナーは遅延起動(詳細画面を開いたときだけ)でコストを抑える
-
デュアルパス(ソロ / グループ)は
towelsCollection()1箇所に集約して、呼び出し側の複雑さを排除 - 個人開発アプリの規模なら、この構成で十分にスケールする
Firebase を使った SwiftUI アプリのアーキテクチャに悩んでいる方の参考になれば幸いです。
アプリ「かえたお」は App Store で公開中です。
Discussion