チャット機能を部分的にレベルアップさせる

この記事は[Firebase Advent Calendar 2018](https://qiita.com/advent-calendar/2018/firebase)の15日目の記事です.

# この記事に書いてあること
* `firestore` を利用したチャットについて
* プラットフォーム → iOS (Web, Androidでも参考にはなるかと)
* 未読/既読表示
* 送信ステータス表示 & 再送

# はじめに
最近はかなり `firebase` を利用する人が増えてきましたね :clap:
自分も今年の夏くらいから利用してまして, 会社(※スタートアップ)のプロダクトもバックエンドはほぼすべて `firebase(GCP)` にしてます.

今回はその中でも, `firestore` について少し知見を共有させていただこうと思います.
世の中に公開したアプリでチャット機能を載せたのですが, その中で考慮したこととその実装例について書いていきます.
今ではこのチャット機能もだいぶチュートリアル的な記事が増えて, 最初のハードルは越えやすくなっているかなと思うので, また一つ先に進めるための参考になれば幸いです.

# 実装するチャットの要件
* 一応複数人にスケールできる設計にする
* 未読/既読を表示する
* 自分がメッセージを送信した直後に表示されるようにする( `firestore` に届いてからではなく, **すぐに** )
* 送信に失敗したことを表示する(オフライン時や弱電界時に, メッセージのタイムアウトに引っかかるなど)
* 自動再送

※ 以下やらないこと

* 認証
* ルーム画面の実装
* ページング(やり取りするメッセージ数が100前後の想定)
* 更新・削除
* ユーザによる手動再送
* 細かいエラーハンドリング

# 環境
* `Xcode 10.1`
* `Swift 4.2.1`
* `Cocoapods 1.6.0.beta.2`
* `firebase-ios-sdk 5.13.0` (執筆時点で最新の `5.14.0` だと[issues/2177](https://github.com/firebase/firebase-ios-sdk/issues/2177)に引っかかってしまった...)

# Collection - Document構成
手抜きですみません :bow:
今回はルームまで突っ込まないので表示しそうな情報を置いてます.

| ルーム( `/rooms` ) | メッセージ( `/rooms/chatMessages` ) |
|:---:|:---:|
| <img width="500" alt="Screen Shot 2018-12-15 at 16.44.36.png" src="https://qiita-image-store.s3.amazonaws.com/0/61124/8b03fd7c-d4ab-9138-82df-d41c206afaa3.png"> | <img width="500" alt="Screen Shot 2018-12-15 at 16.45.19.png" src="https://qiita-image-store.s3.amazonaws.com/0/61124/14187c4e-6cf0-26a1-5d3b-036d366172e3.png"> |


# ソースコード & デモ
https://github.com/gates1de/ChatSandbox


# 実装説明
全部書こうとすると長くなるので, `firestore` に焦点を当ててTips的な部分だけ書きます.
_後, ViewModelに通信処理書いてる部分とかはスルーして頂ければと...ViewControllerだけだとやばかったので笑_

## 未読/既読表示
ここまでは結構やったことがある人が多いかもしれないのでササッと :runner: 

### 基本方針
* チャットルームに入ってメッセージを取得したとき, 「相手のメッセージかつ未読のメッセージ」を保持
* `Firestore.firestore().batch()` を利用して一気に既読更新リクエスト

この方針だと, 画面表示外のメッセージを既読にされる可能性もありますが, ユーザにとっては違和感ないんじゃないかと思います :bulb: (プロダクトの要件次第ですかね)

### メッセージ受信側の処理
※ 一部書き換えて表示しています.

```swift:ChatSandbox/ChatViewModel.swift
Firestore.firestore()
    .collection(Room.collectionName)
    .document(roomId)
    .collection(ChatMessage.collectionName)
    .getDocuments() { querySnapshot, error in
        ...

        let messageId   = documentSnapshot.documentID
        let messageData = documentSnapshot.data()
        guard var newMessage = ChatMessage.initialize(id: messageId, json: messageData) else {
            continue
        }

        ...

        // 相手のメッセージ かつ 未読のメッセージを保持するための配列
        var unreadPartnerMessageList: [ChatMessage] = []

        ...

        if newMessage.userId != self.myUserId && !newMessage.isReadUserId.contains(self.myUserId) {
            newMessage.isReadUserId.append(self.myUserId)
            unreadPartnerMessageList.append(newMessage)
        }

        ...

        resetUnreadCount(unreadOtherMessageList)

        ...
    }

func resetUnreadCount(targetMessageList: [ChatMessage]) {
    guard let messageCollectionRef = self.messageCollectionRef, !targetMessageList.isEmpty else {
        return
    }

    let batch = FirestoreManager.shared.db.batch()

    for message in targetMessageList {
        guard let messageId = message.id else { continue }
        batch.updateData(
            [ ChatMessage.CodingKeys.isReadUserId.rawValue: FieldValue.arrayUnion([self.myUserId]) ],
            forDocument: messageCollectionRef.document(messageId)
        )
    }

    batch.commit { error in
        if let error = error { print(error) }
    }
}

```
基本方針通りにやるとこんな感じで, 特に難しい処理はしていないのですが, 便利なものがあるので紹介します.

```swift
[ ChatMessage.CodingKeys.isReadUserId.rawValue: FieldValue.arrayUnion([self.myUserId]) ]
```

この処理は, `document` 上にある配列データを結合しています.
配列ごと更新せず, もし他の人が既読更新処理をおこなったとしても上書きすることなく, この配列で設定した値のみ追加してくれます.
すでに同じ値が入っていても上書きなので, 二重登録の心配もないです.


### メッセージ送信側の処理
```swift:ChatSandbox/ChatViewModel.swift
Firestore.firestore()
    .collection(Room.collectionName)
    .document(roomId)
    .collection(ChatMessage.collectionName)
    .addSnapshotListener(includeMetadataChanges: true) { snapshot, error in

        ...

        // 更新されたデータがあるかどうかのフラグ
        var isExistChangedData = false

        // 変更メッセージの取得
        for documentChange in snapshot.documentChanges {

            // 無駄な更新を避けるために, 既に取得してあるメッセージ(oldMessage)と変更後のメッセージ(changedMessage)に差分がある場合のみ, 保持するフラグを有効にする
            if oldMessage.isDiff(chatMessage: changedMessage) {
                isExistChangedData = true
            }

            // 各種変更処理(ここで新しい isReadUserId が入る) → 保持

            ...

        }

        // 新規のメッセージと更新後のメッセージをUIに反映する
        self.updateMessageCompletion?(newMessageCellViewModelList, changedMessageCellViewModelList)
    }
```
何の特徴もない処理ですが笑, これで既読マークが表示されます.


## 送信ステータス表示 & 再送
実質この記事で一番伝えたい部分です.
結構このケースで考慮漏れありそうだなと思って記事を書こうと決心しました :sweat_smile: 

(_以下の内容は, 前提としてオフラインデータを有効にしている [`firestore` の `PersistenceEnabled` オプションを有効にしている] 場合に限ります._)

少々主観が入りますが, それなりに有名なチャット系のアプリは **「送信に失敗したことがわかる & 後で再送できるし削除もできる」** という要件が盛り込まれているように思います.
最悪削除は目を瞑るとして, 送信の失敗表示と再送機能が入っていないと, UXが悪いと思われることも少なくない世の中になってきているのではないかと :sweat: (送ったように見えて実は送れてなかった, は最悪ですね...)

`firestore` の場合, 通常の書き込みではローカルに書き込まれただけでも成功とみなされる仕組みになっているので, サーバに到達したことを判定しなければなりません.
`runTransaction` を使えばオフライン時は書き込まれないので変則的ですが, オンラインになるまで送信できないとなるとユーザが待機状態になってしまう可能性があるため, 場合によっては避けたい状況です.

ちょっと長くなりましたが対応方法は様々あるので, 今回は一例を紹介させていただく形にして「私はこんな対応してますよ〜」のようなご意見がもしありましたらご教示頂けますと幸いです :bow: 


### メッセージ送信側の処理
```swift:ChatSandbox/ChatViewModel.swift
Firestore.firestore()
    .collection(Room.collectionName)
    .document(roomId)
    .collection(ChatMessage.collectionName)
    .addSnapshotListener(includeMetadataChanges: true) { snapshot, error in

        ...
        // 新規メッセージの取得
        for document in snapshot.documents {

            guard var newMessage = ChatMessage.initialize(id: messageId, json: document.data()) else {
                continue
            }

            // まだ送信済みになっていないメッセージの場合
            if newMessage.isSent == nil || newMessage.isSent != true {
                // 書き込み待ち以外は送信済みとみなす
                newMessage.isSent = !document.metadata.hasPendingWrites
                notSendMessageList.updateValue(newMessage.isSent, forKey: messageId)
            }

            ...

        }

        ...

        // 送信ステータス更新処理
        self.updateMessageIsSent(targetList: notSendMessageList)
    }
```
知っている方も多いかもしれませんが, `QueryDocumentSnapshot.metadata` ([SnapshotMetadata](https://firebase.google.com/docs/reference/swift/firebasefirestore/api/reference/Classes/SnapshotMetadata)) には `hasPendingWrites` というのがあるので, このフラグからサーバに到達したことを判定します.

これ使うだけなんですけど...ちょっとハマりポイントがありまして, 他にも `isFromCache` というフラグがあります.

```
Returns YES if the snapshot was created from cached data rather than guaranteed up-to-date server data.
If your listener has opted into metadata updates (via includeMetadataChanges:YES)
you will receive another snapshot with isFromCache equal to NO once the client has received up-to-date data from the backend.
```

ドキュメントを見る限りでは, 今回のケースで言えばサーバに到達していなければ `false` になると捉えて, 厳密にサーバに到達したことを判定するならこのフラグも見る必要がありそうだと思って何度も検証したのですが...値がブレブレだったんですよね... `true` にも `false` にもなるという...
一旦こいつは見ずに, `hasPendingWrites` だけに着目しました.

結局, オフライン時には `hasPendingWrites` は `true` になり, オンライン復帰してデータがサーバに到達すると `false` に変わるのでそこをキャッチして再送処理が完了していることを表示することができました.

ベータ版だからかは不明ですが, このあたりの細かい挙動は少々怪しい感じです :bulb:

また, 今回のようにメッセージの状態を管理する場合, `addSnapshotListener` 内部の処理が高頻度に発生しますので, 差分更新処理はポイントになりそうです.
この記事で作成したアプリではあまり考慮していない部分ですが, 本番投入する際にはそこも気にかけて頂ければと思います.


# おわりに
今回はよく実装する内容ではあるかもしれませんが, 意外に記事として見かけない実装かなと思って書いてみました.
何にせよ `firebase` を使いこなせる人が増えれば嬉しいですし, 結果的にプロダクト開発に拍車がかかって業界全体のボトムアップに繋がり, ユーザが使いやすいサービスを爆速で提供していけるようになったら幸いです.

今後も何かしら面白そうな事例が出てきたら共有させて頂きます〜 :bow: