[Swift] リアルタイム更新に対応したRemoteConfigを導入する

## はじめに

リアルタイム更新に対応したFirebase RemoteConfigを導入したくドキュメントを読みながら実装しました。

## 環境

Xcode 14.3

## 内容

Firebaseはセットアップ済みの状態を想定しRemoteConfig部分のみまとめていきます。

**RemoteConfigとは**
> Firebase Remote Config を使ってアプリ内パラメータを定義し、その値をクラウドで更新できます。これにより、アプリのアップデートを配布しなくてもアプリの外観や動作を変更できます。

設定できる値には以下のような制限がある

**パラメータ値のデータ型**
- `String`
- `Boolean`
- `Number`
- `JSON`

**パラメータと条件の制限**
- 最大`2,000`個のパラメータ、最大`500`個の条件を設定できる
- パラメータキーの長さは最大`256`文字
- プロジェクト内のパラメータ値文字列の合計長は、`1,000,000`文字以内にする必要がある


基本はドキュメント通りやっていきます

https://firebase.google.com/docs/remote-config/get-started?hl=ja&platform=ios

### Remote Config をアプリに追加

まずはSPMを使って`FirebaseRemoteConfigSwift`を取得
<img width="400" alt="" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/631585/cf27000c-9ee4-e94d-37fa-c9932986c43b.png">

RemoteConfigを扱うクラスにimport

```swift
import FirebaseRemoteConfig
import FirebaseRemoteConfigSwift
```

Remote Config シングルトン オブジェクトを作成

```swift
remoteConfig = RemoteConfig.remoteConfig()
let settings = RemoteConfigSettings()
settings.minimumFetchInterval = 0
remoteConfig.configSettings = settings
```

ここで設定する`minimumFetchInterval`とはRemoteConfigの値を取得する間隔ですが、リアルタイム更新に対応すれば不要になる

> Apple platforms SDK v10.7.0 以降では、リアルタイムの Remote Config を使用して、更新されたパラメータ値が公開されるとすぐに Remote Config バックエンドから自動的にフェッチできます。これにより、最小フェッチ間隔の設定が不要になります。

https://firebase.google.com/docs/remote-config/get-started?platform=ios&hl=ja#throttling

フェッチ間隔の優先順位は以下の順番

1. fetch(long) のパラメータ
1. FIRRemoteConfigSettings.MinimumFetchInterval のパラメータ
1. デフォルト値（12 時間）

サンプルコードのセットアップ部分を抜き出すとこのような形

https://github.com/firebase/quickstart-ios/blob/eea92b549ae4f649bf15e37b9865a86d7fc089de/config/ConfigExample/RemoteConfigViewController.swift#L19

```swift
import FirebaseRemoteConfig
import FirebaseRemoteConfigSwift

class RemoteConfigViewController: UIViewController {
  private var remoteConfig: RemoteConfig!
  private var remoteConfigView: RemoteConfigView

  /// Convenience init for injecting Remote Config instances during testing
  /// - Parameter remoteConfig: a Remote Config instance
  convenience init(remoteConfig: RemoteConfig) {
    self.init()
    self.remoteConfig = remoteConfig
  }

  override func viewDidLoad() {
    super.viewDidLoad()
    setupRemoteConfig()
  }

  /// Initializes defaults from `RemoteConfigDefaults.plist` and sets config's settings to developer mode
  private func setupRemoteConfig() {
    remoteConfig = RemoteConfig.remoteConfig()
    let settings = RemoteConfigSettings()
    settings.minimumFetchInterval = 0
    remoteConfig.configSettings = settings
    ...
  }
}
```

### アプリ内デフォルトパラメータ値を設定する
この対応は必須ではないのでスキップでもOK

`NSDictionary`または`plist`を使用して、一連のパラメータ名とデフォルトパラメータ値を定義しておくと、Remote Configバックエンドに接続する前もしくは値が設定されていない場合にデフォルト値を使用できる。

plistはRemoteConfigのコンソールから取得できる
<img width="600" alt="" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/631585/d2dce000-598f-6904-e695-af66c9189362.png"><img width="300" alt="" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/631585/492cd63d-222d-10f5-d1f7-3a062c3eee3c.png">

**アプリ内でパラメータ値がgetメソッドによって返される優先順位**

> 1. バックエンドから値がフェッチされ、有効化された場合、アプリはそのフェッチされた値を使用します。有効化されたパラメータ値は永続的です。
> 1. バックエンドから値がフェッチされなかった場合、または Remote Config バックエンドからフェッチされた値が有効化されていない場合、アプリはアプリ内デフォルト値を使用します。
> 1. アプリ内デフォルト値が設定されていない場合、アプリは静的型の値（int の場合は 0、boolean の場合は false など）を使用します。

サンプルコードの`serDefaults`部分

```swift
  private func setupRemoteConfig() {
    remoteConfig = RemoteConfig.remoteConfig()
    // This is an alternative to remoteConfig.setDefaults(fromPlist: "RemoteConfigDefaults"))
    do {
      try remoteConfig.setDefaults(from: QSConfig(topLabelKey: "myTopLabel",
                                                  bottomLabelKey: "Buy one get one free!",
                                                  freeCount: 4))
    } catch {
      print("Failed to set Defaults.")
    }
    ...
  }
```

### 値をフェッチして有効にする

Remote Config からパラメータ値をフェッチするには、`fetchWithCompletionHandler:`または`fetchWithExpirationDuration:completionHandler:`メソッドを呼び出す。バックエンドに設定したすべての値がフェッチされ、Remote Config オブジェクトにキャッシュ保存される。

1 回の呼び出しで値をフェッチして有効化する場合は、`fetchAndActivateWithCompletionHandler:`を使用します。

```swift
remoteConfig.fetch { (status, error) -> Void in
  if status == .success {
    print("Config fetched!")
    self.remoteConfig.activate { changed, error in
      // ...
    }
  } else {
    print("Config not fetched")
    print("Error: \(error?.localizedDescription ?? "No error available.")")
  }
  self.displayWelcome()
}
```

3つのfetchメソッド👇

https://firebase.google.com/docs/reference/swift/firebaseremoteconfig/api/reference/Classes/RemoteConfig#/Fetch

- `func fetch() async throws -> RemoteConfigFetchStatus`
データを取得。`activate()`することで取得したデータがアプリで使用できる

- `func fetch(withExpirationDuration expirationDuration: TimeInterval) async throws -> RemoteConfigFetchStatus`
データを取得し、設定データの存続期間を指定する期間を設定。`activateWithCompletion:`することで取得したデータがアプリで使用できる

- `func fetchAndActivate() async throws -> RemoteConfigFetchAndActivateStatus`
データを取得し、成功した場合は取得したデータをアクティブにします。フェッチ呼び出しが成功した場合、データのアクティブ化を試行した後、オプションの完了ハンドラー コールバックが呼び出される

ドキュメントとは別ですが、サンプルコードの実装はこのような形になっていた

https://github.com/firebase/quickstart-ios/blob/eea92b549ae4f649bf15e37b9865a86d7fc089de/config/ConfigExample/RemoteConfigViewController.swift#L92

```swift
  func fetchAndActivateRemoteConfig() {
    remoteConfig.fetchAndActivate { status, error in
      guard error == nil else { return self.displayError(error) }
      print("Remote config successfully fetched & activated!")
      do {
        let qsConfig: QSConfig = try self.remoteConfig.decoded()
        print(qsConfig)
      } catch {
        self.displayError(error)
        return
      }
      DispatchQueue.main.async {
        self.updateUI()
      }
    }
  }
```


### リアルタイムで更新をリッスンする

パラメータ値をフェッチしたら、リアルタイム更新を使用して、バックエンドからの更新をリアルタイムでリッスンできる

```swift
remoteConfig.addOnConfigUpdateListener { configUpdate, error in
  guard let configUpdate, error == nil else {
    print("Error listening for config updates: \(error)")
  }

  print("Updated keys: \(configUpdate.updatedKeys)")

  self.remoteConfig.activate { changed, error in
    guard error == nil else { return self.displayError(error) }
    DispatchQueue.main.async {
      self.displayWelcome()
    }
  }
}
```

ほぼほぼドキュメントと変わりませんが、サンプルコードでこのような実装になっていた

https://github.com/firebase/quickstart-ios/blob/eea92b549ae4f649bf15e37b9865a86d7fc089de/config/ConfigExample/RemoteConfigViewController.swift#L75

```swift
  /// Initializes defaults from `RemoteConfigDefaults.plist` and sets config's settings to developer mode
  private func setupRemoteConfig() {
    remoteConfig = RemoteConfig.remoteConfig()
    // This is an alternative to remoteConfig.setDefaults(fromPlist: "RemoteConfigDefaults"))
    do {
      try remoteConfig.setDefaults(from: QSConfig(topLabelKey: "myTopLabel",
                                                  bottomLabelKey: "Buy one get one free!",
                                                  freeCount: 4))
    } catch {
      print("Failed to set Defaults.")
    }
    let settings = RemoteConfigSettings()
    settings.minimumFetchInterval = 0
    remoteConfig.configSettings = settings

    // [START add_config_update_listener]
    remoteConfig.addOnConfigUpdateListener { configUpdate, error in
      guard error == nil else { return self.displayError(error) }
      print("Updated keys: \(configUpdate!.updatedKeys)")

      self.remoteConfig.activate { changed, error in
        guard error == nil else { return self.displayError(error) }
        DispatchQueue.main.async {
          self.updateUI()
        }
      }
    }
    // [END add_config_update_listener]
  }
```

## おわりに
もともとアプリの運用で非常に便利であったRemoteConfigですが、リアルタイム更新が可能になったことで、より活用の幅が広がったのかなと思います！セットアップも簡単なので個人開発アプリなどでも初回リリースから入れていきたいところです

それと今回ドキュメントを読んでいて`Remote Config の更新公開時に Slack /メール メッセージを送信する`方法について書かれていた部分があり、これは業務でも使えそうだな〜と思ったのでこの部分はあらためて読んでみたいと思います

https://firebase.google.com/docs/remote-config/use-cases?hl=ja#send_slack_email_message_when_a_update_is_published

## 参考

https://firebase.google.com/docs/remote-config?hl=ja

https://firebase.google.com/docs/reference/swift/firebaseremoteconfig/api/reference/Classes