FirestoreだけでSNSでよくあるリアクション機能を実装してみた【SwiftUI】

リアクション機能は、「コメント」をするよりも簡単に、「いいね」よりも豊富なフィードバックすることができる優れた機能です。
（Slackでもむやみやたらにリアクションをしてます✌️）

そんなリアクション機能を データベースが`Firestore` でSwiftUIのアプリでも実現することができたので具体的な実装方法を紹介させていただきたいと思います✊

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/171702/b6b2c2cf-dcf5-8c43-a864-38f4449f8ed7.png" width=320>

こんな感じで複数リアクションができます😺

## 前提条件
- FirebaseAuthでログインできている
- FirebaseFirestoreでデータを更新・取得できている

## リアクション機能の概要

以下のような機能を兼ねたリアクション機能を実装していきます。

1. ユーザーは投稿に対して複数種類のリアクション（例: 🥰, 🤣, 🤩）をつけられる
2. 同じリアクションの重複防止と解除機能
3. リアクションされたことを投稿者のお知らせする
3. リアクションするたびにリアルタイムでリアクションを更新
4. Firestoreのバッチ処理で一貫性のあるデータ操作を行う
5. Firestoreのキャッシュ機能を活用して通信コストを削減

## 2. 実装の全体構成

本記事では以下のアーキテクチャに基づいてリアクション機能を実装します！

1. View (SwiftUI):  
   ユーザーインターフェースを構築し、ユーザーからの操作を受け付ける
2. ViewModel:  
   Viewの状態を管理し、操作をUseCaseに委譲する
3. UseCase:  
   Firestoreとキャッシュを操作し、ロジック全体を統括する
4. Firestore:  
   データの保存、更新、削除を管理

![IMG_4106.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/171702/b829e4db-61cc-3a6c-d416-4ab3a4cb6137.jpeg)

## 3. Firestoreのデータ設計

Firestoreでは、以下の3つのコレクションでリアクションを管理します💻
- Post
- Reaction
- Notice

### 1. Postコレクション
投稿データとリアクションの集計情報を保持します🫡

```Firestoreのデータ.json
{
  "id": "post123",
  "reactions": ["smilingHeart", "laughing", "star"],
  "smilingHeartCount": 10,
  "laughingCount": 5,
  "starCount": 3,
  "createdAt": "2024-11-29T12:34:56Z"
}
```

Swift側のデータ定義
```Post.swift
struct Post: Identifiable, Codable, Hashable {
  var id: String
  var reactions: [ReactionType]
  var smillingHeartCount: Int?
  var laughingCount: Int?
  var flushedCount: Int?
  var partyingCount: Int?
  var starCount: Int?
  var thumbsUpCount: Int?
}
```

### 2. Reactionサブコレクション
投稿ごとの個別のリアクションを記録します。

```json
{
  "id": "post123_user123_smillingHeart",
  "type": "smillingHeart",
  "postId": "post123",
  "reactionUserId": "user123",
  "posterUserId": "user234",
  "createdAt": "2024-11-29T12:34:56Z"
}
```

Swift側のデータ定義
```Reaction.swift
/// ユーザが投稿したpostに対するリアクション
struct Reaction: Codable, Sendable {
  var id: String
  /// リアクションタイプ
  var type: ReactionType
  /// 投稿ID
  var postId: String
  /// リアクションした人の情報
  var reactionUserId: String
  /// 投稿者の情報
  var posterUserId: String
  var createdAt: Date
}
```

### 3. Noticeコレクション
投稿者に通知を送るためのデータを保持します。

```json
{
  "id": "post123_user123_smillingHeart",
  "targetUserId": "user123",
  "message": "user123があなたの投稿に🥰を付けました。",
  "createdAt": "2024-11-29T12:34:56Z"
}
```

```Notice.swift
struct Notice: Codable, Identifiable, Sendable {
  var id: String
  var targetUserId: String
  var message: String
  var createdAt: Date
}
```

## 4. 各層の実装詳細

### ロジック全体のフロー

以下は、リアクションの追加・削除処理を視覚的に表現したフローチャートです。Firestoreのバッチ処理を活用し、投稿データ・リアクションデータ・通知データを一括で更新または削除します。

```mermaid
flowchart TD
    Start["ユーザーがリアクションボタンをタップ"]
    CheckReaction["すでにこのリアクションをしているか確認"]
    AlreadyReaction["リアクション済み"]
    AddReaction["リアクションを追加"]
    RemoveReaction["リアクションを削除"]
    BatchAdd["バッチ処理で以下を実行: リアクション追加"]
    BatchDelete["バッチ処理で以下を実行: リアクション削除"]
    UpdatePostAdd["投稿データの更新 (リアクション数+1)"]
    UpdatePostRemove["投稿データの更新 (リアクション数-1)"]
    CreateReactionDoc["Reactionサブコレクションにドキュメント追加"]
    DeleteReactionDoc["Reactionサブコレクションのドキュメント削除"]
    CreateNotice["通知データを作成"]
    DeleteNotice["通知データを削除"]
    Complete["処理完了"]

    Start --> CheckReaction
    CheckReaction -->|Yes| AlreadyReaction
    CheckReaction -->|No| AddReaction
    AlreadyReaction --> RemoveReaction
    AddReaction --> BatchAdd
    RemoveReaction --> BatchDelete

    BatchAdd --> UpdatePostAdd
    BatchAdd --> CreateReactionDoc
    BatchAdd --> CreateNotice
    UpdatePostAdd --> Complete
    CreateReactionDoc --> Complete
    CreateNotice --> Complete

    BatchDelete --> UpdatePostRemove
    BatchDelete --> DeleteReactionDoc
    BatchDelete --> DeleteNotice
    UpdatePostRemove --> Complete
    DeleteReactionDoc --> Complete
    DeleteNotice --> Complete
```

![animated.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/171702/8e37e853-66c2-08e9-7de1-595e689d646d.gif)

---

### 1. FirestoreManager: バッチ処理

Firestoreのバッチ処理を利用し、リアクションの追加・削除を安全に行います。
（バッチ処理にすることで、今回書き込む3つの処理が一つでも失敗したら全て失敗にさせることで[原子性](https://wa3.i-3-i.info/word110351.html)を保つことができます）

#### リアクション追加の処理
```FirestoreManager.swift
    let batch = firestore.batch()
    
    let postsRef = firestore.collection("posts").document(postId)
    let reactionRef = firestore.collection("reactions").document(reactionId)
    let noticeRef = firestore.collection("users").document(userId).collection("notices").document(noticeId)

    batch.updateData(postDocumentData, forDocument: postsRef)
    batch.setData(reactionDocumentData, forDocument: reactionRef)
    batch.setData(noticeDocumentData, forDocument: noticeRef)

    try await batch.commit()
```

#### リアクション削除の処理
```FirestoreManager.swift
    let batch = firestore.batch()
    let postsRef = firestore.collection("posts").document(postId)
    let reactionRef = firestore.collection("reactions").document(reactionId)
    let noticeRef = firestore.collection("users").document(userId).collection("notices").document(noticeId)

    batch.updateData(postDocumentData, forDocument: postsRef)
    batch.deleteDocument(reactionRef)
    batch.deleteDocument(noticeRef)

    try await batch.commit()
```

---

### 2. UseCase: ビジネスロジックの統括

リアクションの追加・削除を条件によって分岐させます。

```UseCase.swift
func updateReaction(post: Post, mySelf: User, type: ReactionType) async throws -> Reaction? {
    let reactionId = "\(post.id)_\(mySelf.id)_\(type.rawValue)"
    let noticeId = "\(post.id)_\(mySelf.id)_\(type.rawValue)"

    /// リアクションをしているかどうか最新の情報を取得する
    if try await reactionRepository.alreadyReactioned(post: post, reactionId: reactionId) {
        // リアクション削除
        try await firestoreManager.batchDeleteReaction(...)
        return nil
    } else {
        // リアクション追加
        let newReaction = Reaction(...)
        try await firestoreManager.batchAddReaction(...)
        return newReaction
    }
}
```

---

### 3. ViewModel: 状態管理

ViewとUseCaseの橋渡しを行い、リアクションの状態を管理します。

なおView側で既にリアクションした情報を保持しておくために `ReactionCacheManager`を作成します。
これはアプリ起動時に自分がしたリアクション最新100件を取得しておくものです。
小れをすることによってViewで都度更新状態を取得する必要がなくなります。


```ViewModel.swift
func didTapReaction(type: ReactionType) async {
    guard !showLoading, let user = await userInfoCacheManager.cachedUser else { return }
    defer { showLoading = false }

    do {
        showLoading = true
        let newReaction = try await useCase.updateReaction(post: post, mySelf: user, type: type)
        if let newReaction {
            myReactions.append(newReaction)
        } else {
            myReactions.removeAll(where: { $0.type == type })
        }
    } catch {
        print("Error updating reaction: \(error)")
    }
}
```

---

### 4. SwiftUI: リアクションボタンのUI

Viewにボタンを追加します。Postに含まれる `reactions`をForEachで回して表示させるだけです！

```View.swift
@ViewBuilder
func reactionButton(type: ReactionType) -> some View {
       Button {
            Task { await viewModel.didTapReaction(type: type) }
        } label: {
            HStack {
            emoji.resizable().frame(width: 16, height: 16)
            Text("\(viewModel.post.reactionCount(for: type))")
        }
    }
}
```

## 5. まとめ

今回の記事では、Firestoreのバッチ処理を活用し、SwiftUIを使ったSNSの複数リアクション機能の実装手順を詳しく解説しました。この機能を構築することで、ユーザーは投稿に対して多様な感情を表現でき、投稿者への通知を通じてインタラクションを促進する仕組みを作ることができます。

Firestoreのバッチ処理を用いることで、データ操作の一貫性を保ちながら、リアクション数が増えても効率的に処理を行えます。
またFirestoreだけで完結する設計により、サーバー側の複雑なロジックを省き、メンテナンスコストを抑えることができます。

### フィードバックを歓迎します！
もしこの記事を読んで、「こういう部分がもっと知りたい」「ここは改善した方が良い」などのフィードバックがあれば、ぜひコメント欄で教えてください。また、今回の実装に興味を持った方は、ぜひ実際にコードを書いて試してみてください！新しい発見や改善点があれば、それもぜひ共有していただけると嬉しいです。

本記事が、あなたのSNSアプリ開発に役立つことを願っています！
ぜひ、この実装をベースに自分だけのカスタマイズを加え、より魅力的なアプリを作り上げてください。

僕の作った魅力的なアプリも使ってみてね👊

https://apps.apple.com/jp/app/%E6%97%85%E8%A1%8C%E6%80%9D%E3%81%84%E5%87%BA%E3%83%9E%E3%83%83%E3%83%97-%E6%97%A5%E6%9C%AC%E3%82%84%E4%B8%96%E7%95%8C%E3%81%AE%E5%9C%B0%E5%9B%B3%E3%82%92%E5%86%99%E7%9C%9F%E3%81%A7%E5%9F%8B%E3%82%81%E3%82%88%E3%81%86/id6478291625