はじめに
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形式でのパラメータ追加が可能になります。
JSON形式でのパラメータ追加時は専用のEditorが表示され、JSON形式に添わない場合はエラーが表示され保存ができません。そのためJSON形式に添わない文字列を保存してしまい不具合が発生してしまう、というような事態は避けられるようになっています。
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()
}
}
}
protocol RemoteConfigServiceProtocol {
func fetchAllData()
}
protocol RemoteConfigPropertyProvider {
func getForceAlertInformation() -> ForceAlertInformation?
}
final class RemoteConfigService: RemoteConfigServiceProtocol {
static let shared = RemoteConfigService()
private init() {
remoteConfig = RemoteConfig.remoteConfig()
if !AppUtility.isRelease {
remoteConfig.configSettings.minimumFetchInterval = 0.0
}
remoteConfig.setDefaults(makeDefaultValues(forKeys: RemoteConfigParameterKey.allCases))
}
private let remoteConfig: RemoteConfig
private var expirationDuration: TimeInterval {
switch AppUtility.buildType {
case .debug: return 0.0
case .release: return 10 * 60
}
}
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
}
}
}
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) {
RemoteConfigService.shared.fetchAllData()
}
}
アプリ側の強制ダイアログ表示の実装
実装例
本稿のサンプルでは単純なMVC構成で作成しています。MVVM
やViper
など使用するアーキテクチャで具体的な実装は変わってくるかとは思いますが、その場合は初期表示される画面のViewModel
やPresenter
で判定し表示を行うように実装するなどしていただけると良いかと思います。
バージョンの大小判定ロジック
String
が比較演算を用いてそのまま比較可能なためバージョン判定時に下記のようにしてしまいがちです。
if currentVersion < criteriaVersion {
}
しかし、この方法ですと潜在的なバグを含んでしまいます。例えば、 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
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
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
サンプル