一部の端末で、アプリが一度も立ち上がらないという報告を受けました。ところが手元の端末では、何度起動しても普通に動いたのです。
こういうとき、原因は端末の側に探しに行きたくなります。機種、OS の版、メモリ。ところが原因は、自分の設計のほうにありました。チュートリアル(導入画面、いわゆるオンボーディング)で見せる画像を、画面の定義の中に固定で書いていたのです。その画像が欠けた端末では、チュートリアルが出ないのではなく、画面を作る処理そのものが失敗していました。
「なくても動くはずのもの」は、「なければ動かないもの」と同じ道に載せないようにします。 壁紙アプリでの出来事から私が引いた線は、これ一本です。この記事は、その線を Expo のプロジェクトで引き直す方法を、動かして確かめたコードと一緒に書き残したものです。
症状は「落ちる」ではなく「始まらない」でした
最初に困ったのは、手がかりの少なさです。起動したあとに落ちるのではなく、そもそも最初の画面が組み上がらなかったのです。使われ方の途中で出る不具合なら、操作の履歴から辿れます。ところが利用者に残るのは、「開かない」という一言だけでした。
手元で再現しなかった理由も、あとから見れば単純です。私の端末には、その画像が揃っていたからです。再現しなかったのは、コードが正しいからではなく、私の端末が条件を満たしていたからでした。 この区別を最初につけていれば、機種や OS の版を調べる時間を、かなり省けたはずです。
原因を一文にする
原因は、次の一文にまとめられます。画面を作る必須の処理が、チュートリアルの画像という必須ではないものに依存していました。
起動のとき、アプリは多くのことをします。そのうち、欠けたらアプリが成り立たないものと、欠けても本体は動くものが混ざっています。当時の私は、その二つを区別せずに、一本の道に並べていました。
この表を書いてみて、私は少し恥ずかしくなりました。右の列を埋めるだけで、直す場所が見えるからです。書く前の私は、チュートリアルを「アプリの一部」と思っていて、「アプリが動くための条件」とは別のものだと考えていませんでした。
直した形:失敗を例外でなく「結果」として返す
直し方の核は、任意の処理を実行する入口を一か所に決めることです。呼ぶ側が try-catch を毎回書くと、書き忘れた一か所が次の落とし穴になります。そこで、失敗も待ちすぎも、すべて戻り値として返す関数を一つ作りました。
次の関数は、Node 22 で動かして確かめています。Expo のプロジェクトでも、そのまま使えます。
// optional-step.ts — 「なくても本体は動く」処理を、失敗しても本体を巻き込まずに実行する
export type StepResult<T> =
| { ok: true; value: T }
| { ok: false; reason: 'error' | 'timeout'; error?: unknown };
type Options = {
timeoutMs?: number; // 省略すると待ち時間に上限を付けない(人の操作を待つ処理用)
onFail?: (name: string, reason: 'error' | 'timeout', error?: unknown) => void;
};
export async function optionalStep<T>(
name: string,
run: () => T | Promise<T>,
{ timeoutMs, onFail }: Options = {},
): Promise<StepResult<T>> {
let timer: ReturnType<typeof setTimeout> | undefined;
const racers: Promise<StepResult<T>>[] = [
(async (): Promise<StepResult<T>> => {
try {
return { ok: true, value: await run() };
} catch (error) {
return { ok: false, reason: 'error', error }; // 例外は握りつぶさず、結果として返す
}
})(),
];
if (timeoutMs !== undefined) {
racers.push(
new Promise<StepResult<T>>((resolve) => {
timer = setTimeout(() => resolve({ ok: false, reason: 'timeout' }), timeoutMs);
}),
);
}
const result = await Promise.race(racers);
if (timer !== undefined) clearTimeout(timer);
if (!result.ok) {
try {
onFail?.(name, result.reason, result.error);
} catch {
// 記録の失敗で、さらに本体を止めない
}
}
return result;
}
ここで気をつけたのは三点です。一つ目は、例外を握りつぶさず、結果の型で呼び出し側に返すこと。二つ目は、待ち時間の上限を付けられること。ただし上限を省略した場合は、時計を仕掛けません。人の操作を待つ処理に、上限を付けてしまわないためです。三つ目は、記録用の onFail が失敗しても、本体を止めないことです。
任意の処理は、成功か失敗かを返すだけにして、続きをどうするかは呼ぶ側が決めます。 私はこの分担を好みます。
画像一枚単位で諦める
次は、この関数を使ったチュートリアルの出し方です。ここで決めたのは、どの単位で諦めるかでした。
画像が一枚欠けたら、チュートリアル全体を飛ばすのか。画像だけ諦めて、説明の文章は見せるのか。私は後者にしました。利用者にとって価値があるのは文章のほうで、画像は補助だからです。
import { optionalStep } from './optional-step.ts';
export type Step = { id: string; title: string; image: string | null };
export type Deps = {
version: string;
storage: {
get(key: string): Promise<string | null>;
set(key: string, value: string): Promise<void>;
};
loadSteps(): Promise<Step[]>;
checkAsset(uri: string): Promise<boolean>;
show(steps: Step[]): Promise<void>;
loadTimeoutMs?: number;
onFail?: (name: string, reason: 'error' | 'timeout', error?: unknown) => void;
};
export async function maybeShowOnboarding(deps: Deps): Promise<'shown' | 'already' | 'skipped'> {
const key = `onboarding:${deps.version}`;
const opt = { onFail: deps.onFail };
const flag = await optionalStep('read-flag', () => deps.storage.get(key), { ...opt, timeoutMs: 500 });
if (!flag.ok) return 'skipped'; // 読めない端末では出さない。迷ったら出さない側に倒す
if (flag.value !== null) return 'already';
const loaded = await optionalStep('load-steps', () => deps.loadSteps(), {
...opt,
timeoutMs: deps.loadTimeoutMs ?? 1500,
});
if (!loaded.ok || loaded.value.length === 0) {
await optionalStep('write-flag', () => deps.storage.set(key, 'skipped'), opt); // 失敗したことも記録して、毎回の起動で同じ失敗を繰り返さない
return 'skipped';
}
const steps: Step[] = [];
for (const s of loaded.value) {
let image = s.image;
if (image !== null) {
const check = await optionalStep(`asset:${s.id}`, () => deps.checkAsset(image as string), {
...opt,
timeoutMs: 800,
});
if (!check.ok || check.value !== true) image = null; // 画像だけ諦める。説明文は出す
}
steps.push({ ...s, image });
}
const shown = await optionalStep('show', () => deps.show(steps), opt); // 人の操作を待つので、待ち時間の上限は付けない
await optionalStep('write-flag', () => deps.storage.set(key, shown.ok ? 'done' : 'skipped'), opt);
return shown.ok ? 'shown' : 'skipped';
}
この中で、いちばん迷ったのは skipped の印を保存する行です。失敗したときに印を残さないと、毎回の起動で同じ失敗を繰り返します。読み込みに時間がかかる失敗なら、そのたびに起動が遅くなります。印を残しておけば、失敗は一度だけで済みます。
一方で、この選択には代償があります。一時的な通信の不具合で飛ばされたチュートリアルも、その版では二度と出ません。それでも私は、起動のたびに待たされるよりは、一度の取りこぼしを受け入れる側を選びました。ここは、アプリの性格で判断が分かれる部分です。
もう一つ、読み込みの段階で「読めないなら出さない」に倒している行にも触れておきます。保存先が読めない端末では、チュートリアルを見せたかどうかが分かりません。分からないまま出すと、毎回出てしまうかもしれません。迷ったら、出さない側に倒します。
手元で再現できない失敗を、わざと欠かして再現する
この種の失敗でいちばん時間を使うのは、再現です。端末を探して借りる前に、欠けた状態をコードで作ってしまいます。
// features/onboarding/load-steps.ts(開発ビルドだけで効く、欠落の再現用)
export async function loadSteps() {
if (process.env.EXPO_PUBLIC_SIMULATE_MISSING === 'onboarding') {
throw new Error('simulated: onboarding resource missing');
}
// 本来の読み込み
}
ここで見るのは、EXPO_PUBLIC_SIMULATE_MISSING=onboarding を付けて起動しても、最初の画面まで進めるかどうかです。進めなければ、必須の道に任意の処理が載っています。
上の二つの関数には、この考え方に沿った九つの場合のテストを書きました。正常、読み込みの失敗、読み込みが返ってこない、画像の一部だけ欠ける、保存先が読めない、表示の失敗、記録用の関数自体の失敗などです。そのうち、返ってこない場合は次のように書きます。mk は、依存を差し替えるための小さな補助関数です。
// 読み込みが返ってこなくても、起動が先へ進むこと
const t = mk({ loadSteps: () => new Promise(() => {}) });
assert.equal(await maybeShowOnboarding(t.deps), 'skipped');
assert.deepEqual(t.fails, ['load-steps:timeout']);
九つすべてを Node 22 で実行して、all 9 passed と出ることを確かめました。
node test.ts
正常に動くかどうかより、欠けたときに本体が動き続けるかどうかを、先に確かめます。 場合を並べる発想のほうが、テストの中身よりも長持ちします。
出荷前に、入口の import を検査する
仕組みを作っても、数か月後の自分が入口のファイルへ import を一行足せば、また必須の道に載ってしまいます。それを防ぐために、入口のファイルが任意の機能を静的に読み込んでいないかを調べる小さな検査を用意しました。
#!/usr/bin/env node
// audit-startup-path.mjs — 起動の入り口が、任意の機能を静的に読み込んでいないかを調べる
import { readFileSync } from 'node:fs';
const [, , configPath, ...entries] = process.argv;
if (!configPath || entries.length === 0) {
console.error('usage: audit-startup-path.mjs <optional-patterns.json> <entry files...>');
process.exit(2);
}
const patterns = JSON.parse(readFileSync(configPath, 'utf8')).optional.map((p) => new RegExp(p));
// 先頭が import か、行頭の require だけを見る(関数の中の遅延読み込みは対象外)
const staticLoad = /^(?:import\b[^'"]*['"]([^'"]+)['"]|(?:const|let|var)\s+[^=]+=\s*require\(\s*['"]([^'"]+)['"]\s*\))/;
let violations = 0;
for (const file of entries) {
readFileSync(file, 'utf8').split('\n').forEach((line, i) => {
const m = staticLoad.exec(line);
const target = m && (m[1] ?? m[2]);
if (target && patterns.some((re) => re.test(target))) {
console.log(`${file}:${i + 1}: ${target} 任意機能が起動経路に直結しています`);
violations += 1;
}
});
}
console.log(violations === 0 ? 'OK' : `${violations} violation(s)`);
process.exit(violations === 0 ? 0 : 1);
任意の機能の名前は、設定ファイルに書いておきます。
{ "optional": ["onboarding", "tutorial", "lottie"] }
入口のファイルに任意の機能を直接 import した状態で実行すると、行番号つきで止まります。
node audit-startup-path.mjs optional-patterns.json app/_layout.tsx
# app/_layout.tsx:3: ../features/onboarding/Onboarding 任意機能が起動経路に直結しています
# 1 violation(s)
関数の中で読み込む形(遅延読み込み)は対象にしていません。必要になったときにだけ読むのであれば、起動の道には載らないからです。個人的には、この検査をリリース前の確認手順に一行足すだけで十分だと考えています。重い仕組みを足さなくても、再発の入口は塞げます。
待ち時間の上限は、役割ごとに決める
上限の数字は、コードの中に散らばらせず、役割ごとに理由を持たせて決めました。
ここで一つ、計算して気づいたことがあります。画像の確認を一枚ずつ順番に行うと、最悪の場合は 500ms と 1500ms に、段数 × 800ms が積み上がります。三段なら、合計の上限は 4400ms です。実際にそこまで待つ場面は稀でも、上限を足し算で出しておくと、段数を増やしたくなったときに歯止めになります。段が多いチュートリアルなら、確認を並べて走らせるか、段数そのものを絞るほうが筋だと考えています。
点検は、次の順番で進めると迷いません。
- 入口のファイルを開き、読み込んでいるものを一覧にします。
- 一つずつ「これが欠けた端末でも、利用者は本来の目的を果たせるか」と問い、必須と任意に分けます。
- 任意の側は、
optionalStep を通す形に直します。
- 欠落の再現と、入口の検査を、リリース前の確認に一行ずつ足します。
落とし穴
try-catch で包むだけでは、対処として足りない場合があります。例外が飛ばずに、返ってこないことがあるからです。通信やストレージの待ちは、失敗せずにただ長引くことがあります。待ち時間の上限を付けるのは、そのためです。
エラーを握りつぶすと、原因が分からなくなります。失敗を結果として返し、onFail で記録まで残しておけば、「開かない」という一言から、どの任意の処理で失敗したのかを辿れます。
反対に、包みすぎるのも危険です。本当に必須の処理まで任意として扱うと、壊れた状態のままで起動し、別の場所で原因の分からない落ち方をします。上の表の「必須」の行は、失敗を失敗として扱う側に残してください。
判断の線引き
迷ったときの線引きを、私は一つの問いにしています。「これが欠けた端末でも、利用者は本来の目的を果たせるか」という問いです。壁紙アプリなら、壁紙を見て、保存できるか。果たせるなら任意の側に置きます。
起動の時間を縮める話は、起動時間の予算を決めて、悪化を数字で追う記事で扱いました。また、特定のビルドだけで起きる失敗の見つけ方は、名前の文字列で引いていた資源が縮小で消えた release 限定のクラッシュにまとめています。起動の順序が原因だった例は、起動広告が背面から戻った回にしか出ていなかった話もあわせてご覧ください。
次の一手として、まず自分のアプリの入口のファイルを開き、読み込んでいるものを「欠けたら始まらないか」で分けてみてください。表を一枚書くだけで、直す場所が見えてきます。