Zenn
🍏

Use Observation with Firestore

2025/01/28に公開

Observationで状態管理をする

https://developer.apple.com/documentation/observation

Observation
Make responsive apps that update the presentation when underlying data changes.

観察
基礎となるデータが変更されたときにプレゼンテーションが更新されるレスポンシブ・アプリを作る。


Overview
Observation provides a robust, type-safe, and performant implementation of the observer design pattern in Swift. This pattern allows an observable object to maintain a list of observers and notify them of specific or general state changes. This has the advantages of not directly coupling objects together and allowing implicit distribution of updates across potential multiple observers.

The Observation frameworks provides the following capabilities:

Marking a type as observable

Tracking changes within an instance of an observable type

Observing and utilizing those changes elsewhere, such as in an app’s user interface

To declare a type as observable, attach the Observable() macro to the type declaration. This macro declares and implements conformance to the Observable protocol to the type at compile time.

Observation は、Swift のオブザーバーデザインパターンの、ロバストで、型安全で、実行可能な実装を提供します。このパターンは、observable オブジェクトがオブザーバーのリストを維持し、特定のまたは一般的な状態の変更を通知することを可能にします。これは、オブジェクト同士を直接結合せず、潜在的な複数のオブザーバー間で更新を暗黙的に分配できるという利点があります。

オブザベーション・フレームワークは以下の機能を提供します:

型をオブザーバブルとしてマークする

観測可能な型のインスタンス内の変更を追跡する。

アプリのユーザーインターフェイスなど、他の場所でそれらの変更を観察し、利用する。

型をobservableとして宣言するには、型宣言にObservable()マクロをアタッチします。このマクロは、コンパイル時にObservableプロトコルへの準拠を型に宣言し、実装する。


非同期処理をするときに使うものだと思ってください。今回はCloud Firestoreで使ってみました。

こちらが完成品

Demo Video

https://youtu.be/7lYlFqrb2x8

Try Lesson

Todo

  1. Firebaseのプロジェクトを作成する。
  2. SwiftUIのプロジェクトを作る。
  3. firebase-ios-sdkを追加する

SPMで追加する。

https://github.com/firebase/firebase-ios-sdk

公式の動画を見ながらGoogleService-Info.plistを追加してください。

まずはコレクションの構造に合わせてモデルを作成する。

データモデル
import Foundation
import FirebaseFirestore

/// メモのデータモデル
struct Memo: Identifiable, Codable, Hashable {
    /// 一意の識別子
    var id: String
    
    /// メモのタイトル
    var title: String
    
    /// メモの内容
    var content: String
    
    /// 作成日時
    var createdAt: Date
    
    /// デフォルトイニシャライザ
    init(id: String = UUID().uuidString, 
         title: String = "", 
         content: String = "", 
         createdAt: Date = Date()) {
        self.id = id
        self.title = title
        self.content = content
        self.createdAt = createdAt
    }
    
    /// Firestoreのドキュメントからメモを作成
    init?(document: DocumentSnapshot) {
        guard let data = document.data() else { return nil }
        
        self.id = document.documentID
        self.title = data["title"] as? String ?? ""
        self.content = data["content"] as? String ?? ""
        self.createdAt = (data["createdAt"] as? Timestamp)?.dateValue() ?? Date()
    }
    
    /// Firestoreに保存するデータを生成
    func toFirestoreData() -> [String: Any] {
        return [
            "title": title,
            "content": content,
            "createdAt": Timestamp(date: createdAt)
        ]
    }
}

/// メモに関連するエラー
enum MemoError: Error {
    case fetchError(String)
    case saveError(String)
    case deleteError(String)
    case unknown
    
    var localizedDescription: String {
        switch self {
        case .fetchError(let message):
            return "データの取得に失敗しました: \(message)"
        case .saveError(let message):
            return "データの保存に失敗しました: \(message)"
        case .deleteError(let message):
            return "データの削除に失敗しました: \(message)"
        case .unknown:
            return "不明なエラーが発生しました"
        }
    }
}

ロジックを書いてしまいましたが、ViewModelで状態管理とCRUD処理を行います。SwiftUIはViewModelは相性が悪いそうですが、今回はTCA使ってみようと思ったらうまくいかなかったのでこっちにしました。

ViewModel
import Foundation
import FirebaseFirestore
import Observation

@Observable
class MemoViewModel {
    /// メモのリスト
    var memos: [Memo] = []
    
    /// 現在選択されているメモ
    var selectedMemo: Memo?
    
    /// 読み込み状態
    var isLoading = false
    
    /// エラー状態
    var error: MemoError?
    
    /// Firestoreのインスタンス
    private let db = Firestore.firestore()
    
    /// メモを全件取得
    func fetchMemos() async {
        isLoading = true
        error = nil
        
        do {
            let snapshot = try await db.collection("memos").getDocuments()
            memos = snapshot.documents.compactMap { document -> Memo? in
                let data = document.data()
                return Memo(
                    id: document.documentID,
                    title: data["title"] as? String ?? "",
                    content: data["content"] as? String ?? "",
                    createdAt: (data["createdAt"] as? Timestamp)?.dateValue() ?? Date()
                )
            }
        } catch {
            self.error = .fetchError(error.localizedDescription)
        }
        
        isLoading = false
    }
    
    /// メモを追加
    func addMemo(_ memo: Memo) async {
        isLoading = true
        error = nil
        
        do {
            let data: [String: Any] = [
                "title": memo.title,
                "content": memo.content,
                "createdAt": Timestamp(date: memo.createdAt)
            ]
            _ = try await db.collection("memos").addDocument(data: data)
            await fetchMemos()
        } catch {
            self.error = .saveError(error.localizedDescription)
        }
        
        isLoading = false
    }
    
    /// メモを更新
    func updateMemo(_ memo: Memo) async {
        isLoading = true
        error = nil
        
        do {
            let data: [String: Any] = [
                "title": memo.title,
                "content": memo.content,
                "createdAt": Timestamp(date: memo.createdAt)
            ]
            try await db.collection("memos").document(memo.id).setData(data)
            await fetchMemos()
        } catch {
            self.error = .saveError(error.localizedDescription)
        }
        
        isLoading = false
    }
    
    /// メモを削除
    func deleteMemo(_ memo: Memo) async {
        isLoading = true
        error = nil
        
        do {
            try await db.collection("memos").document(memo.id).delete()
            await fetchMemos()
        } catch {
            self.error = .deleteError(error.localizedDescription)
        }
        
        isLoading = false
    }
}

編集用の詳細ページを作成する。

編集ページ
import SwiftUI
import Observation

struct MemoDetailView: View {
    /// 編集対象のメモ
    @State private var editableMemo: Memo
    
    /// ビューモデル
    @State private var viewModel: MemoViewModel
    
    /// 新規メモかどうか
    @State private var isNewMemo: Bool
    
    /// エラーメッセージ
    @State private var errorMessage: String?
    
    /// 画面を閉じるための環境変数
    @Environment(\.dismiss) private var dismiss
    
    /// イニシャライザ
    init(memo: Memo, viewModel: MemoViewModel, isNewMemo: Bool = false) {
        self._editableMemo = State(initialValue: memo)
        self._viewModel = State(initialValue: viewModel)
        self._isNewMemo = State(initialValue: isNewMemo)
    }
    
    var body: some View {
        NavigationView {
            Form {
                Section(header: Text("タイトル")) {
                    TextField("タイトルを入力", text: $editableMemo.title)
                }
                
                Section(header: Text("内容")) {
                    TextEditor(text: $editableMemo.content)
                        .frame(height: 200)
                }
                
                if let errorMessage = errorMessage {
                    Section(header: Text("エラー")) {
                        Text(errorMessage)
                            .foregroundColor(.red)
                    }
                }
            }
            .navigationTitle(isNewMemo ? "新規メモ" : "メモ編集")
            .navigationBarItems(
                leading: Button("キャンセル") {
                    dismiss()
                },
                trailing: Button(isNewMemo ? "追加" : "保存") {
                    Task {
                        do {
                            if isNewMemo {
                                try await saveNewMemo()
                            } else {
                                try await updateExistingMemo()
                            }
                            dismiss()
                        } catch {
                            errorMessage = error.localizedDescription
                        }
                    }
                }
                .disabled(editableMemo.title.isEmpty)
            )
        }
    }
    
    /// 新規メモを保存
    private func saveNewMemo() async throws {
        await viewModel.addMemo(editableMemo)
        
        if let error = viewModel.error {
            throw error
        }
    }
    
    /// 既存メモを更新
    private func updateExistingMemo() async throws {
        await viewModel.updateMemo(editableMemo)
        
        if let error = viewModel.error {
            throw error
        }
    }
}

データの表示とを表示するView

MemoList
import SwiftUI
import Observation

struct MemoListView: View {
    /// メモのビューモデル
    @State private var viewModel = MemoViewModel()
    
    /// 新規メモ追加用のモーダル表示フラグ
    @State private var showingAddMemoSheet = false
    
    /// エラーアラート表示フラグ
    @State private var showingErrorAlert = false
    
    var body: some View {
        NavigationStack {
            Group {
                if viewModel.isLoading {
                    ProgressView("読み込み中...")
                } else {
                    List {
                        ForEach(viewModel.memos) { memo in
                            NavigationLink(destination: MemoDetailView(memo: memo, viewModel: viewModel)) {
                                VStack(alignment: .leading) {
                                    Text(memo.title)
                                        .font(.headline)
                                    Text(memo.content)
                                        .font(.subheadline)
                                        .foregroundColor(.secondary)
                                }
                            }
                            .swipeActions(edge: .trailing) {
                                Button(role: .destructive) {
                                    Task {
                                        await viewModel.deleteMemo(memo)
                                    }
                                } label: {
                                    Label("削除", systemImage: "trash")
                                }
                            }
                        }
                    }
                }
            }
            .navigationTitle("メモ")
            .navigationBarItems(trailing: 
                Button(action: { showingAddMemoSheet = true }) {
                    Image(systemName: "plus")
                }
            )
            .sheet(isPresented: $showingAddMemoSheet) {
                MemoDetailView(memo: Memo(), viewModel: viewModel, isNewMemo: true)
            }
            .task {
                await viewModel.fetchMemos()
            }
            .refreshable {
                await viewModel.fetchMemos()
            }
            .alert(isPresented: Binding(
                get: { viewModel.error != nil },
                set: { _ in viewModel.error = nil }
            )) {
                Alert(
                    title: Text("エラー"),
                    message: Text(viewModel.error?.localizedDescription ?? "不明なエラーが発生しました"),
                    dismissButton: .default(Text("OK"))
                )
            }
        }
    }
}

struct MemoRowView: View {
    let memo: Memo
    
    var body: some View {
        VStack(alignment: .leading) {
            Text(memo.title)
                .font(.headline)
            Text(memo.content)
                .font(.subheadline)
                .foregroundColor(.secondary)
            Text(memo.createdAt, style: .date)
                .font(.caption)
                .foregroundColor(.secondary)
        }
    }
}

アプリのエントリーポイントのファイルを設定する。これで完成です。

エントリーポイント
import SwiftUI
import FirebaseCore

class AppDelegate: NSObject, UIApplicationDelegate {

    func application(_ application: UIApplication,
                   didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
        FirebaseApp.configure()

        return true
    }
}

@main
struct TcaCloudFirestoreApp: App {
    @UIApplicationDelegateAdaptor(AppDelegate.self) var delegate
    
    var body: some Scene {
        WindowGroup {
            MemoListView()
        }
    }
}

最後に

以前は、Combineなるものでやっていたのですが最近購入した技術書に紹介されたので、今はこっちが主流なのかなと思い乗り換えました。データフローの管理にObservationを使うのが今は一般的なのかと思い最近学習しております。

Discussion

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