Data Container 差し替えで Remote Config の値も一緒に書き換える

はじめに

先日 Data Container を実機からシミュレータへ を書いたあと、実機の状態を持ち込めるなら Remote Config の値もこのタイミングで差し替えたいな、と思って試した記録です。Firebase Console の値はそのままに、ローカルだけ別の分岐で動かしたい場面で便利でした。

Firebase Console の Remote Config パラメータ一覧

Console 側は test_abtest = b のままにしておきます。

1 回の Replace で何が起きるか

実機から Download Container で抜いた .xcappdata の中で、TODO のデータはそのまま使い、Remote Config の test_abtest だけ c に書き換えて Replace Container で戻しました。これ 1 回の操作で、SwiftData の TODO データと Remote Config の値の両方が同時に置き換わります。

TODO List

Before
差し替え前。TODO List は空
After
差し替え後。テスト 1 とテスト 2 が並んでいる

Remote Config

Before
差し替え前。test_abtest は b
After
差し替え後。test_abtest が c に変わっている

ユーザデータとサーバ設定の両方を、実機由来の組み合わせ + ローカル調整した値で一気に再現できる、というのが今回の狙いでした。

Remote Config は Data Container の中にある

Firebase iOS SDK は Remote Config の値を Application Support 配下に SQLite ファイルとして置きます。.xcappdata の中にもそのまま入っています。

<App Container>/AppData/Library/Application Support/Google/RemoteConfig/RemoteConfig.sqlite3

アプリが実際に読むのは main_active テーブルです。スキーマはこのくらいシンプルでした。

CREATE TABLE main_active (
  _id INTEGER PRIMARY KEY,
  bundle_identifier TEXT,
  namespace TEXT,
  key TEXT,
  value BLOB
);

namespaceFirebaseApp.configure() の既定インスタンスなら firebase:__FIRAPP_DEFAULT 固定です。value は BLOB ですが、実態は UTF-8 文字列のバイト列なので、Bool でも数値でも文字列として書き換えれば SDK 側が解釈してくれていそうです。

書き換え手順

Window → Devices and Simulators で実機を選び、Installed Apps から TodoMemo を選択して メニューから Download Container... で抜きます。

抜いた .xcappdata の中の sqlite に対して、sqlite3 CLI で UPDATE を投げるだけです。

DB="path/to/xxx.xcappdata/AppData/Library/Application Support/Google/RemoteConfig/RemoteConfig.sqlite3"

sqlite3 "$DB" "UPDATE main_active SET value = CAST('c' AS BLOB) WHERE key = 'test_abtest';"

念のため main テーブルにも同じ key の行があれば一緒に UPDATE しておくと、次の activate()main の値が main_active を上書きする事故を防げます。

書き換えが済んだら、同じメニューから Replace Container... を選んで戻します。

Xcode の Devices and Simulators で Replace Container を選んでいる画面

これで実機側のアプリには、ユーザデータはそのままに Remote Config だけ書き換えた状態が流し込まれます。

アプリ側に閲覧専用の確認画面を入れておく

毎回 sqlite を直接覗くのは面倒なので、DEBUG ビルドにだけ main_active を一覧表示する画面を入れています。書き換えは sqlite 側に任せて、アプリからは値を変えられないようにしました。アプリ内に書き換え動線があると DEBUG 環境で事故りやすいので、確認は確認だけに絞った形です。

#if DEBUG
struct DebugRemoteConfigView: View {
    @State private var entries: [RemoteConfigSQLiteEditor.Entry] = []

    var body: some View {
        List {
            Section("main_active (\(entries.count))") {
                ForEach(entries) { entry in
                    VStack(alignment: .leading, spacing: 2) {
                        Text(entry.key).font(.caption.bold())
                        Text(entry.value)
                            .font(.caption)
                            .monospaced()
                            .foregroundStyle(.secondary)
                    }
                }
            }
        }
        .navigationTitle("Remote Config")
        .task { await load() }
    }

    private func load() async {
        entries = await Task.detached(priority: .userInitiated) {
            (try? RemoteConfigSQLiteEditor.loadActiveValues()) ?? []
        }.value
    }
}
#endif

SQLite の読み出しは Task.detached でメインアクター外に逃がしているので、View 表示中にメインスレッドが I/O でブロックされません。.task を使っているので、View が消えたタイミングで自動的にキャンセルされます。

Replace Container 直後にこの画面を開けば、流し込んだ値が main_active にちゃんと反映されているか一目で確認できます。

関連: サーバ側の変更を Slack に流しておく

ローカルだけ書き換える運用と裏表ですが、Firebase Console 側の変更を見落とさないよう Slack に通知を流す仕組みも別で動かしています。onConfigUpdated で前バージョンとの差分を json-diff で文字列化して、Slack の Block Kit に貼るだけのシンプルな Cloud Functions です。

Slack に届いた Remote Config 更新通知。バージョン、更新者、更新時刻、変更内容の diff が並んでいる

サーバの値を弄ってもチームに気付かれない、というのが Console まわりの地味な落とし穴なので、書き換えと通知をセットで持っておくと安心感があります。

Cloud Functions のコード抜粋(クリックで展開)
const {onConfigUpdated} = require("firebase-functions/v2/remoteConfig");
const {getRemoteConfig} = require("firebase-admin/remote-config");
const jsonDiff = require("json-diff");

exports.remoteConfigUpdate = onConfigUpdated(async (event) => {
  const rc = getRemoteConfig(app);
  const current = await rc.getTemplateAtVersion(event.data.versionNumber);
  const previous = event.data.versionNumber > 1
    ? await rc.getTemplateAtVersion(event.data.versionNumber - 1)
    : { conditions: [], parameters: {}, parameterGroups: {} };

  [previous, current].forEach((t) => { delete t.etagInternal; delete t.version; });
  const diff = jsonDiff.diffString(previous, current, {maxElisions: 0});

  const version = event.data;
  const payload = {
    blocks: [
      { type: "header", text: { type: "plain_text", text: "🔄 Firebase Remote Config が更新されました" } },
      { type: "section", fields: [
        { type: "mrkdwn", text: `*バージョン:* ${version.versionNumber}` },
        { type: "mrkdwn", text: `*更新タイプ:* ${version.updateType || "増分更新"}` },
        { type: "mrkdwn", text: `*更新元:* ${version.updateOrigin || "不明"}` },
        { type: "mrkdwn", text: `*更新者:* ${(version.updateUser && version.updateUser.email) || "不明"}` },
      ]},
      { type: "divider" },
      { type: "section", text: { type: "mrkdwn", text: `*📋 変更内容:*\n\`\`\`\n${diff}\`\`\`` } },
    ],
  };

  await fetch(process.env.SLACK_INCOMING_WEBHOOK, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(payload),
  });
});

onConfigUpdated は Firebase Functions v2 のトリガーで、Remote Config のバージョンが上がるたびに発火します。差分は getTemplateAtVersion で前バージョンを引いてくるだけで取れます。

注意点

RemoteConfigSettings.minimumFetchInterval = 0 のまま fetchAndActivate を呼ぶと、書き換えた値がサーバの値で上書きされてしまいます。差し替えで試している間は fetch を止めるか、interval を大きめに取るのが現実的です。

addOnConfigUpdateListener でリアルタイムリスナーを登録している場合も同じ理由で要注意です。サーバ側で値が更新されるとハンドラに通知が届きます。SDK が自動で activate するわけではないものの、通知ハンドラ内で activate を呼ぶ実装が一般的なので、結果として書き換えた値がサーバの値に戻ってしまいます。書き換えを試している間はサーバ側の値を更新しないようにしておくと安心です。

それから、テーブル名やスキーマは公式 API ではないので SDK 更新で変わる可能性があります。今回確認したのは Firebase iOS SDK 12.3.0 です。DEBUG ビルド専用にしておくのも忘れずに。

もう少し細かい話: SDK が実際に見ているもの

minimumFetchInterval の経過判定に使われるのは SQLite ではなく NSUserDefaults で、.xcappdata の中の次の plist にある lastSuccessfulFetchTime を SDK が見ています。

<App Container>/Library/Preferences/group.<bundle-id>.firebase.plist
  __FIRAPP_DEFAULT
    firebase
      lastSuccessfulFetchTime  ← interval 判定はこれ
      lastETag

fetch が走った場合も、lastETag がサーバ側と一致していれば SDK は main テーブルを更新しません。activatemain_active にコピーされる値も変わらず、ローカルで書き換えた値はそのまま残ります。「サーバ側を更新しなければローカル書き換えは安全に残る」と言えるのは、この etag マッチが効いているからです。

lastSuccessfulFetchTime を未来に倒して fetch を止める

もう一段確実に止めたい場合は、lastSuccessfulFetchTime を未来時刻に書き換えてしまう方法があります。SDK の interval 判定は (now - lastSuccessfulFetchTime) > minimumFetchInterval という単純な引き算なので、未来時刻なら差分が負数になり、fetch がスキップされます。

PLIST="$XCAPPDATA/AppData/Library/Preferences/group.<bundle-id>.firebase.plist"

# 1 年後に倒す
FUTURE=$(($(date +%s) + 365 * 86400))
plutil -replace "__FIRAPP_DEFAULT.firebase.lastSuccessfulFetchTime" -float "$FUTURE" "$PLIST"

RemoteConfigSettings.minimumFetchInterval = 0 だとこの手法は無効化されるので、DEBUG ビルドでは 3600 などを設定しておくと安心です。

まとめ

Data Container を持ち込めば実機の状態はだいたい再現できますが、Remote Config だけは差し替えの前後で少し弄りたくなる場面があります。main_active を 1 回 UPDATE するだけで済むので、Data Container 差し替えの一連の流れにそのまま組み込めて便利でした。

参考リンク