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

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


Remote Config


ユーザデータとサーバ設定の両方を、実機由来の組み合わせ + ローカル調整した値で一気に再現できる、というのが今回の狙いでした。
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
);
namespace は FirebaseApp.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... を選んで戻します。

これで実機側のアプリには、ユーザデータはそのままに 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 です。

サーバの値を弄ってもチームに気付かれない、というのが 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 テーブルを更新しません。activate で main_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 差し替えの一連の流れにそのまま組み込めて便利でした。