去年の秋も、その前の年の秋も、私は同じスプレッドシートを開いていました。
App Store Connect の OS バージョン別セッション比率を貼り付けて、いちばん古い世代の行を眺めます。0.9%。前年は 1.4%、その前は 2.1% でした。数字は毎年きれいに減っています。
それなのに、結論だけが毎年同じでした。「まだ切れないな」と打ち込んで、シートを閉じる。
3年目に気づいたのは、判断が鈍いのではなく、見ている指標が判断に向いていない、ということでした。利用率は「切ったら何人に届かなくなるか」を教えてくれます。けれど「切らなかったら自分が何を抱え続けるか」については、一言も教えてくれません。
利用率は「上げない理由」しか作らない
個人開発でアプリを複数本抱えていると、意思決定に使える時間は驚くほど短いものです。だからこそ、一つの数字で決めたくなります。
ところが利用率という数字は、構造的に片側にしか働きません。
閾値を 1% に置いたとします。0.9% になった年に上げられるかというと、上げられません。「あと少しで 0.5% になる、来年でいい」と考えるからです。0.5% になった年も同じで、今度は「たった 0.5% を切るために、わざわざリスクを取る意味があるのか」と考えます。分母が減るほど切る痛みは小さくなるはずなのに、心理的な閾値のほうが一緒に下がっていきます。
私の場合、壁紙アプリを6本並行で運用しています。判断を先送りするコストは、6本ぶん静かに積み上がっていました。
積み上がる場所は決まっています。OS の世代差を吸収するための分岐です。
// 実際に残っていたコードの一例(社内向けに簡略化しています)
if (Number. parseInt ( String (Platform.Version), 10 ) >= 17 ) {
await Sharing. shareAsync (uri, { UTI: 'public.png' });
} else {
// 旧 OS では複数選択の共有シートが途中で閉じるので1枚ずつ
for ( const one of uris) {
await Sharing. shareAsync (one, { UTI: 'public.png' });
}
}
書いた当時は正しい分岐でした。問題は、この分岐が「いつ消せるのか」がコードのどこにも書かれていないことです。
半年後の自分は、この else 側がまだ必要なのかどうかを判断できません。判断できないから消せません。消せないから、次の改修のたびに両方の経路を確認することになります。
つまり、先送りの本当のコストは、切り捨てられなかったユーザー数ではなく、消せないまま残った分岐の数として現れていました。
それならば、その数を直接数えればいい。利用率の代わりに、そちらを判断の材料にする。そう考えて作ったのが、これから紹介する仕組みです。
分岐の在り処を、ひとつの規約に寄せる
最初にやったのは、OS 判定を書く場所と書き方を1つに決めることでした。
Platform.Version を各画面から直接読むのをやめて、比較関数に閉じ込めます。文字列比較で "9.0" > "10.0" になる事故を避けるため、要素ごとの数値比較にしています。
// src/lib/os.ts
import { Platform } from 'react-native' ;
const parse = ( v : string | number ) : number [] =>
String (v). split ( '.' ). map (( n ) => Number. parseInt (n, 10 ) || 0 );
/** 実行中の iOS が target 以上かどうか。iOS 以外では常に false を返します。 */
export function atLeastIOS ( target : string ) : boolean {
if (Platform. OS !== 'ios' ) return false ;
const cur = parse (Platform.Version);
const req = parse (target);
for ( let i = 0 ; i < Math. max (cur. length , req. length ); i ++ ) {
const a = cur[i] ?? 0 ;
const b = req[i] ?? 0 ;
if (a !== b) return a > b;
}
return true ;
}
この比較部分は、境界のケースを実際に流して確かめてあります。"17" のようにマイナー番号が省略された値、"17.0.1" と "17.1" のような桁数の違う比較、"16.4" と "16.4.1" の関係。7 パターンを流して、すべて期待どおりの結果になることを確認しました。この手の比較は、書いた直後には正しく見えて、桁が増えた瞬間に崩れます。
下限そのものは、参照される正本を1ファイルに置きます。
// src/config/supportMatrix.ts
// このアプリが公式に対応する OS の下限。ここだけが正本です。
// 変更するときは必ず os-sweep を走らせてから。
export const MIN_OS = {
ios: '16.0' ,
android: 31 ,
} as const ;
そのうえで、OS 分岐には必ず注記を添える規約にしました。
// @drop-when ios>=17.0 -- 17.0 未満は共有シートの多重選択が途中で閉じる
if ( ! atLeastIOS ( '17.0' )) {
return < LegacyShareSheet uris = { uris } />;
}
書式はごく単純です。@drop-when ios>=X に続けて -- のあとへ理由を書く。それだけです。
大事なのは形式の厳密さではなく、「いつ消せるか」と「なぜ書いたか」が、分岐のすぐ横に残ることです。この2つが揃っていれば、半年後の自分が消す判断をできます。
引き上げ先ごとに、消せる分岐を数える
注記が揃えば、あとは数えるだけです。
次のスクリプトは、src 以下を歩いて注記を集め、「iOS をどこまで上げると何箇所が消えるか」を累計で出します。合わせて、すでに下限を下回っている消し忘れと、注記のない野良の分岐も報告します。
#!/usr/bin/env node
// scripts/os-sweep.mjs
// OS 分岐の棚卸し。最低サポート OS を引き上げたときに
// 「消せる分岐が何箇所あるか」を数えて、判断材料にします。
import { readFileSync } from 'node:fs' ;
import { readdir } from 'node:fs/promises' ;
import { join, extname, relative } from 'node:path' ;
const ROOT = process.argv[ 2 ] ?? 'src' ;
const MATRIX = process.argv[ 3 ] ?? 'src/config/supportMatrix.ts' ;
const EXT = new Set ([ '.ts' , '.tsx' , '.js' , '.jsx' ]);
const SKIP = new Set ([ 'node_modules' , '.git' , 'ios' , 'android' , 'dist' , '.expo' ]);
const TAG = /@drop-when \s + ios>= \s * ( [0-9] + (?: \. [0-9] + ) {0,2} )(?: \s * -- \s * ( . * )) ? / ;
const CALL = /atLeastIOS \( \s * ['"] ( [0-9] + (?: \. [0-9] + ) {0,2} ) ['"]\s * \) / ;
/** "16.4" を 160400 のような比較可能な整数へ。壊れた入力は null。 */
function toNum ( v ) {
const parts = String (v). split ( '.' ). map (( n ) => Number. parseInt (n, 10 ));
if (parts. some (( n ) => ! Number. isInteger (n) || n < 0 )) return null ;
const [ a = 0 , b = 0 , c = 0 ] = parts;
return a * 10000 + b * 100 + c;
}
function readMinIOS ( path ) {
let src;
try {
src = readFileSync (path, 'utf8' );
} catch (e) {
throw new Error ( `最低OS定義が読めません: ${ path } (${ e . code ?? e . message })` );
}
const m = src. match ( /ios \s * : \s * ['"] ( [0-9][0-9.] * ) ['"] / );
if ( ! m) throw new Error ( `${ path } に ios: "x.y" の定義が見つかりません` );
const n = toNum (m[ 1 ]);
if (n === null ) throw new Error ( `${ path } の ios 値が不正です: ${ m [ 1 ] }` );
return { raw: m[ 1 ], num: n };
}
async function* walk ( dir ) {
let entries;
try {
entries = await readdir (dir, { withFileTypes: true });
} catch (e) {
if (e.code === 'ENOENT' ) return ;
throw e;
}
for ( const e of entries) {
if (e.name. startsWith ( '.' )) continue ;
const p = join (dir, e.name);
if (e. isDirectory ()) {
if ( SKIP . has (e.name)) continue ;
yield* walk (p);
} else if ( EXT . has ( extname (e.name))) {
yield p;
}
}
}
let min;
try {
min = readMinIOS ( MATRIX );
} catch (e) {
console. error ( `os-sweep: ${ e . message }` );
process. exit ( 2 );
}
const tagged = []; // 注記あり・下限より上
const untagged = []; // 注記のない OS 分岐
const stale = []; // 下限を下回っていて今すぐ消せるもの
for await ( const file of walk ( ROOT )) {
const lines = readFileSync (file, 'utf8' ). split ( ' \n ' );
const annotated = new Set ();
lines. forEach (( line , i ) => {
const t = line. match ( TAG );
if (t) {
const num = toNum (t[ 1 ]);
if (num === null ) {
console. error ( ` ! 不正なバージョン表記: ${ relative ( '.' , file ) }:${ i + 1 } -> ${ t [ 1 ] }` );
return ;
}
const rec = {
file: relative ( '.' , file), line: i + 1 ,
version: t[ 1 ], num, reason: (t[ 2 ] ?? '' ). trim (),
};
// 注記の直後3行までを「この注記が守っている分岐」とみなす
for ( let k = i; k < Math. min (i + 4 , lines. length ); k ++ ) annotated. add (k);
(num <= min.num ? stale : tagged). push (rec);
}
if ( CALL . test (line) && ! annotated. has (i)) {
untagged. push ({ file: relative ( '.' , file), line: i + 1 , version: line. match ( CALL )[ 1 ] });
}
});
}
const byVersion = new Map ();
for ( const t of tagged) byVersion. set (t.version, (byVersion. get (t.version) ?? 0 ) + 1 );
const sorted = [ ... byVersion. entries ()]. sort (( a , b ) => toNum (a[ 0 ]) - toNum (b[ 0 ]));
console. log ( `現在の最低サポート iOS: ${ min . raw }` );
console. log ( `注記つきの OS 分岐: ${ tagged . length } 箇所 / 注記なし: ${ untagged . length } 箇所` );
console. log ( '' );
console. log ( '引き上げ先ごとに消せる分岐の数(累計):' );
let acc = 0 ;
for ( const [ v , n ] of sorted) {
acc += n;
console. log ( ` iOS ${ v . padEnd ( 6 ) } まで上げる -> ${ String ( acc ). padStart ( 3 ) } 箇所が消せる` );
}
if (stale. length ) {
console. log ( '' );
console. log ( `⚠ すでに下限(${ min . raw })を下回っていて、今すぐ消せる分岐が ${ stale . length } 箇所あります:` );
for ( const s of stale) console. log ( ` ${ s . file }:${ s . line } ios>=${ s . version } ${ s . reason }` );
}
if (untagged. length ) {
console. log ( '' );
console. log ( `⚠ 注記のない OS 分岐が ${ untagged . length } 箇所あります(棚卸しの対象外です):` );
for ( const u of untagged) console. log ( ` ${ u . file }:${ u . line } atLeastIOS('${ u . version }')` );
}
process.exitCode = stale. length > 0 ? 1 : 0 ;
サンプルのツリーに対して走らせると、こういう出力になります。
$ node scripts/os-sweep.mjs src src/config/supportMatrix.ts
現在の最低サポート iOS: 16.0
注記つきの OS 分岐: 3 箇所 / 注記なし: 1 箇所
引き上げ先ごとに消せる分岐の数(累計):
iOS 16.4 まで上げる -> 1 箇所が消せる
iOS 17.0 まで上げる -> 2 箇所が消せる
iOS 18.0 まで上げる -> 3 箇所が消せる
⚠ すでに下限(16.0)を下回っていて、今すぐ消せる分岐が 1 箇所あります:
src/screens/Settings.tsx:2 ios>=15.0 すでに下限を超えている(消し忘れ)
⚠ 注記のない OS 分岐が 1 箇所あります(棚卸しの対象外です):
src/lib/os.ts:3 atLeastIOS('17.2')
この一覧が出てくると、会話の中身が変わります。
「0.9% を切るかどうか」ではなく、「iOS 17.0 まで上げれば、いま抱えている分岐のうちこれだけが消える」という話になります。前者は感情の問題ですが、後者は見積もりの問題です。
処理そのものは軽いものです。1,000 ファイル規模のツリーに 1,004 箇所の注記を仕込んで計測したところ、実行時間は 0.082 秒でした。プリコミットフックに入れても体感には出ません。
「消し忘れ」を CI で落とす
このスクリプトで実際にいちばん効いたのは、引き上げ先の集計ではなく、消し忘れの検出のほうでした。
下限を 16.0 に上げたのに、ios>=15.0 の注記が付いた分岐がコードに残っている。これは単なる死んだコードです。読む人の時間を奪い、改修のたびに「こちらの経路も確認しなければ」と手を止めさせます。
終了コードを3段階に分けてあります。
終了コード 状態 CI での扱い
0 消し忘れなし 通過
1 下限を下回った分岐が残っている 失敗させる
2 設定ファイルが読めない・書式が不正 失敗させる(設定の事故)
注記のない野良の分岐は、警告として出すだけで失敗にはしていません。ここを失敗にすると、既存コードを一気に注記だらけにする作業が発生して、導入そのものが止まります。まず新しく書く分岐から規約に乗せて、古いものは触ったときに直す。この順番でないと続きませんでした。
GitHub Actions には、こう組み込んでいます。
# .github/workflows/quality.yml(該当部分のみ)
- name : OS 分岐の棚卸し
run : node scripts/os-sweep.mjs src src/config/supportMatrix.ts
下限を引き上げる変更を含むプルリクエストは、これで自動的に赤くなります。赤いまま通そうとすると、消すべき分岐の一覧が CI のログに並んでいます。手順書を読み返す必要がありません。
段階公開との組み合わせについては、Rork アプリの段階的リリース戦略 — Phased Release・Staged Rollout・Hotfix を組み合わせて「壊さない本番運用」を作る にまとめた運用をそのまま使っています。下限の引き上げは、機能追加よりもロールバックの判断が難しい変更です。段階公開の枠に必ず載せてください。
数え始めてから、予想と違ったこと
半年ほどこの仕組みを回して、事前に考えていたことのいくつかが外れていたと分かりました。
切り捨ては「アプリが消える」ではなく「凍結する」
最低サポートを上げると、旧 OS の端末では App Store にアプリが表示されなくなる。私は長いあいだ、そう理解していました。
実際には違いました。すでに購入・ダウンロード済みのユーザーが購入履歴から再取得しようとすると、その端末で動く最後の互換バージョンが配信されます。アプリは消えません。古いバージョンのまま、そこに残り続けます。
これは救いであると同時に、厄介でもあります。切ったつもりの旧バージョンが動き続けるので、そこで起きる不具合の問い合わせと低評価レビューは、こちらに届き続けます。しかもそのバージョンには、もう修正を配れません。
だから引き上げの前に、最後の互換バージョンとなるビルドを「置き土産」として整えておく必要があります。私は引き上げの1つ前のリリースで、既知の不具合をできる範囲で潰し、サーバー側の API が古いクライアントを弾かないことを確認してから下限を上げるようにしました。凍結させる姿を先に決めておく、という手順です。
下限を自分で決めているつもりが、決めていなかった
もうひとつの誤解は、引き上げの時期を自分が主導している、という思い込みでした。
Rork が生成するのは Expo ベースの React Native アプリです。Expo の SDK は更新のたびに対応 OS の下限を引き上げていきます。SDK を上げないという選択肢は、実質的にはありません。上げなければ、いずれビルドもストア提出も通らなくなります。
つまり私が決められるのは「上げるかどうか」ではなく、「SDK 側が上げてくるタイミングに対して、どれだけ前倒しで準備を終えておくか」だけでした。
これに気づいてから、判断の置き場所を変えました。SDK のリリースノートで下限の変更を確認したら、その値を supportMatrix.ts の候補として控えておく。そして sweep の出力と突き合わせて、「その下限まで上がると何箇所が消えるか」を先に見ておく。SDK 更新の当日に慌てて分岐を整理する、という状況がなくなりました。
具体的にどの SDK がどの下限を要求するかは版によって変わりますので、必ずリリースノートで確認してください。ここで数字を覚えても、来年には合わなくなります。
引き上げ直後の「改善」は、成果ではない
下限を上げた翌週、クラッシュフリー率が目に見えて良くなりました。
嬉しくなって記録しかけて、手が止まりました。コードは何も良くなっていません。消えたのは分岐であって、不具合ではない。数字が動いたのは、いちばんクラッシュしやすかった旧世代の端末が母集団から抜けたからです。
これを「引き上げの成果」として記録すると、翌年の判断が歪みます。「去年は上げたらクラッシュ率が改善した」という記憶だけが残り、実際には改善していないのに、次も同じ期待で判断してしまう。
なので引き上げの前後は、全体の平均ではなく、引き上げ後も残る OS バージョンだけに絞った数字で比較するようにしました。母集団を固定して見ると、たいていの場合ほとんど動いていません。それが正しい姿です。
計測の土台については、Rork × MetricKit でアプリの本番品質を計測する実装ガイド — クラッシュ・ハング・電池消費の診断基盤を一人で作る で組んだ収集の仕組みを、そのまま OS バージョン別の切り口に使っています。
導入して分かった、つまずきどころ
半年のあいだに何度か躓きました。同じところで止まる人がいると思いますので、3つ書いておきます。
下限の正本が二重管理になる
いちばん厄介だったのがこれです。supportMatrix.ts を「正本」と呼びながら、実際にビルドの下限を決めているのは別の場所でした。
Expo でネイティブの最低 OS を決めるのは、ビルド設定側のデプロイメントターゲットです。supportMatrix.ts の値を 17.0 に書き換えても、そちらが 16.0 のままなら、ストアに出るバイナリの下限は 16.0 のままです。sweep は「上げた前提」で消し忘れを報告し、実機はまだ旧 OS で動く。いちばん危ない食い違いです。
対処は単純で、ビルド設定のほうに値を書かず、正本から読ませます。
// app.config.ts
import { MIN_OS } from './src/config/supportMatrix' ;
export default {
expo: {
// ...
plugins: [
[
'expo-build-properties' ,
{
ios: { deploymentTarget: MIN_OS .ios },
},
],
],
} ,
} ;
プラグイン名やキーの構成は SDK の版によって変わりますので、そこはお使いの版のドキュメントで確認してください。大事なのは「値を2箇所に書かない」という一点です。
Platform.Version の直接参照が抜け道になる
atLeastIOS を用意しても、既存コードや急ぎの修正では Platform.Version を直接読む書き方が復活します。sweep はそれを拾えませんので、静かに棚卸しの網から漏れます。
ここは ESLint で塞ぎました。
// eslint.config.js(該当ルールのみ)
{
rules : {
'no-restricted-syntax' : [
'error' ,
{
selector: "MemberExpression[object.name='Platform'][property.name='Version']" ,
message: "Platform.Version は直接読まず、src/lib/os.ts の atLeastIOS を使ってください" ,
},
],
},
}
src/lib/os.ts 自体は対象外にする必要がありますので、そのファイルだけ上書きの設定を足してください。
凍結したビルドの中で、SDK も一緒に凍る
これは後になって気づいた落とし穴です。
下限を上げると、旧 OS の端末には最後の互換バージョンが残ります。そこに含まれている広告 SDK や課金 SDK も、当然その版のまま固定されます。AdMob のように、事業者側のポリシー変更で古い SDK の動作が制限されることがある領域では、これが後から効いてきます。凍結したビルドで広告が表示されなくなっても、修正を配る手段がありません。
収益の一部がそこにぶら下がっているなら、置き土産のビルドには SDK を最新に上げてから凍結させておくべきです。私は一度これを見落として、凍結側の収益が数か月かけて静かにしぼんでいくのを、あとから気づいて悔しい思いをしました。
なお、アプリが1本だけなら、ここまでの仕組みは要りません。注記を書く習慣だけで十分に回ります。複数本を並行して面倒を見ていて、しかも共通のコードを共有パッケージに置いているなら、sweep はそのパッケージ側に置くことをお勧めします。呼び出し側のアプリごとに下限が違う場合、共有コードの分岐はいちばん低い下限に引きずられるからです。どのアプリが足を引っ張っているかは、一覧を見れば一目で分かります。
引き上げの手順
実際に上げるときは、この順番で進めています。
sweep を走らせ、候補となる下限ごとに消せる分岐の数を確認する
Expo SDK のリリースノートで、次に要求される下限を確認する
App Store Connect で、切り捨て対象の OS バージョンのセッション比率と、そこから発生している売上を確認する(利用率は「決める材料」ではなく「覚悟する材料」として見ます)
引き上げの1つ前のリリースで、置き土産となるビルドの既知不具合を潰す
サーバー側 API が、古いクライアントからのリクエストを弾かないことを確認する
supportMatrix.ts の値を更新し、CI が赤くなることを確認したうえで、一覧に出た分岐を消す
段階公開の枠に載せて出す
3 の順番が肝心です。利用率を最初に見ると、そこで思考が止まります。消せる分岐の数を先に見ておくと、利用率は「これだけの人に届かなくなることを引き受けるか」という覚悟の確認になります。同じ数字でも、見る順番で役割が変わります。
引き上げてから30日、見ている3つの数字
指標 見方 手を打つ目安
残存 OS に絞ったクラッシュフリー率 母集団を引き上げ後の構成に固定して前後比較する 前後で 0.2 ポイント以上悪化したら、消した分岐の巻き戻しを検討
旧バージョン起因の問い合わせ件数 置き土産のビルド番号でタグ付けして数える 週あたりの件数が増え続けるなら、置き土産の作りが甘い
アクティブ端末数の推移 切り捨て分を差し引いた想定曲線と実測を重ねる 想定より落ち込むなら、切ったのは旧端末だけではない可能性
3つ目は、当初は見ていませんでした。引き上げた直後にアクティブ端末数が想定より落ちたことがあり、調べたら OS ではなく別のリリースに原因がありました。同じ日に複数の変更を出していたためです。以来、下限の引き上げは単独のリリースで出すようにしています。
今日できること
いま抱えているコードに、OS 分岐がいくつ残っているか。おそらく即答できないはずです。私も答えられませんでした。
まずは atLeastIOS のような比較関数を1つ用意して、これから書く分岐だけに注記を付けてみてください。既存のコードを遡って整理する必要はありません。半年もすれば、判断に足りるだけの一覧が自然に溜まります。
数えられるようになると、先送りは「決断しないこと」ではなく「見えている負債を持ち越すこと」に変わります。同じ結論を選ぶにしても、そのほうがずっと納得できます。
長く続けるほど、こうした地味な仕組みの有無が効いてくるのを感じています。お読みいただきありがとうございました。