リアルタイム Remote Configで強制アップデートを実装する最小構成【Swift】

# はじめに
個人開発しているiOSアプリ「[いいこと日記](https://apps.apple.com/jp/app/id6447811780?platform=iphone)」に強制アップデートを実装する機会があり、手軽に導入できる**Firebase Remote Config**を使用しました。
`Firebase SDK`の`v10.7.0`からサポートされているRemote Configの**リアルタイム更新**を使うことで、起動時だけでなくアプリの利用中でも即座に強制アップデートダイアログを表示することができます。
今回はできるだけ最低限の機能での実装を紹介します。

# Firebaseでの設定
![スクリーンショット 2024-01-17 6.35.07.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/640032/53a086a5-8b8b-48a6-43cc-5aca2944f7b5.png)
Remote Config上に`required_version`というパラメータを作成しておき、そのバージョンよりも古いバージョンだった場合に強制アップデートのダイアログを表示します。

# 実装
```Swift:ForceUpdateManager.swift
import FirebaseRemoteConfig
import UIKit

final class ForceUpdateManager {
    static let shared: ForceUpdateManager = .init()

    private let remoteConfig = RemoteConfig.remoteConfig()
    private let requiredVersionKey = "required_version"

    private init() {}

    func startObserving() {
        // 初期データを取得する
        remoteConfig.fetchAndActivate { [weak self] status, error in
            switch status {
            case .successUsingPreFetchedData, .successFetchedFromRemote:
                self?.forceUpdateIfNeeded()
            default:
                if let error {
                    print(error.localizedDescription)
                }
            }
        }

        // Updateを監視する
        remoteConfig.addOnConfigUpdateListener { [weak self] configUpdate, error in
            guard let self else { return }

            if let error {
                print(error.localizedDescription)
            }

            // Fetchしてきたデータを有効化する
            remoteConfig.activate()

            // 強制アップデートを行う
            if configUpdate?.updatedKeys.first == requiredVersionKey {
                forceUpdateIfNeeded()
            }
        }
    }

    // バージョンを比較して強制アップデートが必要ならアラートを表示する
    private func forceUpdateIfNeeded() {
        guard let currentVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String,
              let requiredVersion = remoteConfig.configValue(forKey: requiredVersionKey).stringValue else {
            return
        }
        //　バージョン文字列を配列に変換(例: "3.2.1" -> [3, 2, 1])
        var currentVersionNums = currentVersion.split(separator: ".").compactMap { Int($0) }
        var requiredVersionNums = requiredVersion.split(separator: ".").compactMap { Int($0) }
        if currentVersionNums.isEmpty || requiredVersionNums.isEmpty
            || currentVersionNums.count > 3 || requiredVersionNums.count > 3 {
            return
        }
        // 3桁まで0で埋める(例: [3, 2] -> [3, 2, 0])
        currentVersionNums.append(contentsOf: repeatElement(0, count: 3 - currentVersionNums.count))
        requiredVersionNums.append(contentsOf: repeatElement(0, count: 3 - requiredVersionNums.count))
        // バージョンを比較する
        for i in 0..<3 {
            let current = currentVersionNums[i]
            let required = requiredVersionNums[i]

            if current == required {
                continue
            } else if current > required {
                return
            } else if required > current {
                showAlert()
                return
            }
        }
    }

    private func showAlert() {
        // アラートを表示するViewController
        guard let viewController = rootViewController else { return }

        let alert = UIAlertController(title: "アップデートのお願い", message: "最新版にアップデートしてください。", preferredStyle: .alert)
        let updateAction = UIAlertAction(title: "App Storeを開く", style: .default) { _ in
            guard let url = URL(string: "https://apps.apple.com/app/{アプリのID}"),
                  UIApplication.shared.canOpenURL(url) else {
                return
            }
            UIApplication.shared.open(url)
            // ストア遷移時にアラートが閉じてしまうため再度表示する
            viewController.present(alert, animated: true)
        }
        alert.addAction(updateAction)

        viewController.present(alert, animated: true)
    }

    // 最前面のViewController
    private var rootViewController: UIViewController? {
        UIApplication.shared.connectedScenes
            .compactMap { $0 as? UIWindowScene }
            .first?
            .windows
            .filter { $0.isKeyWindow }
            .first?
            .rootViewController
    }
}
```
後は`AppDelegate`などで`ForceUpdateManager.shared.startObserving()`を一度呼ぶことで、Remote Configの変更をリアルタイムに取得することができます。

# 参考
https://firebase.google.com/docs/remote-config/get-started?platform=ios&hl=ja
https://qiita.com/Etsuwo/items/14c4698f17924923fc29
https://www.fuwamaki.com/article/258