【iOS】Firebase RemoteConfig で作成する強制ダイアログ機構

はじめに

Firebase Advent Calendar 12日目です。本稿では、Firebaseを使用した強制ダイアログ表示に関して記載します。ここで、強制ダイアログと表記しているのは、いわゆる強制バージョンアップダイアログに代表されるダイアログを表示し、それ以上のユーザーのアプリ使用を防ぐ機能を指しています。

具体的には、アプリケーションの起動時や復帰時にダイアログを表示し、アプリケーションのそれ以上の操作を行わないようにするものです。リリースしたアプリに致命的な不具合があった場合にそのバージョンを使用するユーザーを限りなく少なくするために強制バージョンアップダイアログを表示したり、サーバー側の何らかの不具合によりアプリ使用を防ぎたい場合に使用することが多いかと思います。

環境設定

以下の環境を使用しています。

  • Firebase RemoteConfig 6.12.0
  • Swift 5.1
  • Xcode 11.3
  • iOS 13.2.3

なお、本稿で記載している実装をまとめたサンプルを以下のようにGitHubに上げています。

イデアと実装

本章でどのようなアイデアに基づくのかと、具体的な実装に言及します。

概要

FirebaseのRemoteConfigを使用することで以下を管理します。

  • 対象アプリバージョン(本バージョン未満の場合にダイアログを表示する)
  • ダイアログのタイトル
  • ダイアログのメッセージ
  • ダイアログのOKボタン押下時の遷移先URL(App Store や Sorryページ)

それぞれを一項目として管理しても良いでしょうし、JSONでひとまとまりにしても良いかと思いますが本稿ではJSONとして格納した場合に関して記載します。

アプリ側ではFirebaseで設定されたバージョンと現在のアプリバージョンを比較し、現在のバージョンがFirebaseの設定バージョン未満だった場合にダイアログを表示するような機構を導入します。上記により、本稿が目的とする機構を実現することができます。

Firebase側の設定

Firebase側の設定に関して記載します。「概要」にても記載しました通り、本稿ではJSON形式でデータの登録を行います。パラメータは force_alert_information命名しました。行いたい挙動としては、該当バージョンアップ未満のアプリの場合にアラートを表示し、OKボタン押下でなんらかのWebページに遷移、ストアに遷移、もしくは何もせずにアラートを表示し続けた状態とするとします。これらを実現するために必要な情報は下記です。

  • title - アラートのタイトルに使用
  • message - アラートのメッセージに使用
  • version - アラートの表示可否で使用する基準バージョン
  • url - okボタン押下時の遷移先URL

実際に設定を行います。FirebaseのプロジェクトページのRemoteConfigからパラメータを追加することができます。 パラメータを追加 > 右側の {}を押下することでJSON形式でのパラメータ追加が可能になります。 f:id:Iganin:20191208215732p:plain f:id:Iganin:20191208215855p:plain

JSON形式でのパラメータ追加時は専用のEditorが表示され、JSON形式に添わない場合はエラーが表示され保存ができません。そのためJSON形式に添わない文字列を保存してしまい不具合が発生してしまう、というような事態は避けられるようになっています。

f:id:Iganin:20191208220051p:plain

RemoteConfigのWrapperクラスの作成

アプリ側でのRemoteConfigを扱うwrapperクラスを作成します。具体的な設定・導入までの手順は公式ドキュメントをご確認ください。本稿では、導入までは完了している前提で記載を進めます。

実装概要

以下に作成したクラスを記載します。コードにコメントとして直接詳細を記述していますが、主な注意点は下記です。

  • Debug時の設定
    • 実プロダクトで毎回fetchは取得上限回数に当たる可能性があるため厳しいですが、Debug時は即座に確認したいことが多いため、debugとreleaseで取得間隔を切り分けdebug時は0としています
  • 取得間隔
    • 1時間あたりの取得回数上限があるため、前回取得時より10分の間隔を設けています
  • デフォルト値の扱い
    • plistで扱う方法もあるかと思いますが、コード内で完結させたかったためenumでパラメータキーを定義し、デフォルト値を持たせています。
import Foundation
import FirebaseRemoteConfig

// 取得するパラメータを定義します
enum RemoteConfigParameterKey: String, CaseIterable {
    case forceAlertInformation = "force_alert_information"
    
    var defaultValue: NSObject? {
        switch self {
        case .forceAlertInformation: return ForceAlertInformation.defaultValue()
        }
    }
}

// RemoteConfigの設定用Protocolです
protocol RemoteConfigServiceProtocol {
    func fetchAllData()
}

// RemoteConfigのプロパティ取得用Protocolです
protocol RemoteConfigPropertyProvider {
    func getForceAlertInformation() -> ForceAlertInformation?
}

final class RemoteConfigService: RemoteConfigServiceProtocol {
    
    // Protocolを使用してDI時にモックとの入れ替えが可能なようにインスタンスとして扱うようにしています
    // Singletion
    static let shared = RemoteConfigService()
    
    private init() {
        remoteConfig = RemoteConfig.remoteConfig()
        // releaseビルドではない場合は取得感覚を0としています
        if !AppUtility.isRelease {
            remoteConfig.configSettings.minimumFetchInterval = 0.0
        }
        
        // 全てのデータを取得する前提で定義されているパラメータキーに対するデフォルト値を全て入れています
        remoteConfig.setDefaults(makeDefaultValues(forKeys: RemoteConfigParameterKey.allCases))
    }
    
    // MARK: - Prpoerty
    private let remoteConfig: RemoteConfig
    private var expirationDuration: TimeInterval {
        // debugビルドでは即時反映, releaseビルドでは一定時間あけるようにします
        switch AppUtility.buildType {
        case .debug: return 0.0
        case .release: return 10 * 60 // 10分間
        }
    }
    
    // MARK: - Function
    func fetchAllData() {
        remoteConfig.fetch(withExpirationDuration: expirationDuration) { [weak self] (fetchStatus, error) in
            guard error == nil else { return }
            
            switch fetchStatus {
            case .success: self?.remoteConfig.activate(completionHandler: { error in
                if let error = error {
                    print(error.localizedDescription)
                }
            })
            case .failure, .noFetchYet, .throttled: break
            @unknown default: break
            }
        }
    }
    
    // RemoteConfigParameterKeyで定義したKeyに対してデフォルト値を決定し代入します
    private func makeDefaultValues(forKeys keys: [RemoteConfigParameterKey]) -> [String: NSObject] {
        var defaultValues = [String: NSObject]()
        keys.forEach { key in
            // この部分は後ほど可能な限り修正します
            if let defaultValue = key.defaultValue {
                defaultValues[key.rawValue] = defaultValue
            }
        }
        return defaultValues
    }
}

extension RemoteConfigService: RemoteConfigPropertyProvider {
    private func getProperty(for key: RemoteConfigParameterKey) -> RemoteConfigValue? {
        return remoteConfig.configValue(forKey: key.rawValue)
    }
    
    func getForceAlertInformation() -> ForceAlertInformation? {
        // この部分は後ほど可能な限り修正します
        guard let data = getProperty(for: .forceAlertInformation)?.dataValue else { return nil }
        let decoder = JSONDecoder()
        decoder.keyDecodingStrategy = .convertFromSnakeCase
        return try? decoder.decode(ForceAlertInformation.self, from: data)
    }
}

上記で使用しているForceAlertInformationの実装は下記です。

import Foundation

struct ForceAlertInformation: Codable {
    let title: String
    let message: String
    let version: String
    let url: URL?
}

extension ForceAlertInformation {
    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        title = try container.decode(String.self, forKey: .title)
        message = try container.decode(String.self, forKey: .message)
        version = try container.decode(String.self, forKey: .version)
        let urlString = try? container.decode(String.self, forKey: .url)
        url = urlString == nil ? nil : URL(string: urlString!)
    }
}

extension ForceAlertInformation: RemoteConfigDefaultValueProvidable {
    static func defaultValue() -> NSObject? {
        let defaultValue = ForceAlertInformation(
            title: "確認",
            message: "新しいバージョンのアプリがあります。アップデートをお願いします。",
            version: "1.0.0",
            url: nil)
        let encoder = JSONEncoder()
        encoder.keyEncodingStrategy = .convertToSnakeCase
        guard let defaultData = try? encoder.encode(defaultValue) else { return nil }
        return NSData(data: defaultData)
    }
}

RemoteConfigの設定値の取得タイミング

applicationDidBecomeActive:のタイミングで取得するよう実装しています。このタイミングで行うことにより、アプリがタスクキルされていない場合でも background <-> forgroundの遷移で新しい値を取得・反映させることが可能です。

AppDelegate {
    func applicationDidBecomeActive(_ application: UIApplication) {
        // foreground <-> background遷移時に状態の変更を反映できるよう
        // このタイミングでRemoteConfigの設定値取得を行います
        RemoteConfigService.shared.fetchAllData()
    }
}

アプリ側の強制ダイアログ表示の実装

実装例

本稿のサンプルでは単純なMVC構成で作成しています。MVVMViperなど使用するアーキテクチャで具体的な実装は変わってくるかとは思いますが、その場合は初期表示される画面のViewModelPresenterで判定し表示を行うように実装するなどしていただけると良いかと思います。

バージョンの大小判定ロジック

Stringが比較演算を用いてそのまま比較可能なためバージョン判定時に下記のようにしてしまいがちです。

if currentVersion < criteriaVersion {
    // Alert表示
}

しかし、この方法ですと潜在的なバグを含んでしまいます。例えば、 currentVersion = "3.10.0"criteriaVersion = 3.9.0 の比較を行う場合本来であればcurrentVersionの方がcriteriaVersionよりも大きいためアラートが表示されて欲しくないですが、比較演算子で比較を行なってしまうと 3.10.0の方が3.9.0のより小さいと判定されアラートが表示されてしまいます。

これはおそらくですが、内部的に少数の比較となってしまっており、 3.1と3.9の比較が行われたことにより、3.10.0の方が3.9.0より小さいと判断されているために発生しています。そこで下記のように . で各桁の数字を分離し、各桁の数字の大小比較を行うことでバージョンの大小判定を行います。

struct VersionUtility {
    // 現在のバージョンと比較対象のバージョンを比べ強制アラートの表示が必要かを判定します
    func isForceAlertRequired(currentVersion: String, criteriaVersion: String) -> Bool {
        
        var currentVersionNumbers = currentVersion.components(separatedBy: ".").map { Int($0) ?? 0 }
        var criteriaVersionNumbers = criteriaVersion.components(separatedBy: ".").map { Int($0) ?? 0 }
        let countDifference = currentVersionNumbers.count - criteriaVersionNumbers.count
        
        // major versionから順に比較していき、同値でなくなった時に大小比較結果を返す
        for (current, criteria) in zip(currentVersionNumbers, criteriaVersionNumbers) {
            if current > criteria {
                return false
            } else if current < criteria {
                return true
            }
        }
        // 同値
        return false
    }
}

強制ダイアログの表示メソッド

OKボタン押下後にAlertが閉じないよう再度Alert表示のメソッドを呼び出しているところがポイントになります。

    func showForceAlert(information: ForceAlertInformation) {
        let alertController = UIAlertController(
            title: information.title,
            message: information.message,
            preferredStyle: .alert
        )
        let okAction = UIAlertAction(title: "OK", style: .default) { [weak self] _ in
            // okを押下後にAlertが非表示にならない用再度表示します
            self?.showForceAlert(information: information)
            if let url = information.url,
                UIApplication.shared.canOpenURL(url) {
                UIApplication.shared.open(url, options: [:], completionHandler: nil)
            }
        }
            
        alertController.addAction(okAction)
        present(alertController, animated: true, completion: nil)
    }

実際の判定処理

ViewControllerで行う実際の判定処理部分です。実プロダクトで使用する場合は、アラート表示後の処理を行うかどうか検討し、Bool値を返すようなメソッドに分割変更した方が良いかと思います。

    func showForceAlertIfNeeded() {
        guard let forceAlertInformation = remoteConfigPropertyProvider.getForceAlertInformation() else { return }
        
        let currentVersion = AppUtility.currentVersion
        let criteriaVersion = forceAlertInformation.version
        if VersionUtility().isForceAlertRequired(
            currentVersion: currentVersion, criteriaVersion: criteriaVersion) {
            showForceAlert(information: forceAlertInformation)
        }
    }

タイミング

ダイアログの表示の必要有無の判定および表示処理を実施するタイミングとして下記のライフサイクルを採用しています。

初期表示される画面のviewDidLoad

サンプルでは初期に表示されるViewControllerのViewDidLoad内で処理を行なっています。他の方法としては、例えば、 LauchScreenの表示後にLaunchScreenと同様の画面をUIViewControllerで表示し、そのviewDidLoadで判定を実施の後、ダイアログを表示するかTabBarControllerを表示するかだし分けるなどが考えられます。なお、後述する applicationDidBecomeActive のタイミングを拾えてしまい、同一の画面で2度実行されてしまう可能性あるため、そのような場合にはこのライフサイクルでの判定は不要になります。

    override func viewDidLoad() {
        super.viewDidLoad()
        setup()
        showForceAlertIfNeeded()
    }
applicationDidBecomeActive

アプリがタスクキルされていない場合も考慮して、このライフサイクルでの判定も行います。本メソッドをフォローすることでアプリのbackground <-> forgroundの遷移でも判定を行うことができます。具体的な実装はアプリのメイン画面(例えばRootViewController)にNotification経由でライフサイクルを注入するのが良いと思います。

    func setup() {
        NotificationCenter.default.addObserver(self, selector: #selector(applicationDidBecomeActive), name: UIApplication.didBecomeActiveNotification, object: nil)
    }
    
    @objc func applicationDidBecomeActive() {
        showForceAlertIfNeeded()
    }

合致するビジネス要件

RemoteConfig を使用した強制ダイアログ機構は大変便利なものではありますが、RemoteConfigの特性上ビジネス要件次第では採用が難しいパターンがあります。それはダイアログの表示を判断してから実際に表示されるまでのタイムラグを可能なかぎり0にしたい場合です。

Firebase RemoteConfigはその機能の仕様上、具体的には1時間あたりのフェッチ回数の上限があるという仕様上、前回の値所得時から新規の値取得時まで一定の期間設定値の再取得を行わないようになるパターンが多いです。したがって、このような場合にはダイアログに関するFirebase側の設定変更から実際のアプリ側への反映まで一定のタイムラグが発生してしまいます。

よって、設定変更から反映まで即座におこないたいという厳し目の要件の場合は合致しづらいですが、多少のタイムラグを許容できる場合は本記事の機構で十分実用にたりうると判断しています。

まとめ

強制ダイアログ表示はサービスメンテナンス時やなんらかの不具合によってアプリの全体的なバージョンアップが必要な時などに必要となり、実装しておいた方が良い機能かと思います。サーバーの初期通信などに強制ダイアログ表示機構の返却値等を入れた場合、サーバーがそもそも動作していない場合などにはダイアログ表示に必要な情報を取得することができません。

強制ダイアログ表示を行うための機構をサービスサーバーから分けることで、サーバーが機能していない場合でもメンテナンス中などの表示を行うことができます。機能要件の中で出てきた際には是非選択肢の一つとして検討してみてください。

参考資料

RemoteConfig

AdvcentCalendar

サンプル