実装を見直す際に、同じ問題へ取り組む開発者が再利用できるよう整理しました。例示コードは説明用で、実際のアプリ全体を再現するものではありません。
問題:非同期関数でも、中の走査は同期になり得る
学習アプリでは、解説用画像を端末に保存し、次回はキャッシュから表示したくなります。ネットワーク取得を非同期にしていても、その前段で大量のファイルについて存在やサイズを同期確認すると、JavaScript側が長く処理を占有することがあります。
自分のアプリの実装を見直す中でも、未取得画像の列挙を一度行い、ダウンロード結果から残件を減らすこと、同期確認の途中で他の処理に実行機会を渡すことを重視しています。本記事では考え方を独立したTypeScriptの例にします。アプリのコード全文や計測済みの性能改善率を示すものではありません。
最小の実装例
次のコードは、同期のキャッシュ判定を外から渡す例です。実際のファイルAPIは利用しているSDKに合わせてください。Promise.resolve()ではなくタイマーを使い、一定件数ごとに実行を譲ります。
const yieldTurn = (): Promise<void> =>
new Promise(resolve => setTimeout(resolve, 0));
export async function collectMissing<T>(
targets: readonly T[],
isCached: (target: T) => boolean,
options: { batchSize?: number; signal?: AbortSignal } = {},
): Promise<T[]> {
const batchSize = options.batchSize ?? 150;
if (!Number.isInteger(batchSize) || batchSize < 1) {
throw new RangeError('batchSize must be a positive integer');
}
const missing: T[] = [];
for (let i = 0; i < targets.length; i += 1) {
if (options.signal?.aborted) throw new Error('scan aborted');
if (!isCached(targets[i])) missing.push(targets[i]);
if ((i + 1) % batchSize === 0) await yieldTurn();
}
return missing;
}
150は設計例の初期値で、端末に依存しない正解ではありません。1件の判定コストが高い場合、150件では大きすぎます。タイマーで譲ることも、一定のフレームレートを保証するものではありません。
走査を、毎回やり直さない
未取得一覧を作ったあと、各ダウンロードの成功を集合として受け取り、残件から差し引きます。小さなチャンクを完了するたびに全ファイルを再走査すると、同じ同期確認を何度も繰り返すことになります。
必要なのは「現在の残件がわかること」であって、「毎回ゼロからディスクを数え直すこと」ではありません。ただし、別経路からファイルが更新される場合は、その経路との整合も設計する必要があります。
確認したいケース
存在するファイルだけの場合、欠けている場合、途中で判定関数が例外を返す場合、画面を離れてキャンセルする場合を試します。開発モードだけでなく、リリースに近い条件の実機で、走査中のタップや画面遷移が妨げられていないかを確認します。
測定するなら総時間だけでなく、連続してJSを占有する区間、端末、対象件数、キャッシュ状態も残します。本記事では計測結果を作って載せてはいません。
参考
React Native公式:Performance Overview
https://reactnative.dev/docs/performance/
※2026年11月7日時点の設計整理です。AIを編集・コード例の作成補助に使用し、本人のインタビューと確認した実装の考え方をもとに構成しました。実測していない性能値や学習効果は掲載していません。
この設計を考える背景にある、私が開発している学習補助アプリ「合格!クエスト」