〜SwiftUIとFirestore DatabaseとFirebase Storage〜

# はじめに
「-TENBIN-」という多数決アプリを作成しました！


このアプリでは画像を使いたいと思っていてやり方を探していたら、**Firestore Database**と**Firebase Storage**を組み合わせると出来そうだったのでその仕組みで実装してみました！


きっと誰かの役に立つと信じて、実際のソースコードを一部ですが公開します。
イメージを掴むための参考程度にご覧ください！


最後にアプリ紹介とかアプリアイコンについて書いてあるので、良かったら最後までご覧ください！

https://apps.apple.com/jp/app/tenbin/id6670417709

# ソースコード
- SearchPosterViewModel.swift
  - `fetchPosters()`でFirestore Databaseのデータを全件取得できます。
  - `searchPosters()`はsearchTextをキーワードとしてFirestore Databaseから検索ができます。

- FireStoreUploadModel.swift
  - `uploadSingleImage(image: UIImage)`で画像の保存ができます。保存したら画像のフルパスを変数に格納します。
  - `saveToFirestore()`でFirestore Databaseにデータを格納できます。



<details><summary>Firestore DatabaseとFirebase Storageからデータを取得する（SearchPosterViewModel.swift）</summary>

```SearchPosterViewModel.swift
import SwiftUI
import FirebaseFirestore
import FirebaseStorage

class SearchPosterViewModel: ObservableObject {
    @Published var postersOrderByOrderDesc: [Poster] = []
    @Published var postersOrderByTotalGoodDesc: [Poster] = []
    @Published var searchResults: [Poster] = []
    @Published var isFetching: Bool = false
    @Published var isSearching: Bool = false
    @Published var searchText: String = "" {
            didSet {
                debounceSearch()
            }
        }

    private var debounceWorkItem: DispatchWorkItem?
    
    func debounceSearch() {
        // 以前のリクエストをキャンセル
        debounceWorkItem?.cancel()

        let workItem = DispatchWorkItem { [weak self] in
            self?.searchPosters()
        }
        
        // 0.5秒遅延させて検索実行
        debounceWorkItem = workItem
        DispatchQueue.main.asyncAfter(deadline: .now() + 0.5, execute: workItem)
    }
    
    func fetchPosters() {
        DispatchQueue.main.async {
            print("DEBUG \(URL(fileURLWithPath: #file).lastPathComponent):\(#line)#\(#function) - START")
            self.isFetching = true
            
            Firestore.firestore().collection("board").addSnapshotListener { snapshot, error in
                guard let documents = snapshot?.documents else {
                    print("Error fetching documents: \(error?.localizedDescription ?? "Unknown error")")
                    return
                }
                
                // 非同期処理を開始
                Task {
                    var fetchedPosters: [Poster] = []
                    for document in documents {
                        let data = document.data()
                        let id = document.documentID
                        let contents = data["contents"] as? String ?? ""
                        let hashTags = data["hashTags"] as? [String] ?? []
                        let good1 = data["good1"] as? Int ?? 0
                        let good2 = data["good2"] as? Int ?? 0
                        let imagePath1 = data["imagePath1"] as? String ?? ""
                        let imagePath2 = data["imagePath2"] as? String ?? ""
                        let order = data["order"] as? Int ?? 0
                        
                        // 画像の取得は並行して行う
                        async let image1 = self.fetchImageUrl(forPath: imagePath1)
                        async let image2 = self.fetchImageUrl(forPath: imagePath2)
                        
                        // Poster オブジェクトを作成
                        let poster = Poster(
                            id: id,
                            contents: contents,
                            hashTags: hashTags,
                            good1: good1,
                            good2: good2,
                            imagePath1: imagePath1,
                            imagePath2: imagePath2,
                            image1: await image1,
                            image2: await image2,
                            order: order
                        )
                        
                        // 安全に並行実行するため、`fetchedPosters`に結果を追加
                        fetchedPosters.append(poster)
                    }
                    
                    // 並び替え：orderの降順
                    fetchedPosters.sort { $0.order > $1.order }
                    self.postersOrderByOrderDesc = fetchedPosters
                    // 並び替え：good1とgood2の合計の降順
                    fetchedPosters.sort { ($0.good1 + $0.good2) > ($1.good1 + $1.good2) }
                    self.postersOrderByTotalGoodDesc = fetchedPosters
                    
                    self.isFetching = false
                    print("DEBUG \(URL(fileURLWithPath: #file).lastPathComponent):\(#line)#\(#function) - END")
                }
            }
        }
    }
    
    
    func searchPosters() {
        DispatchQueue.main.async {
            print("DEBUG \(URL(fileURLWithPath: #file).lastPathComponent):\(#line)#\(#function) - START")
            guard !self.searchText.isEmpty else {
                self.searchResults = []
                return
            }
            self.isSearching = true
            
            Firestore.firestore().collection("board").getDocuments { snapshot, error in
                if let error = error {
                    print("Error getting documents: \(error)")
                    return
                }
                
                if let documents = snapshot?.documents {
                    // 半角スペースと全角スペースの両方で`searchText`を分割
                    let searchWords = self.searchText.split(usingRegex: "\\s+")
                    
                    let results = documents.filter { document in
                        let contents = document.data()["contents"] as! String
                        let hashTags = document.data()["hashTags"] as! [String]
                        
                        // `searchWords`のすべての単語が`contents`または`hashTags`に含まれるかどうかをチェック
                        return searchWords.allSatisfy { word in
                            contents.contains(word) || hashTags.contains(where: { $0.contains(word) })
                        }
                    }
                    
                    // 非同期処理を開始
                    Task {
                        var searchedPosters: [Poster] = []
                        
                        for document in results {
                            let data = document.data()
                            let id = document.documentID
                            let contents = data["contents"] as! String
                            let hashTags = data["hashTags"] as! [String]
                            let good1 = data["good1"] as! Int
                            let good2 = data["good2"] as! Int
                            let imagePath1 = data["imagePath1"] as! String
                            let imagePath2 = data["imagePath2"] as! String
                            let order = data["order"] as! Int
                            
                            // 画像の取得は並行して行う
                            async let image1 = self.fetchImageUrl(forPath: imagePath1)
                            async let image2 = self.fetchImageUrl(forPath: imagePath2)
                            
                            // Poster オブジェクトを作成
                            let poster = Poster(
                                id: id,
                                contents: contents,
                                hashTags: hashTags,
                                good1: good1,
                                good2: good2,
                                imagePath1: imagePath1,
                                imagePath2: imagePath2,
                                image1: await image1,
                                image2: await image2,
                                order: order
                            )
                            
                            // 安全に並行実行するため、`fetchedPosters`に結果を追加
                            searchedPosters.append(poster)
                        }
                        
                        self.searchResults = searchedPosters
                        self.isSearching = false
                        print("DEBUG \(URL(fileURLWithPath: #file).lastPathComponent):\(#line)#\(#function) - END")
                    }
                }
            }
        }
    }
    

    func fetchImageUrl(forPath path: String) async -> UIImage? {
        guard !path.isEmpty else {
            return nil
        }

        // まずキャッシュを確認する
        if let cachedImage = ImageCache.shared.getImage(forKey: path) {
            print("DEBUG \(URL(fileURLWithPath: #file).lastPathComponent):\(#line)#\(#function) - [path: \(path)] return cachedImage")
            return cachedImage
        }

        do {
            let storageRef = Storage.storage().reference(withPath: path)
            let url = try await storageRef.downloadURL()
            let (data, _) = try await URLSession.shared.data(from: url)

            if let image = UIImage(data: data) {
                print("DEBUG \(URL(fileURLWithPath: #file).lastPathComponent):\(#line)#\(#function) - [path: \(path)] save cache")
                // 取得した画像をキャッシュに保存
                ImageCache.shared.setImage(image, forKey: path)
                return image
            } else {
                return nil
            }
        } catch {
            print("Error fetching image: \(error.localizedDescription)")
            return nil
        }
    }
    
    func validateSearchText(_ searchText: String) -> String {
        let INVALID_CHARACTERS = [" ", "　"]
        return INVALID_CHARACTERS.contains(searchText) ? "" : searchText
    }

}
```
</details>


<details><summary>Firestore DatabaseとFirebase Storageにデータを格納する（FireStoreUploadModel.swift）</summary>

```FireStoreUploadModel.swift
import Foundation
import UIKit
import FirebaseStorage
import FirebaseFirestore

// TODO: Publishing changes from background threads is not allowed; make sure to publish values from the main thread (via operators like receive(on:)) on model updates.
class FireStoreUploadModel: ObservableObject {
    @Published var contents: String = ""
    @Published var hashTags: [String] = [""]
    @Published var image1: UIImage?
    @Published var image2: UIImage?
    @Published var imagePath1: String = ""
    @Published var imagePath2: String = ""
    @Published var isImagePicker1Presented: Bool = false
    @Published var isImagePicker2Presented: Bool = false
    @Published var isCameraActive: Bool = false
    @Published var isSaving: Bool = false
    
    // TODO: これってPoster.swiftに書くべき？
    func initialize() {
        self.contents = ""
        self.hashTags = [""]
        self.image1 = nil
        self.image2 = nil
        self.imagePath1 = ""
        self.imagePath2 = ""
        self.isImagePicker1Presented = false
        self.isImagePicker2Presented = false
        self.isCameraActive = false
    }
    
    func uploadImage() async {
        var imagePath1: String?
        var imagePath2: String?
        
        self.isSaving = true
        
        await withTaskGroup(of: (Int, String?).self) { taskGroup in
            if let image1 = image1 {
                taskGroup.addTask {
                    let path = await self.uploadSingleImage(image: image1)
                    return (0, path)
                }
            }
            
            if let image2 = image2 {
                taskGroup.addTask {
                    let path = await self.uploadSingleImage(image: image2)
                    return (1, path)
                }
            }
            
            for await (index, path) in taskGroup {
                if index == 0 {
                    imagePath1 = path
                } else if index == 1 {
                    imagePath2 = path
                }
            }
        }
        
        if let imagePath1 = imagePath1, let imagePath2 = imagePath2 {
            DispatchQueue.main.async {
                self.imagePath1 = imagePath1
                self.imagePath2 = imagePath2
                Task {
                    await self.saveToFirestore()
                }
            }
        }
    }
    
    private func uploadSingleImage(image: UIImage) async -> String? {
        guard let data = image.jpegData(compressionQuality: 0.5) ?? image.pngData() else { return nil }
        let imageRef = Storage.storage().reference().child("image/\(UUID().uuidString)")
        do {
            let _ = try await imageRef.putDataAsync(data, metadata: nil)
            return imageRef.fullPath
        } catch {
            print("Error uploading image: \(error)")
            return nil
        }
    }
    
    func saveToFirestore() async {
        let db = Firestore.firestore().collection("board")
        do {
            let count = try await db.count.getAggregation(source: .server).count.intValue
            try await db.document().setData(
                [
                    "contents": self.contents,
                    "hashTags": self.hashTags.filter{!$0.isEmpty},
                    "imagePath1": self.imagePath1,
                    "imagePath2": self.imagePath2,
                    "good1" : 0,
                    "good2" : 0,
                    "order": count + 1
                ]
            )
            DispatchQueue.main.async {
                self.initialize()
                self.isSaving = false
                print("DEBUG \(URL(fileURLWithPath: #file).lastPathComponent):\(#line)#\(#function) - Firestoreへの保存が完了しました")
            }
        } catch {
            print("Error saving image path to Firestore: \(error)")
        }
    }
}

extension StorageReference {
    func putDataAsync(_ uploadData: Data, metadata: StorageMetadata?) async throws -> StorageMetadata {
        try await withCheckedThrowingContinuation { continuation in
            putData(uploadData, metadata: metadata) { metadata, error in
                if let error = error {
                    continuation.resume(throwing: error)
                } else if let metadata = metadata {
                    continuation.resume(returning: metadata)
                } else {
                    continuation.resume(throwing: NSError(domain: "FirebaseStorageError", code: -1, userInfo: nil))
                }
            }
        }
    }
}
```
</details>


<details><summary>呼び出し方 - データ取得</summary>

一部抜粋です。
データ取得箇所以外は削除してあるのでコピペでは利用できないかもしれないです。
イメージを掴むための参考としてご覧ください。

```MainBoardView.swift
import SwiftUI
import FirebaseFirestore
import FirebaseStorage


struct MainBoardView: View {
    @StateObject private var viewModel = SearchPosterViewModel()
        
    var body: some View {
        ScrollView {
            ForEach(viewModel.postersOrderByOrderDesc, id: \.self) { poster in
                PosterCardView(poster: poster)
            }
        }
        .refreshable {
            viewModel.fetchPosters()
        }
        // taskじゃなくてonAppearだとクラッシュする
        .task {
            viewModel.fetchPosters()
        }
    }
}

```
</details>


<details><summary>呼び出し方 - データ格納</summary>

一部抜粋です。
データ格納箇所以外は削除してあるのでコピペでは利用できないかもしれないです。
イメージを掴むための参考としてご覧ください。

```CreatePosterView.swift
import SwiftUI


struct CreatePosterView: View {
    @StateObject private var viewModel = FireStoreUploadModel()

    var body: some View {
        // ここでviewModel.contentsとかにデータを格納します
        TextField("", text: $viewModel.contents, axis: .vertical)
            .textFieldStyle(RoundedBorderTextFieldStyle())

        // 投稿ボタンを押下するとデータが格納できます
        Button(action: {
            Task {
                await viewModel.uploadImage()
            }
        }) {
            Text("投稿")
    }
}
```
</details>



# アプリ紹介
このアプリでは、例えば「きのこの山 vs たけのこの里、どっちが好き？」とか「ネコ派？イヌ派？」のようなみんなの意見を聞くことができます。
是非、みなさん使ってみてください！


# 機能について
- ホームタブ
  - 新着順・人気順に投稿を見ることができます。
  - 画像をタップすることで投票ができ、画像下のバーでどちらが多数派か少数派か判断できます。
  - 画像は何回もタップできるので、左に2票・右に1票とかもできます。
  - 画像上にあるのはハッシュタグです。タップすることで、同じハッシュタグの投稿とハッシュタグが内容に含まれている投稿を検索できます。
- プラスタブ
  - 画像は必須項目となっているのですが、投稿内容とハッシュタグは何も記載しなくてOKです。
- 虫眼鏡
  - 検索欄にキーワードを入れると投稿内容かハッシュタグに一致する投稿を検索できます。

# アプリアイコン
![‎AppIcon.‎001.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3808248/ba5ad53c-0edc-5bd6-c227-cc2bf0497783.png)

アプリ名については多数決を取るということでイメージしたのが天秤だったので素直に**TENBIN**としました。


アプリアイコンの陰陽マークは、そもそも陰陽マークには「完全な陰はない、完全な陽はない」ということを表しています。つまり「天地万物あらゆるものは陰と陽のバランスによって成り立っている」ということです。


なので例えば「きのこの山 vs たけのこの里」で言えば、きのこの山が好きだけど、たまにはたけのこの里も良いよね、ってことありません？
「ネコ派？イヌ派？」で言えば、ネコが好きだけど〜、でも飼うならイヌかな〜みたいなことありません？


そういう状態を表したくて、陰陽マークにしました！


# さいごに
今回は**TENBIN**で実装した、Firestore DatabaseとFirebase Storageへのデータ格納、データ取得についてのコードを公開しました。


**TENBIN**をより良いものにしたいですし、
きっとまだまだ伸び代のあるソースコードだと思うのでコメントを頂きたいです！


ここまでご覧いただきありがとうございました！