はじめに
「-TENBIN-」という多数決アプリを作成しました!
このアプリでは画像を使いたいと思っていてやり方を探していたら、Firestore DatabaseとFirebase Storageを組み合わせると出来そうだったのでその仕組みで実装してみました!
きっと誰かの役に立つと信じて、実際のソースコードを一部ですが公開します。
イメージを掴むための参考程度にご覧ください!
最後にアプリ紹介とかアプリアイコンについて書いてあるので、良かったら最後までご覧ください!
ソースコード
-
SearchPosterViewModel.swift
-
fetchPosters()
でFirestore Databaseのデータを全件取得できます。 -
searchPosters()
はsearchTextをキーワードとしてFirestore Databaseから検索ができます。
-
-
FireStoreUploadModel.swift
-
uploadSingleImage(image: UIImage)
で画像の保存ができます。保存したら画像のフルパスを変数に格納します。 -
saveToFirestore()
でFirestore Databaseにデータを格納できます。
-
Firestore DatabaseとFirebase Storageからデータを取得する(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
}
}
Firestore DatabaseとFirebase Storageにデータを格納する(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))
}
}
}
}
}
呼び出し方 - データ取得
一部抜粋です。
データ取得箇所以外は削除してあるのでコピペでは利用できないかもしれないです。
イメージを掴むための参考としてご覧ください。
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()
}
}
}
呼び出し方 - データ格納
一部抜粋です。
データ格納箇所以外は削除してあるのでコピペでは利用できないかもしれないです。
イメージを掴むための参考としてご覧ください。
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("投稿")
}
}
アプリ紹介
このアプリでは、例えば「きのこの山 vs たけのこの里、どっちが好き?」とか「ネコ派?イヌ派?」のようなみんなの意見を聞くことができます。
是非、みなさん使ってみてください!
機能について
- ホームタブ
- 新着順・人気順に投稿を見ることができます。
- 画像をタップすることで投票ができ、画像下のバーでどちらが多数派か少数派か判断できます。
- 画像は何回もタップできるので、左に2票・右に1票とかもできます。
- 画像上にあるのはハッシュタグです。タップすることで、同じハッシュタグの投稿とハッシュタグが内容に含まれている投稿を検索できます。
- プラスタブ
- 画像は必須項目となっているのですが、投稿内容とハッシュタグは何も記載しなくてOKです。
- 虫眼鏡
- 検索欄にキーワードを入れると投稿内容かハッシュタグに一致する投稿を検索できます。
アプリアイコン
アプリ名については多数決を取るということでイメージしたのが天秤だったので素直にTENBINとしました。
アプリアイコンの陰陽マークは、そもそも陰陽マークには「完全な陰はない、完全な陽はない」ということを表しています。つまり「天地万物あらゆるものは陰と陽のバランスによって成り立っている」ということです。
なので例えば「きのこの山 vs たけのこの里」で言えば、きのこの山が好きだけど、たまにはたけのこの里も良いよね、ってことありません?
「ネコ派?イヌ派?」で言えば、ネコが好きだけど〜、でも飼うならイヌかな〜みたいなことありません?
そういう状態を表したくて、陰陽マークにしました!
さいごに
今回はTENBINで実装した、Firestore DatabaseとFirebase Storageへのデータ格納、データ取得についてのコードを公開しました。
TENBINをより良いものにしたいですし、
きっとまだまだ伸び代のあるソースコードだと思うのでコメントを頂きたいです!
ここまでご覧いただきありがとうございました!