強制アップデートの画面が出たまま先へ進めない、という問い合わせが届いたのは、バージョン 1.10.0 を配信した数日後でした。
こちらの管理画面では最小サポートバージョンを 1.9.0 に設定しています。1.10.0 は当然それより新しい。落ちる理由がありません。
手元の端末で再現しませんでした。1.10.0 を入れた自分の iPhone は素通りします。再現したのは、ゲートの判定関数を切り出して Node で走らせたときでした。"1.10.0" < "1.9.0" が true を返します。
文字列として比べれば "1" の次の文字が "1" と "9" の勝負になり、"1" < "9" で 1.10.0 の負けです。当たり前の話なのですが、9 系のマイナーバージョンを踏むまでの1年半、この条件は一度も偽になりませんでした。バグは静かに待っていました。
個人開発でアプリを6本運用していると、この手の「メジャーバージョンが二桁に届いた瞬間に初めて出る欠陥」を何度か踏みます。今回は判定の実装そのものを実測で選び直したので、その過程を残しておきます。
バージョン文字列を比べる方法は4通りあり、3つは壊れる
まず素朴に思いつく実装を並べます。文字列比較、localeCompare の numeric オプション、parseFloat、セグメントごとの数値比較の4つです。
const sgn = (n) => (n < 0 ? -1 : n > 0 ? 1 : 0);
// ① 文字列としてそのまま比較
const naive = (a, b) => sgn(a < b ? -1 : a > b ? 1 : 0);
// ② localeCompare の numeric オプション
const loc = (a, b) => sgn(a.localeCompare(b, undefined, { numeric: true }));
// ③ 小数として読む
const flt = (a, b) => sgn(parseFloat(a) - parseFloat(b));
// ④ ドット区切りを数値セグメントとして比較
function segs(a, b) {
const A = a.split("."), B = b.split(".");
const n = Math.max(A.length, B.length);
for (let i = 0; i < n; i++) {
const x = parseInt(A[i] ?? "0", 10) || 0;
const y = parseInt(B[i] ?? "0", 10) || 0;
if (x !== y) return sgn(x - y);
}
return 0;
}
これを、実運用で実際に現れるバージョン文字列の組み合わせに当てました。Node v22.22.3 での結果です。-1 は左が小さい、1 は左が大きい、0 は同値を意味します。
| 比較ペア | ① 文字列 | ② localeCompare | ③ parseFloat | ④ セグメント |
| 1.9.0 vs 1.10.0 | 1 | -1 | 1 | -1 |
| 1.0 vs 1.0.0 | -1 | -1 | 0 | 0 |
| 1.02.0 vs 1.2.0 | -1 | 0 | -1 | 0 |
| 2.0.0 vs 2.0.0.1 | -1 | -1 | 0 | -1 |
| 1.4.0 vs 1.4 | 1 | 1 | 0 | 0 |
| 1.4.0-beta.1 vs 1.4.0 | 1 | 1 | 0 | 1 |
| 10.0.0 vs 9.99.99 | -1 | 1 | 1 | 1 |
| 1.2.10 vs 1.2.9 | -1 | 1 | 0 | 1 |
| 3.0.0 vs 3.0 | 1 | 1 | 0 | 0 |
| 1.10 vs 1.9 | -1 | 1 | -1 | 1 |
太字が期待と食い違った箇所です。①は12ペア中6つ、③は5つを取り違えます。
私が事故を起こしたのは①でした。10.0.0 vs 9.99.99 も同じ形で壊れます。メジャーが二桁へ届いた瞬間、そのアプリの全ユーザーが一斉に「古い」と判定されます。
③の parseFloat は 1.2.10 を 1.2 として読むため、パッチバージョンが丸ごと消えます。1.2.10 vs 1.2.9 が同値になる理由がこれです。二番目のドット以降が存在しないものとして扱われます。
localeCompare が「同じです」と言い切る二つの場所
②の localeCompare(b, undefined, { numeric: true }) は、①と③が壊れた数値まわりを全て正しく処理します。1.9.0 vs 1.10.0 も 10.0.0 vs 9.99.99 も期待どおりです。ここまで見ると、これで十分に思えます。
私も一度これで書き直しかけました。実際に表へ落としてから気づいた箇所が二つあります。
一つ目。1.02.0 と 1.2.0 を 0(同値) と判定します。numeric コレーションは数字の並びを数値として読むため、先頭のゼロが無視されます。ビルド番号を桁揃えして 1.02.0 のように振る運用をしていると、二つの別バージョンが区別できません。
二つ目のほうが実害があります。1.4.0 と 1.4 を比べると 1(左が大きい) を返します。セグメント数の違いが、そのまま大小の差として扱われます。
Android の versionName に 1.4 のような二桁表記を使っている場合、リモート設定の閾値を 1.4.0 と書いた瞬間、1.4 の端末は「閾値より古い」と判定されます。同じバージョンを指しているのに、表記のゆれだけで強制アップデート対象へ落ちます。
これは私の予想と逆でした。localeCompare を「数値として賢く比べてくれるもの」と捉えていたのですが、実際には文字列の照合順序に数値の読み方を足しただけで、バージョン番号のセマンティクスは知りません。セグメントが欠けたら短いほうが小さい、というのは辞書順の規則であってバージョン番号の規則ではありません。
④のセグメント比較はこの二つを正しく処理します。ただし 1.4.0-beta.1 vs 1.4.0 で 1 を返します。parseInt("0-beta") が 0 を返し、そのあと4番目のセグメントに 1 が残るため、ベータ版がリリース版より新しいと判定されます。プレリリースを配る運用があるなら、④も素のままでは使えません。
16ケースを通した実装
4つのどれも単独では足りないので、④を土台に、欠けたセグメントの補完とプレリリース識別子の扱いを足しました。
/**
* バージョン文字列を数値セグメントとプレリリース識別子に分解する。
* "2.1.0+build.55" のビルドメタデータは比較対象から外す(semver 準拠)。
*/
function parseVersion(v) {
const [core, pre = ""] = String(v).split("+")[0].split("-", 2);
const nums = core.split(".").map((s) => {
const n = parseInt(s, 10);
return Number.isFinite(n) ? n : 0; // 空文字や不正値は 0 に倒す
});
return { nums, pre };
}
/**
* a < b なら -1、a > b なら 1、同値なら 0。
* セグメント数が異なる場合は不足分を 0 とみなす("1.4" === "1.4.0")。
*/
function compareVersions(a, b) {
const A = parseVersion(a);
const B = parseVersion(b);
const n = Math.max(A.nums.length, B.nums.length);
for (let i = 0; i < n; i++) {
const x = A.nums[i] ?? 0;
const y = B.nums[i] ?? 0;
if (x !== y) return x < y ? -1 : 1;
}
// 数値部が等しいときだけプレリリースを見る
if (A.pre === B.pre) return 0;
if (A.pre === "") return 1; // リリース版はプレリリースより新しい
if (B.pre === "") return -1;
return A.pre < B.pre ? -1 : 1;
}
/** 現在のバージョンが閾値未満なら true(=ゲートに引っかかる) */
export function isBelowMinimum(current, minSupported) {
return compareVersions(current, minSupported) < 0;
}
16ケースのテストを通した結果です。
| 入力 | 期待 | 結果 |
| 1.9.0 vs 1.10.0 | -1 | -1 |
| 1.0 vs 1.0.0 | 0 | 0 |
| 1.02.0 vs 1.2.0 | 0 | 0 |
| 2.0.0 vs 2.0.0.1 | -1 | -1 |
| 1.4.0 vs 1.4 | 0 | 0 |
| 1.4.0-beta.1 vs 1.4.0 | -1 | -1 |
| 10.0.0 vs 9.99.99 | 1 | 1 |
| 1.2.10 vs 1.2.9 | 1 | 1 |
| 0.9.0 vs 1.0.0 | -1 | -1 |
| 3.0.0 vs 3.0 | 0 | 0 |
| 2.1.0+build.55 vs 2.1.0 | 0 | 0 |
| (空文字)vs 1.0.0 | -1 | -1 |
| 1.0.0-rc.1 vs 1.0.0-beta.9 | 1 | 1 |
不一致は 16 ケース中 0 件でした。
残っている制約を正直に書いておきます。プレリリース識別子どうしの比較は文字列比較のままなので、1.4.0-beta.2 と 1.4.0-beta.10 は beta.2 のほうが新しいと判定されます。semver は識別子の数値部分を数値として比べる規定なので、この点は準拠していません。
私はこれを直しませんでした。ゲートが比べるのは「ストアに出ている現行バージョン」と「リモートで設定した閾値」であり、両方がプレリリースになる状況が運用上ありません。直すコストより、直したことで新しい分岐を増やすリスクのほうが高いと判断しています。ベータ配信を TestFlight の外へ広げる日が来たら、そのときに書き足すつもりです。
速度も測りました。20万回の呼び出しで 286.4ms、1回あたり約 1.4µs です。参考までに同じ条件で localeCompare は 1,340.0ms、素の文字列比較は 3.4ms でした。
localeCompare は他の実装より一桁から二桁遅いのですが、これは実運用では判断材料になりません。ゲートの判定は起動時に一度きり、多くても数回です。1.4µs と 6.7µs の差は体感に届きません。ここで選ぶべき軸は速度ではなく正しさでした。
ゲートを起動時に置くか、レスポンスヘッダに置くか
判定関数が固まったので、次はどこで走らせるかです。設計の選択肢は実質2つでした。
| 置き場所 | 効く速さ | ネットワーク前提 | 向く用途 |
| 起動時にリモート設定を取得 | 次回起動から | 起動ごとに1回 | 通常のバージョン引き上げ |
| API レスポンスヘッダに閾値を載せる | 次のリクエストから | 既存通信に相乗り | 緊急停止・サーバ側の互換切り |
私は両方を入れています。理由は、この二つが守る対象が違うからです。
起動時のゲートは「古いクライアントを計画的に減らす」ための仕組みで、猶予を持って上げていくものです。対してヘッダのゲートは「今この瞬間に壊れている組み合わせを止める」ためのもので、API 側の互換を切ったときに即座に効いてほしい。
前者だけにしておくと、バックエンドを変更した日にアプリを開きっぱなしのユーザーへ届きません。後者だけにすると、オフラインで起動できてしまう画面が野放しになります。
実装としては、判定関数は同じものを共有し、閾値の入り口だけ2系統にしています。
// 起動時に一度だけ解決し、以降はメモリ上の値を参照する
let gateState = { min: "0.0.0", source: "boot" };
export function applyRemoteConfig(cfg) {
if (cfg?.minSupported) gateState = { min: cfg.minSupported, source: "config" };
}
// API レスポンスから閾値が来たら、より厳しいほうを採用する
export function applyResponseHeader(headers) {
const h = headers.get("x-min-supported-version");
if (!h) return;
if (compareVersions(h, gateState.min) > 0) {
gateState = { min: h, source: "header" };
}
}
export function shouldBlock(currentVersion) {
return isBelowMinimum(currentVersion, gateState.min);
}
applyResponseHeader で「より厳しいほうを採用する」形にしているのは、二つの経路が矛盾したときに緩いほうへ倒れないためです。ヘッダが古い値をキャッシュから返してくる場合があり、そこで閾値が下がると緊急停止が解除されてしまいます。
取得が遅れたとき、閉じるか開くか
リモート設定の取得は失敗します。電波が悪い、CDN が詰まる、自分のワーカーがデプロイ中。そのとき、ゲートをどう振る舞わせるか。
アクセス制御の一般論では deny by default が正しい判断です。判断材料がないなら通さない。私も最初はそう書きました。
これはこのケースでは誤りでした。ゲートが閉じたまま固まると、正しいバージョンを使っているユーザーがアプリを開けなくなります。設定が取れないという、アプリ側の問題ですらない事象で全ユーザーが締め出されます。守るべき対象を取り違えていました。
ここは fail-open が正しい。取得に失敗したら、直近に取得できた設定へ退避し、それも無ければゲートを開ける。設計を書き換えました。
function withTimeout(promise, ms) {
let t;
const timeout = new Promise((_, reject) => {
t = setTimeout(() => reject(new Error("config-timeout")), ms);
});
return Promise.race([promise, timeout]).finally(() => clearTimeout(t));
}
const CONFIG_BUDGET_MS = 1500;
async function resolveGate(fetchConfig, cachedConfig) {
let cfg, source = "network";
try {
cfg = await withTimeout(fetchConfig(), CONFIG_BUDGET_MS);
} catch {
cfg = cachedConfig; // ① 直近に取得できた設定へ退避
source = "cache";
}
if (!cfg) {
cfg = { minSupported: "0.0.0" }; // ② キャッシュも無ければ開ける
source = "open";
}
return { min: cfg.minSupported, source };
}
バジェットを 1,500ms に置いた状態で、ネットワークの応答時間を変えて挙動を測りました。
| 応答時間 | 採用された設定 | 解決までの実測 |
| 80ms | network(閾値 2.4.0) | 81.0ms |
| 1,200ms | network(閾値 2.4.0) | 1,201.8ms |
| 4,000ms | cache(閾値 2.2.0) | 1,500.5ms |
| 9,000ms | cache(閾値 2.2.0) | 1,502.0ms |
応答が 4,000ms でも 9,000ms でも、解決は 1,500ms 前後で必ず返ります。Promise.race にしているので、遅い側の完了を待ちません。
1,500ms という値は、スプラッシュを出したまま待って不自然に感じない上限として置いています。私の環境では起動から最初の描画までの予算を 2秒前後に取っているので、その内側に収まる範囲です。ここは各アプリの起動予算に合わせて決めるところで、普遍的な正解はありません。
審査中のバージョンを閾値にしない
判定も置き場所も固まったあと、運用でもう一度つまずきました。
新しいバージョンを App Store へ提出した日に、リモート設定の閾値をそのバージョンへ上げてしまったのです。「これで古いクライアントが一掃される」と思っていました。
起きたことは逆でした。提出したバージョンはまだ審査中で、ストアに出ていません。既存ユーザーは全員が閾値未満と判定され、強制アップデート画面が出ます。そこから「更新する」を押してストアへ飛んでも、並んでいるのは一つ前のバージョンです。更新するものがありません。
段階的リリースを使っている場合はさらに厄介です。初日に配信されるのは全ユーザーの1%程度で、残りの99%は新バージョンを取得できないのに、閾値だけが上がっています。
対策は単純で、閾値の更新をリリース作業から切り離しました。
- 新バージョンをストアへ提出する(閾値は触らない)
- 審査を通過し、公開状態になったことを確認する
- 段階的リリースを 100% まで進める
- その状態で数日置き、更新率が想定に届いたことを確認する
- 一つ前のバージョンを閾値に設定する(今出したバージョンではなく)
5番目が要点です。閾値は「今の最新」ではなく「一つ前」に置きます。最新を閾値にすると、その最新を配信し終える前に全員が締め出される可能性が常に残ります。
Google Play と App Store で公開の反映速度も違うため、私は両ストアの公開状態を確認してから閾値を動かすようにしています。片方だけ先に上げたことがあり、Android 側のユーザーだけが宙ぶらりんになりました。
運用で残した3つのルール
一連の作業を経て、手元のチェックリストに3行だけ足しました。
判定関数はテストと一緒に置く。 バージョン比較は「動いているように見える期間」が長すぎます。1.9 から 1.10 へ上がる日まで、あるいはメジャーが 9 から 10 へ届く日まで、壊れた実装は正常に見えます。16ケースのテストは60行ほどで書けるので、書かない理由がありません。
閾値の入力を検証する。 リモート設定に 1.4 と入れたのか 1.4.0 と入れたのかで挙動が変わる実装は、そもそも脆いのですが、管理画面側でも /^\d+(\.\d+){0,3}$/ に一致しない文字列を弾くようにしました。実装の堅牢化と入力の検証は、どちらか一方では足りません。
閉じる方向の変更は、開ける手段とセットで出す。 強制アップデートは、こちらの操作でユーザーの手を止められる数少ない機構です。だからこそ、取得失敗で閉じない・キャッシュへ退避する・閾値を即座に戻せる、この3つが揃うまでは有効化しないと決めました。
バージョン比較のような、教科書の最初のほうに出てきそうな処理でこれだけ踏むとは思っていませんでした。実際に走らせて表にするまで、自分が何を取り違えているのか分からなかった。手を動かして測る以外に近道はないのだと、また一つ確かめた気がしています。
同じところで止まっている方の助けになれば嬉しいです。お読みいただきありがとうございました。