1. はじめに
皆様お疲れ様です。iOS AdventCalendarの17日目を担当させて頂きます、fumiyasac(Fumiya Sakai)と申します。何卒よろしくお願い致します。今週初めに別のAdventCalendarにて、SwiftUIで作る「Drag処理を利用したCarousel型UI」と「Pinterest風GridレイアウトUI」の実装例とポイントまとめという記事も書きましたので、こちらもご覧頂けますと嬉しく思います🙇♂️
以前にお仕事の中でも、Firebaseの機能を活用したアプリ内施策やアプリ内機能を実装する機会も何度かあり、FirebaseについてもSwift Concurrencyにも対応した事で、従来までの処理がasync/awaitを併用してよりシンプルな形で実装できる様になった点は、個人的にも心強く嬉しく思いました。
今回はいつものUI実装関連のTipsとは少し趣向を変えて、Firebaseの機能の中で、これまでの実務でも利用経験がある
- Firebase Realtime Database
- Firebase Storage
の2つをピックアップして、async/awaitを活用する処理の事例をイメージもまじえて簡単にご紹介できればと思います。
【FireStoreにおけるasync/await処理を活用した処理の参考記事】
本記事では割愛していますが、FireStoreについても勿論async/awaitへ対応がなされています。実際の処理イメージ等を掴む際には下記の資料や動画等が参考になると思います。
- FirestoreがSwiftのasync/awaitに対応したので試してみた
- FirestoreでCodable・Async/Awaitが利用できるようになった
- Using async/await with Firebase(※解説動画)
- Firebase Firestore with Async/Await in Swift 5.5 | Swift Concurrency(※解説動画)
また、こちらはFirebase Realtime DatabaseとFirestoreの特徴と相違点について解説している記事になります。
【今回の解説記事で想定している構成】
本記事で想定している構成は下記の様な形となります。
これまでの実務でも、Firebase Realtime DatabaseやFirebase Storageを利用する処理を 「RepositoryクラスないしはUseCaseクラス内で定義し、そのクラスをViewModelクラスないしはPresenterクラスで利用する形」 をとっていました。
ViewModelクラスないしはPresenterクラス内の処理では、画面表示に必要な値の取得や適切な形へのasync/awaitを前提とした処理にしつつも、画面表示に関連する部分については、元々RxSwiftやCombineを利用していた経緯があったこともあり、その点も考慮した形に実装しています。(もしRxSwiftやCombineを利用していないViewModelの場合には、async/awaitの処理だけでも完結できると思います。)
また、後述する処理を利用する様な画面のイメージ概要は下記の様な形となります(今回は画面要素に関する実装は割愛しています)。
2. Firebase Realtime Databaseでasync/awaitを活用する処理の事例&実装例
Firebase SDKがほぼSwiftに対応したことによって、Codable
・async/await
等Swiftが持つ強力な機能が利用可能になりました。特にFirebase Realtime Databaseから取得するデータについては、従来までは[String: Any]
型で返却されていたため、DictionaryのKey値に対応する値を分解してEntityクラスや構造体等へ変換する必要がありましたが、現在ではFirebaseDatabaseSwift
をインポートすることにより、Codableをはじめとした機能を利用することでシンプルにすることができ、またasync/awaitベースの処理を活用することで全体的にコールバックで煩雑になりがちだった処理もシンプルになります。
ここで紹介している処理は、データベース内に登録されている全データの一覧を取得するだけなので、async/awaitベースのgetData()を利用し、取得できたデータをCodableに準拠した構造体にマッピングする様な形になります。
※ Firebase Realtime DatabaseではgatData()
メソッドの他にも、下記のものがasync/awaitに対応しています。
- setValue(_:)
- setValue(_:andPriority:)
- removeValue()
- setPriority(_:)
- updateChildValues(_:)
- observeSingleEventAndPreviousSiblingKey(of:)
- onDisconnectSetValue(_:)
- onDisconnectSetValue(_:andPriority:)
- onDisconnectRemoveValue()
- onDisconnectUpdateChildValues(_:)
- cancelDisconnectOperations()
- runTransactionBlock(_:)
2-1. 一覧データ取得処理をするUseCaseの実装例
① Key値をマッピングする構造体で利用しない場合:
【Shop用DB内登録データ例】
下記の様な形で『Key-Value Object』が登録されている。
https://(Shop用DB).firebaseio.com/
- shops
- 0
- id: 1000001
- name: "美味しいイタリアンのお店"
- shop_image_url: "(お店の画像URL)"
- updated_at: "2022-10-22T12:00:00+09:00"
- foods
- 0
- id: "1000001_main_food"
- image_url: "(イチオシ商品画像URL)"
- uploaded_user: "user000001"
- 1
- id: "1000001_sub1_food"
- image_url: "(その他商品画像URLその1)"
- uploaded_user: "user000001"
- 2
- id: "1000001_sub2_food"
- image_url: "(その他商品画像URLその2)"
- uploaded_user: "user000001"
- 3
- id: "1000001_sub3_food"
- image_url: "(その他商品画像URLその3)"
- uploaded_user: "user000001"
- 4
- id: "1000001_sub4_food"
- image_url: "(その他商品画像URLその4)"
- uploaded_user: "user000001"
・・・(この様なデータが以後続く)・・・
【Shop用DB内データを取得するGetShopsUseCase】
import Foundation
import FirebaseDatabase
import FirebaseDatabaseSwift
protocol GetShopsUseCase {
func getAll() async throws -> [Shop]
}
final class GetShopsUseCaseImpl: GetShopsUseCase {
// MARK: - typealias
typealias keyValueObject = [Shop]
// MARK: - Function
func getAll() async throws -> [Shop] {
let childName = "shops"
// 👉 getData()メソッドを利用してasync/awaitをベースにしたデータ取得処理をする
let snapshot = try await RealtimeDatabaseManager.shared.storyMonitorShopsReference.child(childName).getData()
// 👉 data(as: keyValueObject.self)メソッドを利用してAPIレスポンスをCodableでマッピングする際と同様なイメージで処理が可能
// ※ FirebaseDatabaseSwiftをimportする必要があります。
guard let shops = try? snapshot.data(as: keyValueObject.self) else {
return []
}
return shops
}
}
// MARK: - Struct (Shop)
struct Shop: Codable {
let shopID: Int
let name: String
let shopImageUrl: URL?
let updatedAt: String
let foodImages: [FoodImage]
enum CodingKeys: String, CodingKey {
case shopID = "id"
case name
case shopImageUrl = "shop_image_url"
case updatedAt = "updated_at"
case foodImages = "foods"
}
}
// MARK: - Struct (FoodImage)
struct FoodImage: Codable {
let id: String
let uploadedUser: String
let imageUrl: URL?
enum CodingKeys: String, CodingKey {
case id
case uploadedUser = "uploaded_user"
case imageUrl = "image_url"
}
}
② Key値をマッピングする構造体で利用する場合:
【User用DB内登録データ例】
下記の様な形で『Key-Value Object』が登録されている。
https://(User用DB).firebaseio.com/
- users
- 1000001 (👉 この部分がUserのIDとなっている点に注意!)
- name: "ユーザー1号"
- user_rank: "SSS"
- available_point: 18000
- avatar_image_url: "(アバター画像URL)"
- created_at: "2022-10-22T12:00:00+09:00"
- 1000002
- name: "ユーザー2号"
- user_rank: "A"
- available_point: 750
- avatar_image_url: "(アバター画像URL)"
- created_at: "2022-10-22T12:00:00+09:00"
- 1000003
- name: "ユーザー3号"
- user_rank: "SS"
- available_point: 1200
- avatar_image_url: "(アバター画像URL)"
- created_at: "2022-10-22T12:00:00+09:00"
・・・(この様なデータが以後続く)・・・
【User用DB内データを取得するGetUsersUseCase】
import Foundation
import FirebaseDatabase
import FirebaseDatabaseSwift
protocol GetUsersUseCase {
func getAll() async throws -> [User]
}
final class GetUsersUseCaseImpl: GetUsersUseCase {
// MARK: - typealias
typealias keyValueObject = [String: UserInformation]
// MARK: - Function
func getAll() async throws -> [User] {
let childName = "users"
// 👉 getData()メソッドを利用してasync/awaitをベースにしたデータ取得処理をする
let snapshot = try await RealtimeDatabaseManager.shared.usersReference.child(childName).getData()
// 👉 data(as: keyValueObject.self)メソッドを利用してAPIレスポンスをCodableでマッピングする際と同様なイメージで処理が可能
// ※ FirebaseDatabaseSwiftをimportする必要があります。
guard let dictionary = try? snapshot.data(as: keyValueObject.self) else {
return []
}
// 👉 key部分がユーザーID文字列なのでその点に注意する
let targetUsers = dictionary.map { (userID, userInformation) in
User(targetUserID: Int(userID) ?? 0, userInformation: userInformation)
}
return targetUsers
}
}
// MARK: - Struct (User)
struct User {
let targetUserID: Int
let name: String
let userRank: String
let availablePoint: Int
let avatarImageUrl: URL?
let createdAt: String
init(targetUserID: Int, userInformation: UserInformation) {
self.targetUserID = targetUserID
self.name = userInformation.name
self.userRank = userInformation.userRank
self.availablePoint = userInformation.availablePoint
self.avatarImageUrl = userInformation.avatarImageUrl
self.createdAt = userInformation.createdAt
}
}
// MARK: - Struct (UserInformation)
// MEMO: この構造体は取得できたsnapshotにおける、Dictionaryのvalueに該当する部分
struct UserInformation: Codable {
let name: String
let userRank: String
let availablePoint: Int
let avatarImageUrl: URL?
let createdAt: String
enum CodingKeys: String, CodingKey {
case name
case userRank = "user_rank"
case availablePoint = "available_point"
case avatarImageUrl = "avatar_image_url"
case createdAt = "created_at"
}
}
2-2. 画面表示とUseCase処理をつなぐViewModelの実装例
※ここでは前述したGetShopsUseCase.swift
を利用する処理のみご紹介します。
【データ取得処理実行から画面への反映までの流れ】
- ViewControllerから、適切なタイミングでデータ取得リクエストを実行する
👉 RxSwiftの場合はviewModel.inputs.initialFetchTrigger.onNext(())
を実行する。
👉 Combineの場合はviewModel.inputs.initialFetchTrigger.send()
を実行する。 - ViewModel内の
initialFetchTrigger
に連動して、async/awaitベースのgetShopsFromFirebaseDatabase()
による一覧データ取得処理が実行される。 - 一覧データ取得処理結果に応じて、
ShopsViewModelOutputs
の値が更新される。
👉 RxSwiftの場合は_shops: BehaviorRelay<[Shop]>
と_requestStatus: BehaviorRelay<RequestState>
の値が更新される。
👉 Combineの場合は@Published private var _shops: [Shop]
と@Published private var _requestStatus: RequestState
の値が更新される。 - RxSwift・Combineの場合共に
viewModel.outputs.shops
とviewModel.outputs.requestStatus
の値が更新されるので、ViewController側に定義されたUIへの判定処理等が実行される。
【その1. RxSwiftを利用したViewModelの処理】
import Foundation
import RxSwift
import RxCocoa
enum RequestState {
case none
case requesting
case success
case error
}
protocol ShopsViewModelInputs {
// 初回のデータ取得をViewModelへ伝える
var initialFetchTrigger: PublishSubject<Void> { get }
}
protocol ShopsViewModelOutputs {
// Firebase Realtime Databaseから取得した表示用データを格納する
var shops: Observable<[Shop]> { get }
// 取得処理の実行結果を格納する
var requestStatus: Observable<RequestState> { get }
}
protocol ShopsViewModelType {
var inputs: ShopsViewModelInputs { get }
var outputs: ShopsViewModelOutputs { get }
}
final class ShopsViewModel: ShopsViewModelInputs, ShopsViewModelOutputs, ShopsViewModelType {
var inputs: ShopsViewModelInputs { return self }
var outputs: ShopsViewModelOutputs { return self }
// MARK: - Properties (for ShopsViewModelInputs)
let initialFetchTrigger: PublishSubject<Void> = PublishSubject<Void>()
// MARK: - Properties (for ShopsViewModelOutputs)
var shops: Observable<[Shop]> {
return _shops.asObservable()
}
var requestStatus: Observable<RequestState> {
return _requestStatus.asObservable()
}
// MARK: - Properties
private let disposeBag = DisposeBag()
// MEMO: 中継地点となるBehaviorRelayの変数(Outputの変数を生成するための「つなぎ」のような役割)
// → BehaviorRelayの変化が起こったらObservableに変換されてOutputに流れてくる
private let _shops: BehaviorRelay<[Shop]> = BehaviorRelay<[Shop]>(value: [])
private let _requestStatus: BehaviorRelay<RequestState> = BehaviorRelay<RequestState>(value: .none)
// MEMO: このViewModelで利用するUseCase(※PropertyWrapperを利用したDI)
// 参考: https://qiita.com/fumiyasac@github/items/549e10af41ce91cdfbd1
@Dependencies.Inject(Dependencies.Name(rawValue: "GetShopsUseCase")) private var getShopsUseCase: GetShopsUseCase
// MARK: - Initializer
init() {
// ViewModel側の処理実行トリガーと連結させる
initialFetchTrigger
.subscribe(
onNext: { [weak self] in
guard let self = self else { return }
self.getShopsFromFirebaseDatabase()
}
)
.disposed(by: disposeBag)
}
// MARK: - Private Function
private func getShopsFromFirebaseDatabase() {
Task { @MainActor in
self._requestStatus.accept(.requesting)
do {
// 👉 async/awaitベースの処理で必要な値を取得し、その後BehaviorRelayで定義した値を更新する
let shops = try await self.getShopsUseCase.getAll()
self._shops.accept(shops)
self._requestStatus.accept(.success)
} catch let error {
print("Get All Shops Error: " + error.localizedDescription)
self._requestStatus.accept(.error)
}
}
}
}
【その2. Combineを利用したViewModelの処理】
import Foundation
import Combine
enum RequestState {
case none
case requesting
case success
case error
}
protocol ShopsViewModelInputs {
// 初回のデータ取得をViewModelへ伝える
var initialFetchTrigger: PassthroughSubject<Void, Never> { get }
}
protocol ShopsViewModelOutputs {
// Firebase Realtime Databaseから取得した表示用データを格納する
var shops: AnyPublisher<[Shop], Never> { get }
// 取得処理の実行結果を格納する
var requestStatus: AnyPublisher<RequestState, Never> { get }
}
protocol ShopsViewModelType {
var inputs: ShopsViewModelInputs { get }
var outputs: ShopsViewModelOutputs { get }
}
final class ShopsViewModel: ShopsViewModelInputs, ShopsViewModelOutputs, ShopsViewModelType {
var inputs: ShopsViewModelInputs { return self }
var outputs: ShopsViewModelOutputs { return self }
// MARK: - Properties (for ShopsViewModelInputs)
let initialFetchTrigger = PassthroughSubject<Void, Never>()
// MARK: - Properties (for ShopsViewModelOutputs)
var shops: AnyPublisher<[Shop], Never> {
return $_shops.eraseToAnyPublisher()
}
var requestStatus: AnyPublisher<RequestState, Never> {
return $_requestStatus.eraseToAnyPublisher()
}
// MARK: - Properties
private var cancellables: [AnyCancellable] = []
// MEMO: 中継地点となる@Publishedの変数(Outputの変数を生成するための「つなぎ」のような役割)
// → @Publishedの変化が起こったらObservableに変換されてOutputに流れてくる
@Published private var _shops: [Shop] = []
@Published private var _requestStatus: RequestState = .none
// MEMO: このViewModelで利用するUseCase(※PropertyWrapperを利用したDI)
// 参考: https://qiita.com/fumiyasac@github/items/549e10af41ce91cdfbd1
@Dependencies.Inject(Dependencies.Name(rawValue: "GetShopsUseCase")) private var getShopsUseCase: GetShopsUseCase
// MARK: - Initializer
init() {
// ViewModel側の処理実行トリガーと連結させる
initialFetchTrigger
.sink(
receiveValue: { [weak self] in
guard let self = self else { return }
self.getShopsFromFirebaseDatabase()
}
)
.store(in: &cancellables)
}
// MARK: - Private Function
private func getShopsFromFirebaseDatabase() {
Task { @MainActor in
self._requestStatus = .requesting
do {
// 👉 async/awaitベースの処理で必要な値を取得し、その後BehaviorRelayで定義した値を更新する
let shops = try await self.getShopsUseCase.getAll()
self._shops = shops
self._requestStatus = .success
} catch let error {
print("Get All Shops Error: " + error.localizedDescription)
self._requestStatus = .error
}
}
}
}
3. Firebase Storageでasync/awaitを活用する処理の事例&実装例
次に、画像投稿画面で選択された複数枚の画像を一括でかつ順番を担保した状態でアップロード処理を実行する場合を考えてみましょう。前述したFirebase Realtime Databaseと同様にFirebase Storageの処理についてもasync/awaitベースの処理を活用することができますので、今回の様な仕様ではputDataAsync(_:metadata:)を利用する方針とします。この処理を実行するためのUseCaseクラスのポイントをまとめると下記の様になるかと思います。
3-1. 複数枚の画像データアップロード処理をするUseCaseの実装例
import Foundation
import FirebaseStorage
import UIKit
// MARK: - Enum (for File Upload Result)
enum UploadImagesResult {
case none
case loading
case success
case failure
}
protocol PostShopImagesUseCase {
func uploadShopImages(shopID: String, images: [UIImage]) async -> UploadImagesResult
}
final class PostShopImagesUseCaseImpl: PostShopImagesUseCase {
// MARK: - Function
func uploadShopImages(shopID: String, images: [UIImage]) async -> UploadImagesResult {
return await uploadImagesToFireStorage(shopID: shopID, images: images)
}
// MARK: - Private Function
private func uploadImagesToFireStorage(shopID: String, images: [UIImage]) async -> UploadImagesResult {
// 👉 画像アップロードディレクトリ名・ファイル名で利用するための日付文字列を取得する
let dateFormatter: DateFormatter = {
var dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyyMMdd"
return dateFormatter
}()
let uploadDateString = dateFormatter.string(from: Date())
// 👉 画像アップロード処理用の本丸部分
var uploadSuccessCount: Int = 0
for (i, image) in images.enumerated() {
// 👉 PHPickerViewControllerやUIImagePickerControllerから取得した画像データをアップロードする前準備
// 引数で受け取ったUIImageをData型に変換する(compressionQualityの値は仕様に応じて決定する)
guard let data = image.jpegData(compressionQuality: 0.5) else {
assertionFailure("jpegへの変換に失敗しました。")
break
}
// Metadataを設定する
let metadata = StorageMetadata()
metadata.contentType = "image/jpeg"
// 👉 アップロードする画像を配置する場所を設定する(配置するパス情報については仕様に応じて決定する)
// Path例: user_shop_images/{shopID}/{userID}/{YYYYMMDD}/{YYYYMMDD_(1...5).jpg}
let imageIndex = index + 1
let imagePath = "user_shop_images/"
+ "\(shopID)/"
+ "\(getUserID())/"
+ "\(uploadDateString)/"
let imageDirectory = imagePath + "\(uploadDateString)_\(imageIndex).jpg"
// 👉 FirebaseStorageへputDataAsyncを利用して画像ファイルアップロード処理を実行する
// ※ for文のループ処理を実行している最中は、画面上はLoadingIndicatorが表示されている想定をして実装する様にする。
guard let reference = FirebaseStorageManager.shared.storageReference?.child(imageDirectory) else {
assertionFailure("referenceの設定に失敗しました。")
break
}
do {
// MEMO: ファイルアップロード処理に成功した場合はこの場合はuploadSuccessCountのカウントをインクリメントしている。
_ = try await reference.putDataAsync(data, metadata: metadata)
uploadSuccessCount += 1
print("File Upload Success: " + "Uploaded Count is " + String(describing: uploadSuccessCount))
} catch let error {
// MEMO: 厳密にはファイルアップロード処理に失敗した画像があればログを送信する等をしておいた方がが望ましい。
print("File Upload Error: " + error.localizedDescription)
}
}
// 👉 成功時or失敗時のハンドリング処理を実行する
// ※ この処理ではアップロードに成功したものが1つでもあった場合には成功と見なしているが、返り値とその内容については仕様に応じて決定するのが望ましい。
return (uploadSuccessCount > 0) ? .success : .failure
}
private func getUserID() -> String {
// (実際の処理は割愛しています) UserIDを取得する処理
}
}
3-2. 画面表示とUseCase処理をつなぐViewModelの実装例
※実際の画面では、UIPickerViewController等のカメラロールからアップロード対象の画像を選択する処理やアップロード対象の画像から削除する処理をする過程で、一時的に選択したUIImageを格納する変数_selectedImages
の個数の増減をするための処理が必要になります。本記事ではこの部分は割愛して、画像をアップロードする処理に関連する部分のみピックアップしています。
【選択画像一括アップロード処理実行から実行完了時までの流れ】
- 画像を投稿するボタンを押下して、選択画像一括アップロード処理を実行する。
👉 RxSwiftの場合はviewModel.inputs.uploadImagesTrigger.onNext(())
を実行する。
👉 Combineの場合はviewModel.inputs.uploadImagesTrigger.send()
を実行する。 - ViewModel内の
uploadImagesTrigger
に連動して、async/awaitベースのpostUploadImages(shopID: shopID)
による画像一括アップロード処理が実行される。 - 画像一括アップロード処理結果に応じて、
PostShopImagesViewModelOutputs
の値が更新される。
👉 RxSwiftの場合は_uploadImagesResult: BehaviorRelay<UploadImagesResult>
の値が更新される。
👉 Combineの場合は@Published private var _uploadImagesResult: UploadImagesResult
の値が更新される。 - RxSwift・Combineの場合共に
viewModel.outputs.uploadImagesResult
の値が更新されるので、ViewController側に定義された結果に応じたUI表示関連処理等が実行される。
【その1. RxSwiftを利用したViewModelの処理】
import Foundation
import RxSwift
import RxCocoa
protocol PostShopImagesViewModelInputs {
// ・・・(その他の処理は省略)・・・
// 選択済み画像のアップロードをViewModelへ伝える
var uploadImagesTrigger: PublishSubject<Void> { get }
}
protocol PostShopImagesViewModelOutputs {
// ・・・(その他の処理は省略)・・・
// 取得処理の実行結果を格納する
var uploadImagesResult: Observable<UploadImagesResult> { get }
}
protocol PostShopImagesViewModelType {
var inputs: PostShopImagesViewModelInputs { get }
var outputs: PostShopImagesViewModelOutputs { get }
}
final class PostShopImagesViewModel: PostShopImagesViewModelInputs, PostShopImagesViewModelOutputs, PostShopImagesViewModelType {
var inputs: PostShopImagesViewModelInputs { return self }
var outputs: PostShopImagesViewModelOutputs { return self }
// MARK: - Properties (for PostShopImagesViewModelInputs)
let uploadImagesTrigger: PublishSubject<Void> = PublishSubject<Void>()
// MARK: - Properties (for PostShopImagesViewModelOutputs)
// ・・・(その他の処理は省略)・・・
var uploadImagesResult: Observable<UploadImagesResult> {
return _uploadImagesResult.asObservable()
}
// MARK: - Properties
private let disposeBag = DisposeBag()
// ----------
// MEMO: 投稿画面で選択したUIImageを一時的に格納するための変数
// 👉 このコードでは該当処理を割愛していますが、実際には追加・変更・削除で内容が変化する想定です。
// ----------
private var _selectedImages: BehaviorRelay<[UIImage]> = .init(value: [])
// MEMO: 中継地点となるPublishRelayの変数(Outputの変数を生成するための「つなぎ」のような役割)
// → PublishRelayの変化が起こったらObservableに変換されてOutputに流れてくる
private let _uploadImagesResult: BehaviorRelay<UploadImagesResult> = .init(value: .none)
// MEMO: このViewModelで利用するUseCase(※PropertyWrapperを利用したDI)
// 参考: https://qiita.com/fumiyasac@github/items/549e10af41ce91cdfbd1
@Dependencies.Inject(Dependencies.Name(rawValue: "PostShopImagesUseCase")) private var postShopImagesUseCase: PostShopImagesUseCase
// MARK: - Initializer
init(shopID: Int) {
// ViewModel側の処理実行トリガーと連結させる
uploadImagesTrigger
.subscribe(
onNext: { [weak self] in
guard let self = self else { return }
self.postUploadImages(shopID: shopID)
}
)
.disposed(by: disposeBag)
}
// MARK: - Private Function
private func postUploadImages(shopID: Int) {
Task { @MainActor in
self._uploadImagesResult.accept(.loading)
// 👉 async/awaitベースの処理で必要な値を取得し、結果に応じて_uploadImagesResultの内容を更新する
let result = await self.postShopImagesUseCase.uploadShopImages(shopID: shopID, images: _selectedImages.value)
self._uploadImagesResult.accept(result)
self._uploadImagesResult.accept(.none)
}
}
}
【その2. Combineを利用したViewModelの処理】
import Foundation
import Combine
protocol PostShopImagesViewModelInputs {
// ・・・(その他の処理は省略)・・・
// 選択済み画像のアップロードをViewModelへ伝える
var uploadImagesTrigger: PassthroughSubject<Void, Never> { get }
}
protocol PostShopImagesViewModelOutputs {
// ・・・(その他の処理は省略)・・・
// 取得処理の実行結果を格納する
var uploadImagesResult: AnyPublisher<UploadImagesResult, Never> { get }
}
protocol PostShopImagesViewModelType {
var inputs: PostShopImagesViewModelInputs { get }
var outputs: PostShopImagesViewModelOutputs { get }
}
final class PostShopImagesViewModel: PostShopImagesViewModelInputs, PostShopImagesViewModelOutputs, PostShopImagesViewModelType {
var inputs: PostShopImagesViewModelInputs { return self }
var outputs: PostShopImagesViewModelOutputs { return self }
// MARK: - Properties (for PostShopImagesViewModelInputs)
let uploadImagesTrigger = PassthroughSubject<Void, Never>()
// MARK: - Properties (for PostShopImagesViewModelOutputs)
// ・・・(その他の処理は省略)・・・
var uploadImagesResult: Observable<UploadImagesResult> {
return $_uploadSuccess.eraseToAnyPublisher()
}
// MARK: - Properties
private var cancellables: [AnyCancellable] = []
// ----------
// MEMO: 投稿画面で選択したUIImageを一時的に格納するための変数
// 👉 このコードでは該当処理を割愛していますが、実際には追加・変更・削除で内容が変化する想定です。
// ----------
@Published private var _selectedImages: [UIImage] = []
// MEMO: 中継地点となる@Publishedの変数(Outputの変数を生成するための「つなぎ」のような役割)
// → @Publishedの変化が起こったらObservableに変換されてOutputに流れてくる
@Published private var _uploadImagesResult: UploadImagesResult = .none
// MEMO: このViewModelで利用するUseCase(※PropertyWrapperを利用したDI)
// 参考: https://qiita.com/fumiyasac@github/items/549e10af41ce91cdfbd1
@Dependencies.Inject(Dependencies.Name(rawValue: "PostShopImagesUseCase")) private var postShopImagesUseCase: PostShopImagesUseCase
// MARK: - Initializer
init(shopID: Int) {
// ViewModel側の処理実行トリガーと連結させる
uploadImagesTrigger
.sink(
receiveValue: { [weak self] in
guard let self = self else { return }
sself.postUploadImages(shopID: shopID)
}
)
.store(in: &cancellables)
}
// MARK: - Private Function
private func postUploadImages(shopID: Int) {
Task { @MainActor in
self._uploadImagesResult = .loading
// 👉 async/awaitベースの処理で必要な値を取得し、結果に応じて_uploadImagesResultの内容を更新する
let result = await self.postShopImagesUseCase.uploadShopImages(shopID: shopID, images: _selectedImages.value)
self._uploadImagesResult = result
self._uploadImagesResult = .none
}
}
}
4. (番外編) Realtime Database & Firebase Storageで利用する接続先を用途に応じて変更する
用途に応じてDatabaseやStorageの接続先をStaging環境とProduction環境で分けたい場合や、複数のDatabaseやStorageを利用している場合に用途に応じた接続先に振り分けたい場合には、下記の様な形でSingleton Instance
を定義して設定することができます。
4-1. Firebase Storageで環境に応じた接続先を振り分ける例
import Foundation
import FirebaseStorage
final class FirebaseStorageManager {
private (set)var storageReference: StorageReference!
// MARK: - Initializer
init() {
#if STAGING
let storageUrl = "gs://(Staging用).appspot.com/"
#else
let storageUrl = "gs://(Production用).appspot.com/"
#endif
self.storageReference = Storage.storage(url: storageUrl).reference()
}
// MARK: - Singleton
static let shared = FirebaseStorageManager()
}
4-2. Firebase Realtime Databaseで環境に応じた接続先を振り分ける+用途に応じたDatabaseへ接続する例】
こちらは前述したStaging環境とProduction環境での分岐に加えて、下記の様な形で処理によって必要なデータベース接続先が異なる場合の処理例になります。
import Foundation
import FirebaseDatabase
final class RealtimeDatabaseManager {
private (set)var shopsReference: DatabaseReference!
private (set)var usersReference: DatabaseReference!
// MARK: - Initializer
init() {
#if STAGING
let shopsUrl = "https://(Shop用DBStaging用).firebaseio.com/"
let usersUrl = "https://(User用DBStaging用).firebaseio.com/"
#else
let shopsUrl = "https://(Shop用DBProduction用).firebaseio.com/"
let usersUrl = "https://(User用DBProduction用).firebaseio.com/"
#endif
let shopsDatabase = Database.database(url: shopsUrl)
shopsDatabase.isPersistenceEnabled = true
let usersDatabase = Database.database(url: usersUrl)
usersDatabase.isPersistenceEnabled = true
self.shopsReference = shopsDatabase.reference()
self.usersReference = usersDatabase.reference()
}
// MARK: - Singleton
static let shared = RealtimeDatabaseManager()
}
5. まとめ
私が経験したのは、0から機能を作るのではなく、既に形となっている状態から新たな機能を追加する場合でのFirebaseの活用にはなりましたが、これまではコールバック処理で書いていた部分をasync/awaitベースの処理に置き換えることで、随分とコードや処理がシンプルな形にできた様に思います。
今回紹介したRealtime DatabaseやFirebase Storage以外の他の機能についてもasync/awaitに対応していることは知っているものの、FireStoreやFirebase Authentication等まだ試せていない機能もあるので、一通りの機能を組み合わせたUI実装ありきのサンプル開発にも今後は取り組んでみようと考えています。