きっかけは、6本並行で運用している壁紙アプリのうち、一番ダウンロード数の多い1本の起動が「最近もっさりしてきた」というレビューでした。クラッシュではありません。落ちないし、機能も動く。ただ、タップしてから最初の壁紙一覧が出るまでが、自分で触っても明らかに遅い。計測してみると、コールドスタートの TTI(操作可能になるまでの時間)が、半年前の 1,500ms から 2,400ms に伸びていました。
機能を足すたびに、初期化処理が少しずつ増えていたのです。広告 SDK、解析、リモート設定、フォントのプリロード、お気に入りの復元。どれも単体では「数十ミリ秒だから」と通してきたものでした。2014年から個人開発を続けて累計5,000万ダウンロードを超えるなかで、私が一番くり返してきた失敗がこれです。一つひとつは正しい判断でも、起動という共有資源に対して誰も上限を決めていないと、合計はいくらでも膨らみます。この記事は、起動時間を「速くする」対象から「予算を決めて守る」対象に変えた、その設計と運用の記録です。
なぜ「最適化」ではなく「予算」で考えるようになったか
起動を速くするテクニックは世の中にたくさんあります。Hermes を使う、バンドルを分割する、画像を遅延読み込みする。私もひと通り試しました。ですが、テクニックを単発で当てるアプローチには弱点があります。直した直後は速いのに、数か月後にはまた遅くなるのです。
理由ははっきりしています。最適化は「今ある遅さ」を削る作業なので、新しく足される遅さを防げません。機能追加のたびに初期化が増え、半年かけてまた元の木阿弥になります。私の6本すべてで、これがほぼ同じ周期で起きていました。
そこで考え方を変えました。起動時間は、CPU 時間やメモリと同じ「有限の共有資源」です。であれば、家計と同じように予算を決めて、各処理にいくらまで使ってよいかを割り当て、超えたら止めればいい。視点を「速くする」から「枠内に収める」に移した瞬間に、運用がぐっと安定しました。
宮大工だった祖父たちを思い出します。木組みは、一本ごとに「ここは何分で削る」と決めて手を動かすのではなく、全体の寸法と取り合いを先に決めてから各部材を作る、と聞いたことがあります。起動時間も同じで、全体の枠を先に引いてから各処理に配分するほうが、結果として崩れにくいというのが私の実感です。
起動を3フェーズに分けて予算を配る
最初にやったのは、起動を意味のある3つのフェーズに分けることでした。「起動が遅い」を一つの塊で見ているうちは、どこを削ればいいか判断できません。
私が6本で共通に使っている区切りは次の3つです。
- ネイティブ起動フェーズ:プロセス生成からReactルートビューがマウントされるまで。ここはアプリ側のJSではほぼ制御できない領域です。
- 初回描画フェーズ:JSが動き始めてから、最初の意味のある画面(壁紙一覧のスケルトン)が描画されるまで。
- 対話可能フェーズ:スケルトンが出てから、スクロールやタップに実際に反応できる状態(TTI)になるまで。
そのうえで、各フェーズに予算(上限ms)を割り当てます。私が現在6本で使っている配分はこうです。
- ネイティブ起動フェーズ:上限 700ms
- 初回描画フェーズ:上限 600ms
- 対話可能フェーズ:上限 500ms
- 合計予算:1,800ms(TTI)
この 1,800ms という数字は、ミドルレンジの実機(私の検証では Pixel 6a と iPhone SE 第3世代)で、ユーザーが「待たされた」と感じ始める手前の経験的なラインとして置いています。端末性能に幅があるので、絶対値より「予算を決めて運用に乗せること」自体が本質です。重要なのは、新しい初期化処理を足したい人(過去の自分を含めて)が、必ずこの表のどこかから時間を捻出しなければならない、という制約が生まれることです。
計測なしに予算は守れない:TTIマーカーを仕込む
予算を決めても、各フェーズの実測値が見えなければ意味がありません。私は外部の重い計測ツールを入れる前に、まず軽量な自前マーカーを仕込みました。performance.now() を使った、フェーズ境界の記録です。
// src/perf/startupBudget.ts
type Phase = 'native' | 'firstPaint' | 'interactive';
const BUDGET_MS: Record<Phase, number> = {
native: 700,
firstPaint: 600,
interactive: 500,
};
const marks: Partial<Record<Phase, number>> = {};
// JSが評価され始めた時刻を起点にする
const jsStart = performance.now();
export function markPhase(phase: Phase) {
marks[phase] = performance.now() - jsStart;
}
export function reportStartup() {
// ネイティブ起動分はネイティブモジュールから受け取る(後述)
const nativeMs = getNativeStartupMs();
const firstPaint = (marks.firstPaint ?? 0);
const interactive = (marks.interactive ?? 0);
const phases = {
native: nativeMs,
firstPaint: firstPaint,
interactive: interactive - firstPaint, // フェーズ単体の所要
};
const over = (Object.keys(phases) as Phase[]).filter(
(p) => phases[p] > BUDGET_MS[p]
);
return {
phases,
tti: nativeMs + interactive,
over, // 予算超過したフェーズ名の配列
};
}
markPhase('firstPaint') はスケルトンを描画したコンポーネントの useEffect の先頭で、markPhase('interactive') は一覧のデータが揃ってインタラクションを受け付けられる時点で呼びます。ネイティブ起動分(プロセス生成→RNルート)はJS側からは測れないので、Androidなら Process.getStartUptimeMillis()、iOSなら ProcessInfo の起動時刻をネイティブモジュール経由で渡すのが正確です。
ここで大事なのは、reportStartup() の戻り値を Crashlytics のカスタムキーや解析イベントとして送っておくことです。私は startup_tti と startup_over_phase の2つを毎起動で送り、リリースごとの分布を見ています。1台の手元計測だけだと端末差に振り回されるので、実ユーザーの分布(特にp75)で判断するようにしてからは、誤った「速くなった気がする」に騙されなくなりました。
一番効いた一手:SDK初期化を最初のフレーム後ろに退避する
予算超過の犯人を実ユーザー分布で追いかけると、6本すべてで共通の主犯がいました。起動と同時に走る、サードパーティSDKの初期化です。
私のアプリは AdMob を収益の柱にしているので、広告SDKの初期化が起動経路に組み込まれていました。さらに Crashlytics、解析、リモート設定。これらを「起動時に確実に初期化しておきたい」という素朴な気持ちで、ルートコンポーネントのトップレベルで同期的に呼んでいたのです。
問題は、これらの初期化が最初のフレームを描く前に割り込む点でした。ユーザーが見たいのは壁紙一覧であって、広告SDKの準備ができたかどうかではありません。そこで、初期化を「最初のフレームが出た後」に退避させました。
// Before:ルートで同期初期化(最初の描画を遅らせる)
export default function App() {
// これらが起動経路を直接塞いでいた
initAdMob();
initCrashlytics();
initRemoteConfig();
return <Navigation />;
}
// After:最初のフレーム後にまとめて遅延初期化
import { InteractionManager } from 'react-native';
export default function App() {
useEffect(() => {
// 描画とインタラクションが落ち着いてから初期化する
const task = InteractionManager.runAfterInteractions(() => {
initCrashlytics(); // クラッシュ捕捉は最優先で先に
initRemoteConfig();
initAdMob(); // 広告は最初の表示まで時間があるので最後でよい
});
return () => task.cancel();
}, []);
return <Navigation />;
}
この一手だけで、主力1本の TTI が 2,400ms から 1,500ms に下がりました。フェーズ別に見ると、初回描画フェーズが 1,100ms → 520ms に縮んだのが効いています。広告SDKの初期化が最初の描画と競合しなくなった分が、ほぼそのまま戻ってきた形です。
ただし、退避には順番の設計が要ります。私は次のルールを6本で統一しました。
- Crashlytics は最初に初期化する:遅延初期化の最中にクラッシュすると、その情報を取り逃すからです。退避はしても、退避先の先頭に置きます。
- AdMob は最後でよい:壁紙一覧をスクロールして広告枠に到達するまでには確実に間があるので、最初のフレームと競合させる必要がありません。
- リモート設定は描画に関わる値だけ早める:UIの分岐に使う値があるなら、それだけは軽量に先読みし、残りは退避します。全部退避すると、初回起動だけ古い既定値で描いてしまう画面が出ます。
公式のドキュメントには「アプリ起動時に初期化してください」とだけ書かれているSDKが多いですが、それは「最初のフレームより前に」という意味ではありません。ここを取り違えると、丁寧に初期化しているつもりで起動を塞ぎます。実際に運用して分かった、文書には書かれていない勘所です。
予算超過を CI で止める:しきい値ゲート
最後の仕上げが、予算を「守らせる」仕組みです。人間の善意に頼ると、半年後にまた膨らみます。そこで、リリース前のCIで起動予算を検査するゲートを入れました。
考え方はシンプルです。計測ビルドを実機(私は Firebase Test Lab の物理デバイスを使っています)で起動し、reportStartup() の p75 TTI を取り出して、予算を超えていたらビルドを失敗させます。
// scripts/startup-budget-gate.mjs
import { readFileSync } from 'node:fs';
const BUDGET_TTI_MS = 1800;
const HARD_FAIL_MS = 2000; // 予算+余裕。これを超えたら確実に落とす
// 計測起動の結果(解析エクスポートやTest Labのログから集計)
const { p75Tti, overPhases } = JSON.parse(
readFileSync(process.env.STARTUP_REPORT ?? 'startup-report.json', 'utf8')
);
console.log(`p75 TTI = ${p75Tti}ms (budget ${BUDGET_TTI_MS}ms)`);
if (overPhases.length) {
console.log(`over-budget phases: ${overPhases.join(', ')}`);
}
if (p75Tti > HARD_FAIL_MS) {
console.error(`✗ 起動予算を超過しました(${p75Tti}ms > ${HARD_FAIL_MS}ms)。ビルドを中止します。`);
process.exit(1);
}
if (p75Tti > BUDGET_TTI_MS) {
console.warn(`△ 予算超過(警告)。次のリリースまでに是正してください。`);
}
迷ったのは、超過を「警告」にするか「失敗」にするかでした。最初は全部警告にしていたのですが、警告は誰も見ません。3リリースほどで予算が形骸化しました。そこで、予算(1,800ms)超過は警告、予算+余裕(2,000ms)超過はビルド失敗という二段構えに倒しました。
「超えたら落とす」を入れて初めて、機能追加のレビュー時に「これ、起動予算のどこから出す?」という会話が生まれるようになりました。技術的な検査というより、チーム(私の場合はほぼ一人ですが)の意思決定を変えるための仕掛けです。落とす痛みがないと、予算は守られません。これは6本を回してきて一番強く感じている点です。
6本に展開して分かったこと
この設計を6本すべてに展開して、いくつか分かったことがあります。
予算の絶対値は、アプリごとに少し変える価値がありました。画像が主役の壁紙アプリと、テキスト中心の癒し系アプリでは、初回描画フェーズの妥当な予算が違います。共通の配分表を出発点にしつつ、初回描画だけはアプリ特性に合わせて ±150ms ほど調整しています。
それから、InteractionManager.runAfterInteractions への退避は強力ですが、退避しすぎると別の問題が出ます。あるアプリで解析の初期化まで深く退避させたら、起動直後の数秒の行動ログが取れなくなりました。計測系(クラッシュ・解析)は退避の先頭、収益系(広告)は末尾という順序は、この失敗から決めたものです。
次に同じ状況に出会ったら、まずやることは一つです。手元の1台で「速くなった/遅くなった」を語る前に、実ユーザーの p75 TTI をリリースごとに並べて見ること。分布で見始めると、どの機能追加が予算を食ったかが驚くほどはっきり見えます。起動時間は、勘ではなく台帳で管理する対象だと、今は考えています。
同じように複数アプリを並行運用している方の参考になれば幸いです。お読みいただきありがとうございました。