【SwiftUI】Firebase Remote Configを使って、アプリのコードを変更せずに画面を切り替えてみた

2022.05.19

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

アプリの画面を気分によって変えたい時があっても、アプリのコードを変更するとApp Store Connectで審査提出しないといけないので骨が折れます。しかし、Firebase Remote Configを使うとアプリのコードを変更することなく簡単に画面を切り替えることが出来るのでその方法をSwiftUIで実践しつつ紹介していきます。今回は、Firebase Apple SDKがメジャーアップデートして9.0.0がリリースされたということでそれを使っていきます。

作ったもの

Remote Configの値を変更して、アプリの見た目を変えています。

環境

  • Xcode 13.3.1
  • Firebase Apple SDK 9.0.0

Remote Configとは

Firebase Remote Config 公式ドキュメントから引用しますと、

アプリのアップデートを公開しなくても、アプリの動作と外観を変更できます。コストはかからず、1日あたりのアクティブ ユーザー数の制限もありません。

Firebaseコンソールで、アプリの動作や外観を制御するアプリ内デフォルト値を作成し、アプリはRemote Config バックエンドAPIを使用して、その値を反映することが出来ます。

準備

今回はFirebase Apple SDK 9.0.0を使用する為、Xcodeの最小必要バージョンは13.3.1となっております。

また、Firebaseプロジェクトが既に作成してある前提で進めていきます。

公式のFirebaseをAppleプロジェクトに追加するにプロジェクト作成手順が分かりやすく書いてありますので、まだの方はこちらを参考にしていただければと思います。

  1. Firebaseプロジェクトを作成する
  2. アプリを Firebase に登録する
  3. Firebase構成ファイルを取得し、アプリのルートディレクトリに配置する

ここまで準備が出来ましたら、アプリにFirebase SDKを追加します。

補足

Firebase Apple SDKは旧名iOS SDKから改名されましたが、以前の名残が残っており、所々でfirebase-ios-sdkになっております。紛らわしいので今回は必要な箇所以外はFirebase SDKと記載させていただきます。

アプリにFirebase SDKを追加

Xcodeで File > Add Packages... をクリックし開かれた画面の検索フォームにhttps://github.com/firebase/firebase-ios-sdkを入力します。

入力すると、firebase-ios-sdkというパッケージが出てくるので、Dependency RuleUp to Next Major Versionを指定して、バージョンを9.0.0にします。

Add Packageボタンを押すとパッケージの追加処理が開始します。少し追加の処理に時間がかかるイメージがありますが気長に待ちください。

しばらくすると、 Firebase SDKのどのプロダクトを選択するかを決める画面が出てきます。

  • FirebaseRemoteConfig
  • FirebaseRemoteConfigSwift

今回は上記二つを選択して、Add Packageを行います。

FirebaseRemoteConfigSwiftは、9.0.0になる前はベータ版だったのですが、9.0.0からベータの記載が取り除かれたので試してみることにしました。FirebaseRemoteConfigSwiftを選択しなくても、Remote Config自体は何の問題もなく使用出来ます。

パッケージの追加が完了したのでアプリ側の実装に移ります。

アプリに実装する

AppDelegateを作成する

SwiftUIにはAppDelegateクラスはデフォルトでは存在しない為、作成が必要です。

import SwiftUI
import Firebase

@main
struct RemoteConfigPracticeApp: App {

    @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate

    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }

    class AppDelegate: NSObject, UIApplicationDelegate {
        func application(_ application: UIApplication,
                         didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
            FirebaseApp.configure()
            return true
        }
    }
}

AppDelegateを使用する為に、Appの構造体の中で@UIApplicationDelegateAdaptor(AppDelegate.self)を用意し、AppDelegateクラスを作成します。

アプリ起動時にFirebaseApp共有インスタンスを構成したいのでAppDelegateクラスのdidFinishLaunchingWithOptions関数内で、FirebaseApp.configure()を実行します。

RemoteConfigParameterを作成

今回はRemote Configのパラメータを扱うRemoteConfigParameterを作成しました。

import Foundation
import FirebaseRemoteConfig
import FirebaseRemoteConfigSwift

struct RemoteConfigParameter {

    /// 初期値のディクショナリー
    static let defaultConfigValues: [String: NSObject] = {
        var defaultDictionary = [String: NSObject]()
        for parameter in ParameterType.allCases {
            defaultDictionary.updateValue(parameter.defaultValue, forKey: parameter.key)
        }
        return defaultDictionary
    }()

    private let remoteConfig: RemoteConfig

    init(with remoteConfig: RemoteConfig) {
        self.remoteConfig = remoteConfig
    }

    /// Remote Configのパラメータタイプ
    enum ParameterType: String, CaseIterable {
        case isUnderMaintenance
        case maintenanceMessage

        var key: String {
            return self.rawValue
        }

        var defaultValue: NSObject {

            switch self {
            case .isUnderMaintenance:
                return false as NSObject
            case .maintenanceMessage:
                return "メンテナンス中" as NSObject
            }
        }
    }

    /// メンテナンス中かどうか
    var isUnderMaintenance: Bool {
        do {
            return try decodedValue(.isUnderMaintenance)
        }
        catch {
            return ParameterType.isUnderMaintenance.defaultValue as! Bool
        }
    }

    /// メンテナンスメッセージ
    var maintenanceMessage: String {
        do {
            return try decodedValue(.maintenanceMessage)
        }
        catch {
            return ParameterType.maintenanceMessage.defaultValue as! String
        }
    }

    // MARK: - Private func

    /// Remote Configで取得している値をデコードして返す
    /// - Parameter parameter: Remote Configパラメータタイプ
    /// - Returns: デコードされたRemote Configの値を返す
    private func decodedValue<T>(_ parameter: ParameterType) throws -> T {

        switch parameter {
        case .isUnderMaintenance:
            return try remoteConfig[parameter.key].decoded(asType: Bool.self) as! T
        case .maintenanceMessage:
            return try remoteConfig[parameter.key].decoded(asType: String.self) as! T
        }
    }
}

プロパティや関数について説明していきます。

defaultConfigValues

Remote Configに設定する初期値です。

static let defaultConfigValues: [String: NSObject] = {
    var defaultDictionary = [String: NSObject]()
    for parameter in ParameterType.allCases {
        defaultDictionary.updateValue(parameter.defaultValue, forKey: parameter.key)
    }
    return defaultDictionary
}()

enum ParameterTypeにfor-in文でキー値とオブジェクトを持ったディクショナリを生成しています。

enum ParameterType

Remote Configで今回利用するパラメータに対してキー値とデフォルト値を取り出せるようにしています。

enum ParameterType: String, CaseIterable {
    case isUnderMaintenance
    case maintenanceMessage

    var key: String {
        return self.rawValue
    }

    var defaultValue: NSObject {

        switch self {
        case .isUnderMaintenance:
            return false as NSObject
        case .maintenanceMessage:
            return "メンテナンス中" as NSObject
        }
    }
}

case isUnderMaintenanceは現在メンテナンス中かどうかを判定するパラメータで、case maintenanceMessageはメンテナンス中に表示する文字列のパラメータになります。

isUnderMaintenance

メンテナンス中かどうかの変数になります。

var isUnderMaintenance: Bool {
    do {
        return try decodedValue(.isUnderMaintenance)
    }
    catch {
        return ParameterType.isUnderMaintenance.defaultValue as! Bool
    }
}

 decodedValue(_: ParameterType)を呼んで値を取得しています。decodedValuethrows付きの関数なのでエラーの場合はデフォルト値を返しています。

maintenanceMessage

メンテナンス中に表示する文字列です。

var maintenanceMessage: String {
    do {
        return try decodedValue(.maintenanceMessage)
    }
    catch {
        return ParameterType.maintenanceMessage.defaultValue as! String
    }
}

isUnderMaintenanceと同様に、decodedValue(_: ParameterType)で値を取得して、エラーの場合はデフォルト値を返しています。

decodedValue

RemoteConfigValueをデコードした値を取得する関数です。

private func decodedValue<T>(_ parameter: ParameterType) throws -> T {

    switch parameter {
    case .isUnderMaintenance:
        return try remoteConfig[parameter.key].decoded(asType: Bool.self) as! T
    case .maintenanceMessage:
        return try remoteConfig[parameter.key].decoded(asType: String.self) as! T
    }
}

渡されたParameterTypeに紐づくRemoteConfigValueに対してFirebaseRemoteConfigSwiftにあるRemoteConfigValueのエクステンションメソッドdecoded(asType:)を使用してデコードした値を返しています。

こちらはthrows付きの関数になるので、デコードに失敗した場合はエラーを返します。

RemoteConfigState

Remote Configの状態を持っているObservableObjectです。

import SwiftUI
import FirebaseRemoteConfig

class RemoteConfigState: ObservableObject {

    /// fetchAndActivateを実施中ならtrue
    @Published var isFetchAndActivating = false

    private let remoteConfig: RemoteConfig
    private let remoteConfigParameter: RemoteConfigParameter

    // Remote Configの最小フェッチ間隔
    private let minimumFetchInterval: TimeInterval = {
    #if DEBUG
        return 0
    #else
        // デフォルト値 12時間(推奨)
        return 43200
    #endif
    }()

    init() {
        remoteConfig = RemoteConfig.remoteConfig()
        let settings = RemoteConfigSettings()
        settings.minimumFetchInterval = minimumFetchInterval
        remoteConfig.configSettings = settings
        remoteConfig.setDefaults(RemoteConfigParameter.defaultConfigValues)
        remoteConfigParameter = RemoteConfigParameter(with: remoteConfig)

        fetchAndActive()
    }

    var isUnderMaintenance: Bool {
        return remoteConfigParameter.isUnderMaintenance
    }

    var maintenanceMessage: String {
        return remoteConfigParameter.maintenanceMessage
    }

    /// RemoteConfigの値をfetchしてactivateする
    private func fetchAndActive() {
        isFetchAndActivating = true

        Task {
            do {
                let _ = try await remoteConfig.fetchAndActivate()
                DispatchQueue.main.async {
                    self.isFetchAndActivating = false
                }
            }
            catch {
                print(error.localizedDescription)
            }
        }
    }
}

isFetchAndActivating

今回はFirebase Remote Configの読み込み方法にある読み込み画面の表示中に有効にする方法で実装します。

なので、読み込み中である場合は読み込み表示画面を表示する為の変数を用意しておきます。

@Published var isFetchAndActivating = false

この方法を使用する場合は、質の高いユーザーエクスペリエンスを提供するに読み込み画面にタイムアウトを追加することが推奨されています。が、今回は実装しておりません。実際にプロダクトコードとして書く時には使用したいですね。

A/Bテスト用の値を読み込む場合には、ユーザーがテストに参加してテスト値が適用されるまでの時間を短縮する為にも、この方法が強く推奨されています。

Firebase Remote Configの読み込み方法次回の起動時に新しい値を読み込む方法を活用すれば、読み込み画面を表示する必要はなり、ユーザーの待機時間が大幅に短縮されますが、最新の状態になる為にアプリを最低2回起動する必要があります。

init

init() {
    remoteConfig = RemoteConfig.remoteConfig()
    let settings = RemoteConfigSettings()
    settings.minimumFetchInterval = minimumFetchInterval
    remoteConfig.configSettings = settings
    remoteConfig.setDefaults(RemoteConfigParameter.defaultConfigValues)
    remoteConfigParameter = RemoteConfigParameter(with: remoteConfig)

    fetchAndActive()
}

settings.minimumFetchIntervalではRemote Configの最小フェッチ間隔を設定しており、remoteConfig.setDefaultsではRemote Configのパラメータの初期値をセットしています。最後にfetchAndActive()でRemote Configの値をフェッチしてアクティベートしています。

settings.minimumFetchInterval

RemoteConfigバックエンドに対してフェッチ要求を再度行う前に経過する必要のある最小間隔になります。ここで設定した最小フェッチ間隔が経過するまで、バックエンドへの追加のフェッチ要求は許可されません。

Firebase Remote Config を使ってみるに記述があるのですが、

Remote Configのデフォルトのフェッチ間隔は 12時間であり、本番環境で推奨されるフェッチ間隔もこの値です。この場合、実際にフェッチ呼び出しが行われた回数に関係なく、12 時間の期間内で構成がバックエンドから複数回フェッチされることはありません。

ただ12時間の間、フェッチがされないとなると開発でのテストに困ってしまう為、開発環境ではこのminimumFetchIntervalの値を小さく設定することで頻繁なキャッシュの更新を行うことが出来ます。

このminimumFetchIntervalを小さく設定する方法は、開発目的でのみ使用し、本番環境で実行されるアプリには使用しないでくださいと記載されております。

今回は下記のようにDEBUGスキームの場合とそうでない場合でminimumFetchIntervalを変えています。

private let minimumFetchInterval: TimeInterval = {
#if DEBUG
    return 0
#else
    // デフォルト値 12時間(推奨)
    return 43200
#endif
}()

Xcodeでスキームを切り替える方法についてはこちらの記事を参考にしました。

remoteConfig.setDefaults

remoteConfig.setDefaults(RemoteConfigParameter.defaultConfigValues)では、キー値に対してのデフォルト値をセットしています。

Remote Configが値を決定する方法は下記のようになっています。

  1. 最初にサーバーから保存されたキャッシュ値があるかどうかをチェックし、ある場合はそれを使用する
  2. キャッシュされた値がない場合は、setDefaults()で定義されたデフォルトを参照する
  3. サーバーからキャッシュされた値がなく、デフォルトに値がない場合、サーバーはそのタイプのシステムデフォルトを使用する。

fetchAndActive()

Remote Configの値をフェッチしてアクティベートします。

private func fetchAndActive() {
    isFetchAndActivating = true

    Task {
        do {
            let _ = try await remoteConfig.fetchAndActivate()
            DispatchQueue.main.async {
                self.isFetchAndActivating = false
            }
        }
        catch {
            print(error.localizedDescription)
        }
    }
}

まず、isFetchAndActivatingのフラグをtrueにし、現在フェッチまたはアクティベート中であることを示します。

そして、try await remoteConfig.fetchAndActivate()で値のフェッチとアクティベートを実行します。awaitなので、fetchAndActivate()に成功すると、self.isFetchAndActivating = falseに進み、失敗すると、catchの中が呼ばれるようになっています。

今回はエラーハンドリングは行なっておりませんが、実際には適切なエラーハンドリングを行いましょう。

プロパティ

それぞれRemoteConfigParameterから値を取得しています。

var isUnderMaintenance: Bool {
    return remoteConfigParameter.isUnderMaintenance
}

var maintenanceMessage: String {
    return remoteConfigParameter.maintenanceMessage
}

ContentView

最後に見た目のViewになります。

import SwiftUI

struct ContentView: View {

    @StateObject var remoteConfigState = RemoteConfigState()

    var body: some View {

        ZStack {

            if remoteConfigState.isUnderMaintenance {

                ZStack {
                    Rectangle()
                        .fill(.yellow)
                        .ignoresSafeArea()

                    Text(remoteConfigState.maintenanceMessage)
                }

            } else {
                Text("正常運転中!")
            }

            //
            // ? Remote Config読み込み中画面
            //
            if remoteConfigState.isFetchAndActivating {

                ZStack {
                    Rectangle()
                        .fill(.gray.opacity(0.8))
                        .ignoresSafeArea()

                    Text("Remote Config\n読み込み中")
                }
            }
        }
    }
}

まず、remoteConfigState.isFetchAndActivatingtrueの場合は、読み込み中の画面が表示されます。読み込みが完了すると、remoteConfigState.isUnderMaintenanceフラグをみて、メンテナンス中であれば、メンテナンス中の画面が表示され、メンテナンス中でなければ*正常運転中!というテキストが表示されます。

おわりに

Remote Configで変更したものはリアルタイムで更新はされますが、本番環境での最小フェッチ間隔は12時間に設定することが推奨されています。なので、瞬時に画面を変更したい場合には活用するのは難しそうですね。12時間の間隔を考慮できるのであれば、アプリを変更してApp Store Connectに審査を提出するという流れを省く事ができるのでとても良いなと感じました。A/Bテストに活用するのも良さそうですね!

またせっかくの機会なのでFirebaseRemoteConfigSwiftを試してみたのですが、コンプリーションハンドラーの記載がなくなり、とてもスッキリしました。

今回少し登場したasync awaitもこれから学んでいきたいと思います。

参考