【iOS】アプリとFirebase Firestoreを連携する

#始めに
本ドキュメントは次の4点を目標とします。

- セキュリティを考慮したFirestore設定を行う
- アプリからFirestoreへ書き込む
- Firestoreからアプリへ読み込む
- Firestoreを監視し、アプリで表示する情報をリアルタイム更新する

執筆時の開発環境は次の通りです。

- Xcode 12.4
- Swift 5

Xcodeプロジェクトとそれに対応するFirebaseプロジェクトが作成済みであり、[iOS側のFirebase初期導入手順](https://firebase.google.com/docs/ios/setup?hl=ja)は実装済みである事が前提です。
また次のライブラリが必要です。

- FirebaseFirestore
- FirebaseFirestoreSwift

https://github.com/firebase/firebase-ios-sdk/blob/master/SwiftPackageManager.md

#実装
次の仕様を満たすサンプルを実装します。

- Firebaseにサインインし、Firestoreに日記データの入出力を行う

##Firebaseの設定
###Firestoreデータベースの作成
Firestore画面を表示し、データベースの作成をクリックします。
![データベースの作成](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4077/76658061-831f-3534-d950-0a3f3b837eda.png)
データベースの作成画面では「テストモードで開始する」を選択し、次へボタンをクリックします。
**重要：
公式ドキュメントにも記載されていますが、アプリからFirestoreにアクセスする場合、必ず「テストモード」で開始してください。
本番環境モードで開始した場合、その後セキュリティルールを全解放にしてもアプリからアクセスできませんでした。**
![テストモードで開始](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4077/49b138cb-f496-8cc9-0e40-1fab059ae0ea.png)
好きなロケーションを選択し、有効にするをクリックするとFirestoreデータベースが作成されます。
![スクリーンショット_2021-04-05_19_33_31.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4077/85143486-054a-ee05-e65a-fd4bc9f03a6a.png)

###Firestoreルールの設定
作成するデータベースは次のイメージです。
![データベースイメージ](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4077/2323f7e5-70d2-1ee3-b63f-28156fa24bf1.png)
日記コレクションに対し次のルールを設定します。

- 未サインインユーザはデータベースにアクセスできない
- 他人の日記データにはアクセスできない

Firestore画面のルールタブを選択すると、初期のルールが記述されています。
次のルールに書き換え、公開をクリックします。

```
rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
  
    match /diary {
    	match /{document} {
      	allow read, update, delete: if request.auth != null && resource.data.uid == request.auth.uid;
        allow create: if request.auth != null;
      }
    }
    
  }
}
```

##iOSアプリの実装
次の流れを実装します。

1. Firebaseにサインインする
1. 日記をFirestoreに保存する
1. Firestoreを監視し、リアルタイムに日記一覧を更新する

###サインイン機能
次の記事で紹介しています。
https://qiita.com/ICTFractal/items/52d9ffdc85f50ccba657

###日記データ構造体
FirebaseFirestoreSwiftフレームワークを使用すると、自動的にFirestoreから得られるデータを構造体にマップできます。
また構造体から直接Firestoreに書き込むことも可能になり、非常に便利です。
マップする際定型的な処理が必要になる為、まず定型処理をラップしたプロトコルを実装します。

```FirestoreDataCodable.swift
import Firebase
import FirebaseFirestoreSwift


protocol FirestoreDataCodable: Codable {
    
    var id: String? { get set }
    
    static func targetCollectionRef() -> CollectionReference
}

// MARK: - Static
extension FirestoreDataCodable {
    
    typealias ComplesionClosure = (Error?)->Void
    
    static func from(document: QueryDocumentSnapshot) -> Self? {
        do {
            return try document.data(as: Self.self)
        } catch {
            debugPrint(error.localizedDescription)
        }
        
        return nil
    }
    
    static func from(collection: QuerySnapshot) -> [Self] {
        return collection.documents.compactMap { Self.from(document: $0) }
    }
}

// MARK: - Internal
extension FirestoreDataCodable {
    
    func set(complesion: ComplesionClosure?) {
        do {
            let collectionRef = Self.targetCollectionRef()
            let documentRef = id == nil ? collectionRef.document() : collectionRef.document(id!)
            try documentRef.setData(from: self) { error in
                if let error = error {
                    debugPrint(error.localizedDescription)
                }
                complesion?(error)
            }
        } catch {
            debugPrint(error.localizedDescription)
            complesion?(error)
        }
    }
}
```

次にFirestoreDataCodableプロトコルを採用した日記データ構造体を実装します。

```DiaryModel.swift
import Firebase
import FirebaseFirestoreSwift

struct DiaryModel: FirestoreDataCodable {
    
    @DocumentID var id: String?
    
    let uid: String
    let body: String
    let title: String
    
    static func targetCollectionRef() -> CollectionReference {
        return Firestore.firestore().collection("diary")
    }
}
```

`targetCollectionRef()`は入出力を行うFirestore内の場所を返します。
`@DocumentID`を指定すると、データ取得時にドキュメントIDが格納されます。
![Firestoreと構造体の対応](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4077/46632e2c-63a8-b521-9090-c026e03c41e4.png)

###画面設計
StoryboardでViewController上にUITableViewを配置し、次の設定を行います。

- 「tableView」という名前でUITableViewのプロパティを作成
-  dataSourceをViewControllerに設定
-  delegateをViewControllerに設定

###日記保存処理
tableViewのフッターに日記保存ボタンを配置し、タップ時の保存処理を実装します。
またアプリを実行する為、tableView周りの最低限の処理を実装します。

```ViewController.swift
class ViewController: UIViewController {

    private var diary = [DiaryModel]()
}

private extension ViewController {

    func addDiary() {
        guard let uid = AccountManager.shared.uid else { return }
        DiaryModel(uid: uid, body: "本文", title: Date().debugDescription).set { (error) in
            if let error = error {
                debugPrint(error.localizedDescription)
            } else {
                debugPrint("保存しました")
            }
        }
    }
}

extension ViewController: UITableViewDataSource {
    
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return diary.count
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        var cell = tableView.dequeueReusableCell(withIdentifier: "cell")
        if cell == nil {
            cell = UITableViewCell(style: .subtitle, reuseIdentifier: "cell")
        }
        
        let data = diary[indexPath.row]
        cell?.textLabel?.text = data.title
        cell?.detailTextLabel?.text = data.body
        
        return cell!
    }
}

extension ViewController: UITableViewDelegate {
    
    func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? {
        let button = UIButton(type: .roundedRect)
        button.backgroundColor = .blue
        button.setTitleColor(.white, for: .normal)
        button.setTitle("保存", for: .normal)
        button.addAction(.init(handler: { (_) in
            self.addDiary()
        }), for: .touchUpInside)
        return button
    }
}
```

`addDiary()`で日記データをFirestoreに保存しています。
アプリを実行し、保存ボタンをタップします。
「保存しました」とログに出力されれば正常に処理が終了しています。
FirebaseコンソールからFirestoreデータを表示すると、日記データが保存されていることが確認できます。
![日記の保存確認](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4077/0ad37278-ede9-fc6f-6b86-25a3ce9246f8.png)

###Firestoreの監視とtableViewのリアルタイム更新
Firestoreから取得した日記データをtableViewに表示します。
またFirestoreを監視し、tableViewを常に最新の状態に更新します。

```ViewController.swift
import Firebase

class ViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()
        listenDiary()
    }
}

private extension ViewController {
    
    func listenDiary() {
        guard let uid = AccountManager.shared.uid else { return }
        
        DiaryModel.targetCollectionRef().whereField("uid", isEqualTo: uid).addSnapshotListener { [weak self] (snapshot, error) in
            if let error = error {
                debugPrint(error.localizedDescription)
            }
            
            if let collection = snapshot {
                self?.diary = DiaryModel.from(collection: collection)
                self?.tableView.reloadSections(.init(integer: 0), with: .automatic)
            }
        }
    }
}
```

`viewDidLoad()`で`listenDiary()`を実行し、Firestoreの監視を開始しています。
`DiaryModel.from(collection: collection)`で受信データを構造体にマップしています。
本サンプルのようにコレクションを指定して複数のドキュメントを得る場合、`snapshot`の型は`QuerySnapshot`となります。
ドキュメントIDを指定して単一のドキュメントを得る場合の`snapshot`の型は`QueryDocumentSnapshot`です。
単一ドキュメントを構造体にマップしたい場合は`DiaryModel.from(document:)`を使います。

アプリを実行し、先ほど保存した日記データが表示されることが確認できます。
また、新たに保存する度に一覧が更新されることが確認できます。
正しく監視できていない場合、`listenDiary()`コール時点でFirebaseにサインインされているかを確認してください。
![実行結果](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4077/27a0a8f3-7768-c725-12fe-310c5be61fbf.png)