LoginSignup
9
4

Firebase Realtime Database & FireStorageでasync/awaitを利用した簡単な処理実装例の紹介

Last updated at Posted at 2022-12-18

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
  • FireStorage

の2つをピックアップして、async/awaitを活用する処理の事例をイメージもまじえて簡単にご紹介できればと思います。

【FireStoreにおけるasync/await処理を活用した処理の参考記事】

本記事では割愛していますが、FireStoreについても勿論async/awaitへ対応がなされています。実際の処理イメージ等を掴む際には下記の資料や動画等が参考になると思います。

また、こちらはFirebase Realtime DatabaseとFirestoreの特徴と相違点について解説している記事になります。

【今回の解説記事で想定している構成】

本記事で想定している構成は下記の様な形となります。

スクリーンショット 2022-12-18 2.38.28.png

これまでの実務でも、Firebase Realtime DatabaseやFireStorageを利用する処理を 「RepositoryクラスないしはUseCaseクラス内で定義し、そのクラスをViewModelクラスないしはPresenterクラスで利用する形」 をとっていました。

ViewModelクラスないしはPresenterクラス内の処理では、画面表示に必要な値の取得や適切な形へのasync/awaitを前提とした処理にしつつも、画面表示に関連する部分については、元々RxSwiftやCombineを利用していた経緯があったこともあり、その点も考慮した形に実装しています。(もしRxSwiftやCombineを利用していないViewModelの場合には、async/awaitの処理だけでも完結できると思います。)

また、後述する処理を利用する様な画面のイメージ概要は下記の様な形となります(今回は画面要素に関する実装は割愛しています)。

スクリーンショット 2022-12-18 7.01.34.png

2. Firebase Realtime Databaseでasync/awaitを活用する処理の事例&実装例

Firebase SDKがほぼSwiftに対応したことによって、Codableasync/await等Swiftが持つ強力な機能が利用可能になりました。特にFirebase Realtime Databaseから取得するデータについては、従来までは[String: Any]型で返却されていたため、DictionaryのKey値に対応する値を分解してEntityクラスや構造体等へ変換する必要がありましたが、現在ではFirebaseDatabaseSwiftをインポートすることにより、Codableをはじめとした機能を利用することでシンプルにすることができ、またasync/awaitベースの処理を活用することで全体的にコールバックで煩雑になりがちだった処理もシンプルになります。

ここで紹介している処理は、データベース内に登録されている全データの一覧を取得するだけなので、async/awaitベースのgetData()を利用し、取得できたデータをCodableに準拠した構造体にマッピングする様な形になります。

※ Firebase Realtime DatabaseではgatData()メソッドの他にも、下記のものがasync/awaitに対応しています。

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】

GetShopsUseCase.swift
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】

GetUsersUseCase.swift
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を利用する処理のみご紹介します。

【データ取得処理実行から画面への反映までの流れ】

  1. ViewControllerから、適切なタイミングでデータ取得リクエストを実行する
    👉 RxSwiftの場合はviewModel.inputs.initialFetchTrigger.onNext(())を実行する。
    👉 Combineの場合はviewModel.inputs.initialFetchTrigger.send()を実行する。
  2. ViewModel内のinitialFetchTriggerに連動して、async/awaitベースのgetShopsFromFirebaseDatabase()による一覧データ取得処理が実行される。
  3. 一覧データ取得処理結果に応じて、ShopsViewModelOutputsの値が更新される。
    👉 RxSwiftの場合は_shops: BehaviorRelay<[Shop]>_requestStatus: BehaviorRelay<RequestState>の値が更新される。
    👉 Combineの場合は@Published private var _shops: [Shop]@Published private var _requestStatus: RequestStateの値が更新される。
  4. RxSwift・Combineの場合共にviewModel.outputs.shopsviewModel.outputs.requestStatusの値が更新されるので、ViewController側に定義されたUIへの判定処理等が実行される。

【その1. RxSwiftを利用したViewModelの処理】

ShopsViewModel.swift
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の処理】

ShopsViewModel.swift
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. FireStorageでasync/awaitを活用する処理の事例&実装例

次に、画像投稿画面で選択された複数枚の画像を一括でかつ順番を担保した状態でアップロード処理を実行する場合を考えてみましょう。前述したFirebase Realtime Databaseと同様にFireStorageの処理についてもasync/awaitベースの処理を活用することができますので、今回の様な仕様ではputDataAsync(_:metadata:)を利用する方針とします。この処理を実行するためのUseCaseクラスのポイントをまとめると下記の様になるかと思います。

スクリーンショット 2022-12-18 9.29.29.png

3-1. 複数枚の画像データアップロード処理をするUseCaseの実装例

PostShopImagesUseCase.swift
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"

            // 👉 FirestorageへputDataAsyncを利用して画像ファイルアップロード処理を実行する
            // ※ for文のループ処理を実行している最中は、画面上はLoadingIndicatorが表示されている想定をして実装する様にする。
            guard let reference = FireStorageManager.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の個数の増減をするための処理が必要になります。本記事ではこの部分は割愛して、画像をアップロードする処理に関連する部分のみピックアップしています。

【選択画像一括アップロード処理実行から実行完了時までの流れ】

  1. 画像を投稿するボタンを押下して、選択画像一括アップロード処理を実行する。
    👉 RxSwiftの場合はviewModel.inputs.uploadImagesTrigger.onNext(())を実行する。
    👉 Combineの場合はviewModel.inputs.uploadImagesTrigger.send()を実行する。
  2. ViewModel内のuploadImagesTriggerに連動して、async/awaitベースのpostUploadImages(shopID: shopID)による画像一括アップロード処理が実行される。
  3. 画像一括アップロード処理結果に応じて、PostShopImagesViewModelOutputsの値が更新される。
    👉 RxSwiftの場合は_uploadImagesResult: BehaviorRelay<UploadImagesResult>の値が更新される。
    👉 Combineの場合は@Published private var _uploadImagesResult: UploadImagesResultの値が更新される。
  4. RxSwift・Combineの場合共にviewModel.outputs.uploadImagesResultの値が更新されるので、ViewController側に定義された結果に応じたUI表示関連処理等が実行される。

【その1. RxSwiftを利用したViewModelの処理】

PostShopImagesViewModel.swift
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の処理】

PostShopImagesViewModel.swift
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 & FireStorageで利用する接続先を用途に応じて変更する

用途に応じてDatabaseやStorageの接続先をStaging環境とProduction環境で分けたい場合や、複数のDatabaseやStorageを利用している場合に用途に応じた接続先に振り分けたい場合には、下記の様な形でSingleton Instanceを定義して設定することができます。

4-1. FireStorageで環境に応じた接続先を振り分ける例

FireStorageManager.swift
import Foundation
import FirebaseStorage

final class FireStorageManager {

    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 = FireStorageManager()
}

4-2. Firebase Realtime Databaseで環境に応じた接続先を振り分ける+用途に応じたDatabaseへ接続する例】

こちらは前述したStaging環境とProduction環境での分岐に加えて、下記の様な形で処理によって必要なデータベース接続先が異なる場合の処理例になります。

スクリーンショット 2022-12-17 23.44.02.png

RealtimeDatabaseManager.swift
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やFireStorage以外の他の機能についてもasync/awaitに対応していることは知っているものの、FireStoreやFirebase Authentication等まだ試せていない機能もあるので、一通りの機能を組み合わせたUI実装ありきのサンプル開発にも今後は取り組んでみようと考えています。

9
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
9
4