💽

SwiftUI × Firestoreでデータ保存を実装する

に公開

1. はじめに

以前実装したFirebase Authを使った認証機能追加の延長線で、今回はFirestoreを使ったクラウドへのデータ保存を実装したいと思います。

2. 事前準備

以下のリンク先の記事を参考に「プロジェクトの作成」と「アプリの登録」を行います。

2.1.1. プロジェクトの作成

2.1.2. アプリの登録

3. Firestore Databaseでデータベースを作成する

Firestore Databaseからデータベースを作成する

DB作成手順01

「データベースID」と「ロケーション」を指定する

DB作成手順02

セキュリティルールを設定する

DB作成手順03

4. セキュリティルールを変更する

以下のコードを参考にセキュリティルールを変更する

ルール変更01

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    // 認証されたユーザーのみ読み書き可能(開発用)
    // 本番利用の際は細かく設定すること
    match /{document=**} {
      allow read, write: if request.auth != null;
    }
  }
}

5. Xcodeプロジェクトにライブラリを追加する

以下のサイトを参考に、Xcodeプロジェクトにライブラリを追加します。

https://firebase.google.com/docs/firestore/quickstart?hl=ja#swift

[File]>[Add Package Dependencies]から以下のURLを検索する

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

「Firebase Firestore」を追加する

Xcodeプロジェクトライブラリ追加01

6. 実装

6.1. インスタンスを初期化する

💡ポイント:

  • FirebaseApp.configure() は アプリケーションの起動時に一度だけ 呼び出す必要がある
  • SwiftUIの場合は @main 構造体の init() 内で初期化するのが最適
  • 初期化前に他のFirebase機能を使用するとクラッシュの原因となる
MyApp.swift
import SwiftUI
import Firebase
import FirebaseFirestore

@main
struct MyApp: App {
    init() {
        // Firebaseの初期化
        FirebaseApp.configure()  // ここで一度だけ初期化
    }
    
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

6.2. データを追加する

💡ポイント:

  • Firestore.firestore()でメインのFirestoreインスタンスを取得
  • コレクション参照 → db.collection("コレクション名")
  • addDocument(data:)メソッドはドキュメントIDを自動生成
  • データは[String: Any]形式のディクショナリで渡す
AddDataView.swift
import SwiftUI
import FirebaseFirestore

struct AddDataView: View {
    @State private var name = ""
    @State private var age = ""
    @State private var message = ""
    
    // Firestoreのインスタンスを取得
    let db = Firestore.firestore()
    
    var body: some View {
        VStack {
            TextField("名前", text: $name)
                .textFieldStyle(RoundedBorderTextFieldStyle())
            
            TextField("年齢", text: $age)
                .textFieldStyle(RoundedBorderTextFieldStyle())
                .keyboardType(.numberPad)
            
            Button("保存") {
                addUser()
            }
            
            Text(message)
        }
        .padding()
    }
    
    func addUser() {
        // usersコレクションへの参照
        let usersRef = db.collection("users")
        
        // データを追加
        usersRef.addDocument(data: [
            "name": name,
            "age": Int(age) ?? 0,  // 文字列から数値への変換に注意
            "createdAt": Timestamp(date: Date())  // タイムスタンプの追加
        ]) { error in
            if let error = error {
                message = "エラー: \(error.localizedDescription)"
            } else {
                message = "ユーザーを追加しました"
                name = ""
                age = ""
            }
        }
    }
}

6.3. データを取得する

単一ドキュメントの取得

💡ポイント:

  • キュメント参照 → db.collection("コレクション名").document("ドキュメントID")
  • getDocument()メソッドは非同期で動作する
  • 取得したドキュメントの存在確認(document.exists)が重要
  • データはdocument.data()で取得でき、型は[String: Any]?
UserDetailView.swift
import SwiftUI
import FirebaseFirestore

struct UserDetailView: View {
    let userId: String
    @State private var user: [String: Any]?
    @State private var message = ""
    
    let db = Firestore.firestore()
    
    var body: some View {
        VStack {
            if let user = user {
                Text("名前: \(user["name"] as? String ?? "")")
                Text("年齢: \(String(describing: user["age"] ?? ""))")
            } else {
                Text("ユーザー情報を読み込み中...")
            }
            
            Text(message)
        }
        .onAppear {
            fetchUser()
        }
    }
    
    func fetchUser() {
        // ドキュメントIDを指定して単一のドキュメントを取得
        db.collection("users").document(userId).getDocument { document, error in
            if let error = error {
                message = "エラー: \(error.localizedDescription)"
                return
            }
            
            if let document = document, document.exists {
                // document.exists で存在確認を行う
                user = document.data()  // データを取得
                message = "ユーザー情報を取得しました"
            } else {
                message = "ユーザーが見つかりません"
            }
        }
    }
}

コレクション内のすべてのドキュメントを取得

💡ポイント:

  • getDocuments()メソッドでコレクション全体を取得
  • 結果はquerySnapshot.documentsの配列として返される
  • 各ドキュメントのdata()メソッドでデータにアクセス
  • 大量のデータがある場合はページネーションを検討する
UserListView.swift
import SwiftUI
import FirebaseFirestore

struct UserListView: View {
    @State private var users: [[String: Any]] = []
    @State private var message = ""
    
    let db = Firestore.firestore()
    
    var body: some View {
        VStack {
            List {
                ForEach(users, id: \.self) { user in
                    VStack(alignment: .leading) {
                        Text("名前: \(user["name"] as? String ?? "")")
                        Text("年齢: \(String(describing: user["age"] ?? ""))")
                    }
                }
            }
            
            Text(message)
        }
        .onAppear {
            fetchAllUsers()
        }
    }
    
    func fetchAllUsers() {
        db.collection("users").getDocuments { querySnapshot, error in
            if let error = error {
                message = "エラー: \(error.localizedDescription)"
                return
            }
            
            guard let documents = querySnapshot?.documents else {
                message = "ドキュメントがありません"
                return
            }
            
            // ドキュメントの配列を作成
            users = documents.map { $0.data() }  // map関数でデータ変換
            message = "\(users.count)人のユーザーを取得しました"
        }
    }
}

リアルタイムリスナーを使用したデータ取得

💡ポイント:

  • addSnapshotListenerでリアルタイム更新を監視
  • データベースの変更があるたびに自動的にコールバックが呼ばれる
  • リスナーは参照を保持してonDisappearなどで解除するのが理想的
  • 複数回のリスナー設定に注意(メモリリークの原因になる)
RealtimeUserListView.swift
import SwiftUI
import FirebaseFirestore

struct RealtimeUserListView: View {
    @State private var users: [[String: Any]] = []
    @State private var message = ""
    
    let db = Firestore.firestore()
    
    var body: some View {
        VStack {
            List {
                ForEach(users, id: \.self) { user in
                    VStack(alignment: .leading) {
                        Text("名前: \(user["name"] as? String ?? "")")
                        Text("年齢: \(String(describing: user["age"] ?? ""))")
                    }
                }
            }
            
            Text(message)
        }
        .onAppear {
            // リアルタイムリスナーを設定
            listenForUsers()
        }
    }
    
    func listenForUsers() {
        // リアルタイムリスナーを設定
        let listener = db.collection("users").addSnapshotListener { querySnapshot, error in
            if let error = error {
                message = "エラー: \(error.localizedDescription)"
                return
            }
            
            guard let snapshot = querySnapshot else {
                message = "スナップショットがありません"
                return
            }
            
            // データベースの変更を自動検知
            users = snapshot.documents.map { $0.data() }
            message = "\(users.count)人のユーザーを監視中"
        }

     // リスナーを解除する場合(重要)
     // listener.remove()
    }
}

クエリを使用したデータ取得

💡ポイント:

  • whereField()メソッドでフィルタリング条件を指定
  • 複数の条件を連結して使用可能
  • インデックスが必要な複雑なクエリはFirebaseコンソールからの設定が必要
  • クエリの実行結果は通常のgetDocuments()と同じ形式で返される
FilteredUserListView.swift
import SwiftUI
import FirebaseFirestore

struct FilteredUserListView: View {
    @State private var users: [[String: Any]] = []
    @State private var message = ""
    @State private var minAge = ""
    
    let db = Firestore.firestore()
    
    var body: some View {
        VStack {
            HStack {
                Text("最小年齢:")
                TextField("例: 20", text: $minAge)
                    .textFieldStyle(RoundedBorderTextFieldStyle())
                    .keyboardType(.numberPad)
                    .frame(width: 100)
                
                Button("検索") {
                    fetchFilteredUsers()
                }
            }
            .padding()
            
            List {
                ForEach(users, id: \.self) { user in
                    VStack(alignment: .leading) {
                        Text("名前: \(user["name"] as? String ?? "")")
                        Text("年齢: \(String(describing: user["age"] ?? ""))")
                    }
                }
            }
            
            Text(message)
        }
    }
    
    func fetchFilteredUsers() {
        guard let ageValue = Int(minAge) else {
            message = "有効な年齢を入力してください"
            return
        }
        
        // 条件付きクエリの実行:年齢でフィルタリングするクエリ
        db.collection("users")
            .whereField("age", isGreaterThanOrEqualTo: ageValue)
            // 複数条件を追加できる
            // .whereField("name", isEqualTo: "山田")
            // .orderBy("age", descending: true)
            .getDocuments { querySnapshot, error in
                if let error = error {
                    message = "エラー: \(error.localizedDescription)"
                    return
                }
                
                guard let documents = querySnapshot?.documents else {
                    message = "該当するユーザーがいません"
                    users = []
                    return
                }
                
                users = documents.map { $0.data() }
                message = "\(users.count)人のユーザーが該当しました"
            }
    }
}

6.4. データを更新する

💡ポイント:

  • updateData()メソッドは指定したフィールドのみを更新する
  • 存在しないフィールドは新しく追加される
  • ドキュメント全体の置き換えには代わりにsetData()を使用
  • updateDataはドキュメントが存在しない場合エラーになる
  • 更新時にupdatedAtフィールドを追加すると変更履歴の追跡に便利
UpdateUserView.swift
import SwiftUI
import FirebaseFirestore

struct UpdateUserView: View {
    let userId: String
    @State private var name = ""
    @State private var age = ""
    @State private var message = ""
    
    let db = Firestore.firestore()
    
    var body: some View {
        VStack {
            TextField("名前", text: $name)
                .textFieldStyle(RoundedBorderTextFieldStyle())
            
            TextField("年齢", text: $age)
                .textFieldStyle(RoundedBorderTextFieldStyle())
                .keyboardType(.numberPad)
            
            Button("更新") {
                updateUser()
            }
            
            Text(message)
        }
        .padding()
        .onAppear {
            // 現在のデータを取得
            fetchCurrentData()
        }
    }
    
    func fetchCurrentData() {
        db.collection("users").document(userId).getDocument { document, error in
            if let document = document, document.exists, let data = document.data() {
                name = data["name"] as? String ?? ""
                if let age = data["age"] as? Int {
                    self.age = String(age)
                }
            }
        }
    }
    
    func updateUser() {
        // ドキュメントを更新
        db.collection("users").document(userId).updateData([
            "name": name,
            "age": Int(age) ?? 0,
            "updatedAt": Timestamp(date: Date())  // 更新日時を記録
        ]) { error in
            if let error = error {
                message = "エラー: \(error.localizedDescription)"
            } else {
                message = "ユーザー情報を更新しました"
            }
        }
    }
}

6.5. データを削除する

💡ポイント:

  • delete()メソッドはドキュメント全体を削除する
  • 一部のフィールドだけを削除するにはupdateData()FieldValue.delete()を使用
  • 削除されたドキュメントは復元できないので確認処理を設ける
  • 関連するサブコレクションは自動的に削除されないので注意
DeleteUserView.swift
import SwiftUI
import FirebaseFirestore

struct DeleteUserView: View {
    let userId: String
    @State private var message = ""
    @Environment(\.presentationMode) var presentationMode
    
    let db = Firestore.firestore()
    
    var body: some View {
        VStack {
            Text("本当にこのユーザーを削除しますか?")
                .padding()
            
            Button("削除") {
                deleteUser()
            }
            .foregroundColor(.red)
            
            Text(message)
        }
        .padding()
    }
    
    func deleteUser() {
        // ドキュメントを削除(削除操作は取り消せないので注意)
        db.collection("users").document(userId).delete() { error in
            if let error = error {
                message = "エラー: \(error.localizedDescription)"
            } else {
                message = "ユーザーを削除しました"
                // 少し待ってから前の画面に戻る
                DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
                    presentationMode.wrappedValue.dismiss()
                }
            }
        }
        // 特定のフィールドだけを削除する場合(例)
        /*
        db.collection("users").document(userId).updateData([
            "age": FieldValue.delete()
        ])
        */
    }
}

7. まとめ

今回はFirestoreを使ったデータ保存の実装手順について記載しました。
環境を整えれば実装自体は容易にできるので、ぜひ参考にしていただければと思います。

関連記事

https://zenn.dev/jnch/articles/d02c40600b3cd8

https://zenn.dev/jnch/articles/57a170bcbb6fa6

Discussion