Google Play の期限である8月31日を前に、手元のアプリを並べて「このアプリは期限までに新しいビルドを出し切れるか」を一本ずつ確かめていました。個人開発を長く続けていると、更新頻度の落ちたアプリが必ず何本か残ります。動いてはいるけれど、直近で触っていない。そういうアプリほど、いざビルドを通そうとすると依存の更新で足を取られます。
そこで頭を切り替えて、別の問いを立てました。もし期限に間に合わず、しばらく新しいビルドを出せない期間ができたとして、そのアプリの中身をどこまで遠隔から動かせるのか。
数え直してみると、想像していたより狭い範囲しか残っていませんでした。理由は単純で、遠隔から動かせるのは「配信済みの版が読む予定にしていた分岐」だけだからです。当たり前のことなのですが、更新が出せる前提で運用していると、この境界を意識せずに済んでしまいます。
期限を前に、遠隔で触れる範囲を数え直しました
期限そのものの整理はtarget API 36 の8月31日は、更新を出す人と出さない人で意味が違います に書いた通りで、更新を出す人と出さない人でリスクの現れ方が変わります。ここで扱いたいのは、その先の話です。更新が出せない状態に入ったとき、手元に残る操作系は何か。
私の場合、壁紙の追加はアプリ更新を伴わない構造にしてあります。画像をバンドルから切り離した設計判断は壁紙アプリのバイナリサイズを抑える — 画像をバンドルから切り離す設計判断 にまとめました。この構造のおかげで、コンテンツの供給は更新が止まっても続きます。
一方で、機能の停止・案内の掲出・最小サポート版の引き上げといった「運用の操作」は、遠隔設定を経由します。ここが今回の主題です。
コンソールに置いたキーは、それを読む版が配られていなければ存在しません
遠隔設定を導入していると、「後から何でも変えられる」という感覚が育ちます。実際には、変えられるのは配信済みのバイナリが getString("...") で読みに行く名前だけです。コンソール側に新しいパラメータを追加しても、それを参照するコードが入った版が端末に届いていなければ、その値は誰にも読まれません。
普段はこの制約が表に出ません。新しいキーを足したくなったら、そのキーを読むコードと一緒に次のリリースへ載せるからです。キーとコードが同じビルドで出ていく限り、ずれは起きません。
ずれが問題になるのは、リリース経路が閉じたときです。凍結期間に入ってから「この機能を止めたい」と思っても、止めるための分岐が配信済みの版に無ければ、コンソールでできることは何もありません。
つまり凍結前の最後のリリースは、その時点で必要な機能を積むだけでなく、その後しばらく使うことになるかもしれない操作系を先に積んでおく回 でもあります。使う予定のないスイッチをコードに入れるのは、普段なら避けたい判断です。凍結の可能性がある局面だけは、逆の判断が正しくなります。
凍結前の最後のビルドに積んだ4つのスイッチ
積むものを絞る作業でもあります。数を増やせば増やすほど、読み出しの分岐が増えて検証の面積が広がります。私は次の4つに落ち着きました。
1. 機能単位のキルスイッチ
不具合が見つかった機能を、画面ごと消すのではなく無効化して代替表示に落とす分岐です。停止対象を文字列の配列で受け取る形にしておくと、機能が増えても読み出し側を触らずに済みます。キルスイッチの命名や粒度はフィーチャーフラグが散らかる前に — 命名・段階公開・キルスイッチを6本横断で統治する設計 で整理した方針をそのまま使いました。
2. 最小サポートビルド番号
これ未満の版には案内を出す、という下限値です。凍結が明けたあとに古い版を切り離すための下ごしらえでもあります。バージョン文字列の比較は実装によって挙動が割れるため、私はビルド番号の整数比較に寄せました。文字列比較の落とし穴は1.10.0 の端末が強制アップデートに落ちる — 最小サポートバージョン判定を4実装で実測する で実測しています。
3. アセット配信のベース URL
配信元を差し替えられるようにしておくと、CDN 側の障害やパス構成の変更を、アプリ更新なしで吸収できます。ここは https:// で始まる文字列だけを受け付ける検証を入れました。
4. お知らせの本文
「不具合を確認しています」「対応版を準備しています」を出す枠です。凍結期間に一番使う可能性が高いのに、一番忘れられやすい枠でもあります。
個別キーを増やす設計と、ペイロード1本にする設計
一般的には、型付きのパラメータを個別に定義する形が推奨されます。コンソール上で意味が読めますし、値の型もそこで担保されます。
ただし凍結を前提にすると、この形は不利になります。新しい観点で操作したくなったときに、新しいキーを足せないからです。そこで私は、上の4つを1つの JSON 文字列として1本のキーに畳み、アプリ側でスキーマを緩く読む形にしました。表現力を配信済みの版の中に閉じ込めておく、という発想です。
観点 個別キー方式 ペイロード1本方式
コンソールでの可読性 高い (1行ずつ意味が見える)低い(JSON を読む必要がある)
凍結中の表現力 低い(新しいキーは届かない) 高い (既存の枠内で組み替えられる)
値の検証責任 コンソール側にも一部ある すべてアプリ側
壊れた値の影響 そのキーだけ 全体が既定値へ落ちる
向いている局面 通常のリリース運用 更新経路が閉じうる期間
「壊れた値の影響が全体に及ぶ」という欠点は、そのまま設計の要件になります。ペイロード1本に寄せるなら、パースは絶対に例外を投げてはいけません。読めない設定が届いた場合に必ず既定値へ落ちて、アプリは通常どおり起動する。ここを外すと、設定の配信ミスが起動不能に直結します。
実際に書いたパーサがこちらです。未知のキーは捨て、型が合わない値は既定値に戻します。
export const FLAG_DEFAULTS = {
schema: 1 ,
killedFeatures: [], // 停止したい機能キー
minSupportedBuild: 0 , // これ未満の版に案内を出す
assetBaseUrl: "https://assets.example.net/v1" ,
notice: null , // { id, title, body, url } | null
};
const isPlainObject = ( v ) =>
typeof v === "object" && v !== null && ! Array. isArray (v);
// 「読めない設定が来ても、必ず起動できる」ことを最優先にしている
export function parseFlags ( raw ) {
if ( typeof raw !== "string" || raw. length === 0 )
return { ... defaults (), source: "default" };
let obj;
try {
obj = JSON . parse (raw);
} catch {
return { ... defaults (), source: "parse-error" };
}
if ( ! isPlainObject (obj)) return { ... defaults (), source: "shape-error" };
// schema を上げれば、旧版はまとめて既定値に落ちる(意図的な退避経路)
if (obj.schema !== FLAG_DEFAULTS .schema)
return { ... defaults (), source: "schema-mismatch" };
const out = { ... defaults (), source: "remote" };
if (Array. isArray (obj.killedFeatures)) {
out.killedFeatures = obj.killedFeatures. filter (( k ) => typeof k === "string" );
}
if (Number. isInteger (obj.minSupportedBuild) && obj.minSupportedBuild >= 0 ) {
out.minSupportedBuild = obj.minSupportedBuild;
}
if ( typeof obj.assetBaseUrl === "string" && / ^ https: \/\/ / . test (obj.assetBaseUrl)) {
out.assetBaseUrl = obj.assetBaseUrl;
}
if ( isPlainObject (obj.notice) && typeof obj.notice.id === "string" ) {
out.notice = {
id: obj.notice.id,
title: typeof obj.notice.title === "string" ? obj.notice.title : "" ,
body: typeof obj.notice.body === "string" ? obj.notice.body : "" ,
url:
typeof obj.notice.url === "string" && / ^ https: \/\/ / . test (obj.notice.url)
? obj.notice.url
: null ,
};
}
return out;
}
source を戻り値に含めているのは、デバッグメニューで「いま読んでいる設定がどこから来たか」を見えるようにするためです。凍結中は、設定が効いていないのか、そもそも届いていないのかの区別が最初の分岐点になります。
手元で7パターンを流した結果は次の通りでした。型が混在した入力では、文字列でないキーが落ち、"412" という文字列のビルド番号は既定値の 0 に戻り、http:// のベース URL は採用されませんでした。
入力 source killedFeatures minSupportedBuild
空文字 default [] 0
壊れた JSON parse-error [] 0
配列が届いた shape-error [] 0
schema 違い schema-mismatch [] 0
正常 remote ["widget-refresh"] 412
型が混在 remote ["ok"] 0
未知キー混入 remote [] 0
既定値を共有したまま返したら、二度目の読み出しが汚れました
最初に書いた版では、既定値を { ...FLAG_DEFAULTS } で展開して返していました。浅いコピーなので、配列は同じ実体を指したままです。
この状態で、呼び出し側が受け取った killedFeatures に一度でも要素を追加すると、以後の「既定値」が汚染されます。確かめたところ、追加した文字列がそのまま次回の既定値として返ってきました。
defaults after mutation: ["accidental"]
second parse: ["accidental"]
普段の運用ではまず表面化しません。設定が正常に届いている限り、既定値の配列は差し替えられて捨てられるからです。表面化するのは、設定の取得に失敗して既定値へ落ちた回、つまり最も守りたい経路 だけです。凍結中に遠隔設定へ依存を寄せた状態でこれを踏むと、停止したはずのない機能が停止対象として残り続けます。
修正は既定値を凍結して、返すたびに新しい実体を作るだけです。
const FLAG_DEFAULTS = Object. freeze ({
schema: 1 ,
killedFeatures: Object. freeze ([]),
minSupportedBuild: 0 ,
assetBaseUrl: "https://assets.example.net/v1" ,
notice: null ,
});
// 呼び出し側がどう扱っても、次回の既定値は汚れない
function defaults () {
return structuredClone ({
... FLAG_DEFAULTS ,
killedFeatures: [ ... FLAG_DEFAULTS .killedFeatures],
});
}
Object.freeze を併用しているのは、うっかり既定値そのものを書き換えるコードが混ざったときに、黙って通さないためです。ES モジュールは strict モードで評価されるため、凍結した配列への push はその場で TypeError になります。手元で確認したところ、静かに無視されるのではなく例外として表に出ました。設定まわりの事故は、静かに間違うより早く落ちるほうが助かります。
参照キーの照合は、HEAD ではなく配信済みのタグに対して行います
「コンソールにあるキー」と「コードが読むキー」を突き合わせる検証は、遠隔設定を使うなら早めに置いておく価値があります。ただし凍結を意識する場合、照合の相手を間違えると意味が反転します。
HEAD のソースを見て「このキーは参照されている」と判断しても、そのコードがまだリリースされていなければ、端末上の版はそのキーを読みません。照合すべきは、いま配信されている版のタグ です。
import { readFileSync, readdirSync, statSync } from "node:fs" ;
import { join, extname } from "node:path" ;
const SRC = process.argv[ 2 ] ?? "src" ; // 配信済みタグを checkout した木を渡す
const EXPORT = process.argv[ 3 ] ?? "console-export.json" ;
const KEY_RE =
/remoteConfig \. get(?:String | Boolean | Number | Value) \( \s * ["'`] ( [\w.-] + ) ["'`]\s * \) / g ;
function walk ( dir , acc = []) {
for ( const name of readdirSync (dir)) {
const p = join (dir, name);
if ( statSync (p). isDirectory ()) walk (p, acc);
else if ([ ".ts" , ".tsx" , ".js" , ".jsx" ]. includes ( extname (p))) acc. push (p);
}
return acc;
}
const referenced = new Map ();
for ( const file of walk ( SRC )) {
for ( const m of readFileSync (file, "utf8" ). matchAll ( KEY_RE )) {
if ( ! referenced. has (m[ 1 ])) referenced. set (m[ 1 ], []);
referenced. get (m[ 1 ]). push (file);
}
}
const declared = new Set (
Object. keys ( JSON . parse ( readFileSync ( EXPORT , "utf8" )).parameters ?? {})
);
const missing = [ ... referenced. keys ()]. filter (( k ) => ! declared. has (k));
const orphan = [ ... declared]. filter (( k ) => ! referenced. has (k));
console. log ( `参照キー ${ referenced . size } / コンソール側 ${ declared . size }` );
for ( const k of missing)
console. log ( ` ✗ コンソールに無い: ${ k } (${ referenced . get ( k ). join ( ", " ) })` );
for ( const k of orphan) console. log ( ` ⚠ 誰も読んでいない: ${ k }` );
process.exitCode = missing. length > 0 ? 1 : 0 ;
小さなサンプルで流すと、次のように出ます。
参照キー 4 / コンソール側 4
✗ コンソールに無い: enable_new_gallery (src/useFlags.ts)
⚠ 誰も読んでいない: winter_campaign
数が一致していても中身がずれている、という状態が一目で分かります。使い方の要点は、git worktree add ../shipped v2.1.0 のように配信済みのタグを別の木へ展開し、その src を渡すことです。凍結期間中は、この検証の入力が固定されます。参照キーの一覧が変わらないという事実そのものが、凍結中に触れる範囲の定義になります。
なお ⚠ 誰も読んでいない の側は、通常運用では「消し忘れ」を意味しますが、凍結前だけは意味が変わります。これから使う予定で先に置いたキーが、まだコードに入っていないという警告として読むべき場面があります。
取得と反映のタイミングは、凍結中ほど取り返しがつきません
遠隔設定は、値を保存した瞬間に端末へ届くわけではありません。Firebase Remote Config の場合、本番向けの最小取得間隔は既定で12時間です。この間隔を短くする設定は開発時のためのもので、本番でそのまま使うとスロットリングに当たります。
さらに、取得と反映は別の操作です。起動直後に取得を投げて即座に反映しようとすると、その回の起動では間に合わず、次の起動から効くという挙動になりがちです。段階公開を5%から始めるような慎重な出し方と組み合わせると、「配ったつもりの設定が、まだどの端末でも効いていない」時間帯が生まれます。
凍結中はここが効いてきます。ビルドを出せる期間なら、設定の反映が遅れても最悪はホットフィックスで追い越せます。更新経路が閉じている期間には、その追い越しがありません。私は次の順序を守るようにしました。
変更前に、現在値を JSON ファイルとして手元に保存する
検証用の条件(テスト端末のみに配る条件)で先に配り、デバッグメニューの source が remote に変わることを確認する
全体へ広げたあと、最低でも1回のコールドスタートを挟んで実機で確認する
効き始めた時刻を記録し、次回の見積もりに使う
4番目は地味ですが、凍結が長引くほど効いてきます。「保存してから実際に効くまで、自分のアプリではどれくらいかかるか」を一度でも測っておくと、慌てて二重に配る事故が減ります。フィーチャーフラグ側の運用設計はFeature Flags を運用に耐えさせる — Rork Max のキルスイッチと段階的ロールアウト設計 にまとめた考え方をそのまま持ち込んでいます。
壊れた設定を配ってしまったときの戻り方
パーサが例外を投げない設計にしてあれば、壊れた JSON を配っても既定値に落ちるだけで済みます。問題は「文法としては正しいが、内容が誤っている」場合です。停止すべきでない機能キーを killedFeatures に入れてしまった、といった種類の事故がこれにあたります。
戻す手段は2つあります。1つは保存しておいた前回の値をそのまま貼り戻すこと。もう1つは schema を意図的に別の値へ上げて、全端末をまとめて既定値へ退避させることです。後者は乱暴に見えますが、既定値が安全側に倒れている 設計であれば、最も速く確実な退避経路になります。冒頭のパーサで schema 不一致を素通りさせずに既定値へ落としているのは、この退避経路を残すためです。
退避したあとに落ち着いて直せる、という状態を作れるかどうかが、凍結期間の心理的な余裕をかなり左右します。
凍結が明けたあとに残るもの
先回りして積んだスイッチは、期限を越えたあとも残ります。使わなかったスイッチは、そのまま分岐の負債になります。凍結が明けて通常のリリース経路が戻ったら、参照キーの照合スクリプトを HEAD に対して走らせ直し、⚠ 誰も読んでいない に出たキーを片付ける番です。
私自身、今回の棚卸しで「遠隔設定があるから大丈夫」と思い込んでいた部分がいくつも崩れました。動かせる範囲は、設定画面の側ではなく、端末に届いているコードの側で決まっています。この向きを取り違えていた期間が長かったぶん、数え直した意味はありました。
まずは自分のプロジェクトで、配信済みのタグを別の木に展開し、参照キーの一覧を出してみてください。そこに並んだ名前が、更新が止まった日に自分に残される操作系のすべてです。ここまでお読みいただき、ありがとうございました。