Swift：FirestoreとcollectionViewを使った投稿一覧の取得・更新・削除処理

FirestoreでSNS的なユーザー投稿型のシステムを作る場合、以下のような仕様が必要だと思う。

- 最初に投稿一覧を降順（日付が新しい順）で表示する
- 投稿されたらホーム一覧を自動更新する（理想は自分の投稿完了じだけ自動更新）
- 投稿を削除したら投稿一覧からも自動で削除する
- いいねやコメントなどのFirestoreのフィールドバリューの更新ではリロードさせない

![ezgif-7-51a193c5d13c.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/242610/25a48c39-4e88-b640-5bc8-e53c207bf215.gif)


よりよりライブラリなどあると思うが基本的なCollectionViewの仕組みだけでやってみた。
具体的には投稿の取得/削除を`snapshot.documentChanges`と`collectionView.insertItems(at:)`/`collectionView.deleteItems(at:)`を使って行う。


##コード
```swift
import FirebaseFirestore

class ViewController: UIViewController {

 @IBOutlet weak var collectionView: UICollectionView!
 var stories: [Story] = []
 let db = Firestore.firestore()
 var storiesListener: ListenerRegistration?

 override func viewDidLoad() {
        super.viewDidLoad()
		self.setStoriesListener()
    }

func setStoriesListener() {
		self.storiesListener = self.db.collection("stories")
			.order(by: "createTime", descending: true)  // 降順で（日付が新しい順から）取ってくる
			.addSnapshotListener({ (querySnapshot, error) in
				guard let snapshot = querySnapshot else {
					print("Error while getting collection: \(error)")
					return
				}
				snapshot.documentChanges.forEach { diff in
					if (diff.type == .added) {
						let story = Story(document: diff.document)
						let newIndex = Int(diff.newIndex)
						self.stories.insert(story, at: newIndex) // 投稿を格納している配列のindexとFirestore上のindexを揃えてやる
						self.collectionView.insertItems(at: [IndexPath(item: newIndex, section: 0)])
					}
					if (diff.type == .modified) {
						// いいねやコメントなどのフィールドバリューの更新はここが呼ばれる
                        // リアルタイムでいいね数やコメント数の変化を反映したい場合は collectionView.reloadItems(at:)を使うはず
					}
					if (diff.type == .removed) {
						let oldIndex = Int(diff.oldIndex)
						self.stories.remove(at: oldIndex)
						self.collectionView.deleteItems(at: [IndexPath(item: oldIndex, section: 0)])
					}
				}
				
			})
｝

extension ViewController: UICollectionViewDataSource, UICollectionViewDelegate {
	
    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
        return self.stories.count
   }
   
	func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
		let cell = collectionView.dequeueReusableCell(withReuseIdentifier: "Cell", for: indexPath) as! StoryCell
		let story = self.stories[indexPath.row]	
        // cellに画像やラベルテキストを設定する処理			
		return cell
	}		
}
```

ちなみにcreateTimeでサーバータイムスタンプを使っていたりすると`.added`のあとに`.modefied`がすぐに呼ばれることに注意。

##リスナーをセットするタイミング
上記ではviewDidLoad()でリスナーをセットしていて、Firebaseドキュメントなどで見られるviewWillAppearではない。これはホームタブを移動して帰ってきたときに都度、再取得&描画処理が走るのを防ぎたいからだ。deinitはホームタブの遷移では呼ばれないので他の画面にいってもずっと関しをつづけることができる。

viewWillApperでリスナーをセットしてviewWillDesaperで`storiesListener?.remove（）`を呼ばないようにするとタブで切り替えて戻ってくるたびにリスナーが重複セットされるのが原因か、やっぱり都度リロードが走ってしまう。

誰か適切なやり方をしっていたら教えてほしいです🥺

##reloadData（）とinsertItem(at: Index)の違い
reloadDataを呼ぶと`collectionView(_:cellForItemAt:)が`collectionView(_:numberOfItemsInSection)`で返された回数分、0から順番に呼ばれてCellが生成される。

一方でinsertItems(at: Index)を呼ぶと、`at`で指定したIndexだけで都度`collectionView(_:cellForItemAt)`が呼ばれる。

##reloadData()じゃなんで駄目か
addSnapshotListenerで常に監視をしていれば何かしらの変更は全部拾える。しかしそれでは、1つ投稿があったりいいねやコメントがあるたびにすべてのCellが再リロードされて投稿一覧画面がチカチカしてみれたものじゃなくなる。

かといってリッスンをせずに`getDocuments`で1回だけ取得すると、今度は投稿後や削除に更新が走らずユーザーが不安になる（たいていその作業は別画面で行うはずなので、その作業のcompletionでルートのViewから辿ってホーム画面の`getPost`のようなfunctionを呼び出したり、collectionViewを更新したりは難しくて複雑そうだった）。

なのに多くの文献ではこういったやり方がほとんどなくて苦戦した。

##参考にした記事
insertやdeleteを用いたきれいな更新は、iOSやFirestoreなどで神記事を量産している @mono さんの以下記事が参考になった。
https://gist.github.com/mono0926/e27131e8e6e1cf27a0dc4b655a240350

newIndexやoldIndexの正確な理解はFirebase公式ドキュメントへ（Firebase Firestore と Cloud Firestore って別物だっけ‥？）
https://firebase.google.com/docs/reference/swift/firebasefirestore/api/reference/Classes/DocumentChange