app-startup-and-bootstrap について
Flutter開発者向けの起動・bootstrapガイドで、エラー処理、依存注入、初回フレームの早期表示を扱います。起動後のウォームアップや、バックグラウンド移行時の状態保存も説明します。
主な機能
- 01起動直後にエラーハンドラーを設定
- 02bootstrap()で依存を構築・注入
- 03初回フレーム前にテーマと設定を読み込み
- 04初回フレーム後にサービスをウォームアップ
- 05バックグラウンド移行時に状態をフラッシュ
- 06Riverpodエラーの再試行方針を設定
活用シーン
- 01Flutterアプリの起動フロー設計
- 02Riverpodの依存オーバーライド設定
- 03ウォームアップによる初回フレーム遅延の防止
- 04アプリ一時停止時の保留中書き込みへの対応
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ zakariaf/ flutter-skills --skill app-startup-and-bootstrapインストール後、Claude Code で/app-startup-and-bootstrapを入力して読み込まれたか確認します。
詳細
アプリの起動とブートストラップ
main() の役割は、クラッシュを捕捉する仕組みを設置し、最初のフレームに必要な最小限の状態を読み込み、実際の依存関係をツリーに接続して、runApp に引き継ぐことだけです。高速で、順序が明確で、失敗を隠せないようにします。コストのかかる処理はすべて、最初のフレームの後に実行するか、起動経路から完全に切り離します。
絶対に守るルール
- エラーハンドラーを最初に設定し、例外が発生する可能性のある処理より前に置きます。
WidgetsFlutterBinding.ensureInitialized()の直後に、クラッシュログの出力先、FlutterError.onError、PlatformDispatcher.instance.onErrorを設定します。最も例外が発生しやすいのは DB のオープンです。その後にハンドラーを設定すると、そもそもの目的が逆転してしまいます。 - エラーハンドラーは 2 つだけにし、ゾーンは使いません。
FlutterError.onError(ビルド/レイアウト/ペイントのエラー)とPlatformDispatcher.instance.onError(捕捉されない非同期エラー)ですべての経路をカバーできます。runZonedGuardedは絶対に追加しないでください。「3 つすべてが必要」という助言は、クラッシュ SDK 向けのものです(Sentry は初期化処理をゾーンでラップします)。そのような SDK がなければ、ゾーンには何の利点もなく、ゾーンの不一致という、文書化された危険を招くだけです。この警告に対する Flutter 自身の解決策は、ゾーンを削除することです。 PlatformDispatcher.onErrorは常にtrueを返します。falseを返すとエンベッダーのフォールバックに処理が渡り、プロセスが終了したりハングしたりする可能性があります。デバッグコンソールへの出力にはreturn kReleaseModeではなく、kDebugModeのもとでdebugPrintを使います。- エラーハンドラーから例外を投げないでください。 本体を裸の
try/catch (_)で囲み、破棄するエラーが意図的なものである理由を説明するコメントを残します。そうしないと、誰かが「修正」しようとして、ハンドラー内で無限再帰が発生するようにしてしまいます。 runAppの前に設定/テーマを読み込みます。 パレット、テキストスケールのポリシー、ロケール、最初の描画に関わる選択は、同期的に読み込みます(数行程度なら 10ms 未満です)。これにより、最初のフレームが正しく描画されます。誤ったテーマが一瞬表示されるのは、見た目だけの問題ではなく、目に見える不具合です。- コンポジションルートの
bootstrap()で実際のインフラを構築し、オーバーライド経由で注入します。 機能コードは、例外を投げるプレースホルダープロバイダーに依存します。bootstrap()で実際の DB/サービスを構築し、ルートのProviderScopeでoverrideWithValueを使ってオーバーライドします。配線漏れがあれば、null を返すのではなく、起動時に明確に失敗します。これによりテスト用の差し替え箇所も確保できます。 - ウォームアップは
addPostFrameCallbackに遅延し、main()では絶対に await しません。 プラグインやエンジンのウォームアップ(オーディオ、TTS、初回のネットワークハンドシェイクなど)は、メインスレッドで同期的にコストを発生させ、コールドスタート経路で ANR を引き起こします。最初の使用可能なフレームの後に、ベストエフォートで実行します。 - 最初のフレームをブロックしないでください。 起動経路で唯一の
awaitは、避けられないブロッカーである DB のオープンだけです。マイグレーションの実行中に空白のウィンドウを表示するのではなく、すぐに UI シェルを表示します。 - ログ出力の前に
ProviderExceptionをアンラップします。 Riverpod 3 はプロバイダーの失敗をラップして再スローします。ラッパーをログに記録すると、本当の原因が隠れ、どのログもProviderExceptionと表示されてしまいます。 - アプリの失敗モデルに合わせて Riverpod のリトライを調整します。 Riverpod 3 は、失敗したプロバイダーをデフォルトで再試行します(指数バックオフで約 38 秒)。唯一の失敗要因がローカルのバグ(破損した DB、ファイルの欠落など)であるプロバイダーでは、
retry: (count, error) => nullを設定し、スピナーの裏で再試行を繰り返すのではなく、すぐに明確に失敗させます。 - 単一のライフサイクルオブザーバーで、バックグラウンド時に永続状態をフラッシュします。
WidgetsBindingObserverを 1 つ登録し、didChangeAppLifecycleStateでアプリがinactive/pausedになったときに保留中の書き込みをフラッシュします。バックグラウンドのアプリは OS により、以降のコールバックなしで終了される可能性があります。また、resumedになったときに、時間依存の状態を再読み込みします。これはbootstrap()の鏡像です。bootstrap()がコールド起動時に状態を復元し、オブザーバーがプロセス終了前にそれを永続化します。コールバック内ではref.readでサービスを取得します(watchは使いません)。
手順
// lib/main.dart — the whole launch path in one readable function (~40 lines).
Future<void> main() async {
// Same function body as runApp(): no zone, so no zone-mismatch warning.
WidgetsFlutterBinding.ensureInitialized();
final CrashLog log = await CrashLog.open(); // FIRST: a crash before this is invisible forever.
installErrorHandlers(log);
final deps = await bootstrap(); // the one blocker: open DB, read settings, build services.
runApp(
ProviderScope(
// Local-only failures are real bugs — fail fast, don't retry for ~38s.
retry: (count, error) => null,
overrides: [
appDatabaseProvider.overrideWithValue(deps.db),
settingsRepositoryProvider.overrideWithValue(deps.settings),
notificationGatewayProvider.overrideWithValue(deps.notifications),
],
child: const App(), // flavor-blind, DI-blind widget tree.
),
);
}
WidgetsFlutterBinding.ensureInitialized()
→ CrashLog.open() first; nothing above may throw unseen
→ FlutterError.onError = … cheap, synchronous
→ PlatformDispatcher.onError = …
→ bootstrap(): open DB (+migration), read settings, build services ← only blocker
→ runApp(ProviderScope(overrides: …, child: App()))
──────────────────────────────────────── FIRST FRAME (UI visible, usable)
→ addPostFrameCallback: unawaited(service.warmUp()) ← never blocks
2 つのエラーハンドラー
void installErrorHandlers(CrashLog log) {
// Errors inside Flutter's build/layout/paint callbacks.
FlutterError.onError = (FlutterErrorDetails details) {
try {
FlutterError.presentError(details);
log.record(details.exceptionAsString(), details.stack);
} catch (_) {
// Never let the error handler throw — do not "fix" this into recursion.
}
};
// Uncaught async errors outside the framework's callbacks.
PlatformDispatcher.instance.onError = (Object error, StackTrace stack) {
try {
log.record(unwrapProviderException(error).toString(), stack); // rule 9
if (kDebugMode) debugPrint('$error\n$stack');
} catch (_) {
// Never let the error handler throw — do not "fix" this into recursion.
}
return true; // ALWAYS true (rule 3).
};
}
クラッシュシンク自体は同期処理であり(最初のフレームでのクラッシュを含め、強制終了時にもエントリが残る必要があります)、サイズに上限があり、例外をスローできない仕組みです。何もせずに catch (_) するガードは、意図的に 「エラーを握りつぶさない」(ここではルール 4 が優先されます)に反しています。このコメントは重要です。
コンポジションルート
// lib/bootstrap.dart — the ONE place concrete implementations are named.
// Feature code sees only the throwing placeholder providers below.
class AppDeps {
const AppDeps(this.db, this.settings, this.notifications);
final AppDatabase db;
final SettingsRepository settings;
final NotificationGateway notifications;
}
Future<AppDeps> bootstrap() async {
final db = await openAppDatabase(); // plain top-level factory — no Riverpod, isolate-reusable.
final settings = DriftSettingsRepository(db);
await settings.load(); // small read; needed before first paint.
return AppDeps(db, settings, LiveNotificationGateway());
}
// lib/providers.dart — placeholders throw until bootstrap() overrides them.
final appDatabaseProvider =
Provider<AppDatabase>((ref) => throw UnimplementedError('override in bootstrap()'));
final settingsRepositoryProvider =
Provider<SettingsRepository>((ref) => throw UnimplementedError('override in bootstrap()'));
final notificationGatewayProvider =
Provider<NotificationGateway>((ref) => throw UnimplementedError('override in bootstrap()'));
ウィジェットツリー(App)は具体的な実装をインポートせず、フレーバーフラグも読み取りません。複数のエントリポイント(main_dev.dart、main_prod.dart、ストアごとのフレーバー)は、1 つの App と 1 つのプレースホルダープロバイダーリストを共有し、異なるのは bootstrap() のオーバーライドだけです。そのため、各 main は行ごとの差分を比較できます。テストでは、同じプロバイダーをフェイクまたはインメモリ DB でオーバーライドすることで、同一の接続点を再利用します。実際の main() は実行しません。
遅延ウォームアップ
// In App's (or the first screen's) initState — NOT in main().
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
unawaited(ref.read(someServiceProvider).warmUp()); // best-effort; UI already usable
});
}
ウォームアップのポリシーと、その失敗時のポリシーは、意図的に正反対になっています。ウォームアップが失敗しても、初回利用時に少し遅くなるだけで、通知されないこともあります。一方、ウォームアップする実際の処理が失敗した場合は、明確にエラーを通知する必要があります。この 2 つをまとめないでください。
アプリのライフサイクル:バックグラウンド移行時にフラッシュ
// The SAME State that owns deferred warm-up also owns the lifecycle observer.
class _AppState extends ConsumerState<App> with WidgetsBindingObserver {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
switch (state) {
case AppLifecycleState.inactive:
case AppLifecycleState.paused:
unawaited(ref.read(settingsRepositoryProvider).flush()); // may never resume
case AppLifecycleState.resumed:
ref.read(clockSensitiveProvider.notifier).refresh(); // re-read time-sensitive state
case AppLifecycleState.detached:
case AppLifecycleState.hidden:
break;
}
}
}
オブザーバーは 1 つだけ登録し、dispose で削除します。各機能のウィジェットに didChangeAppLifecycleState を分散させないでください。各機能はリポジトリ/notifier に flush() を公開し、ルートオブザーバーがそれを呼び出します。時間に依存するものはすべて clockProvider(service-boundary-and-native を参照)を通じて再読み込みし、DateTime.now() は決して使いません。
唯一のブロッカー:DB を開く処理
設定行を 12 件読み込むのは簡単で、安全に await できます。本当のリスクは、アップデート後の初回起動時に行われるスキーマ移行です。ユーザーがアプリを使えるようになるまで、上限のない処理が挟まることになります。ルールは「移行を速くする」ことではありません。空白のウィンドウを表示する代わりに、すぐに UI シェルを描画すること、そして移行前のスナップショットを作成し、不正な移行から復旧できるようにすることです。移行の手順については run-migration を、接続設定については persistence-drift を参照してください。
アンチパターン
runZonedGuardedでrunAppを囲む — クラッシュ SDK がなければ、ゾーン不一致の警告が出るだけで、他には何も得られません。2 つのハンドラーですでにすべての経路をカバーしています。main()でawait service.warmUp()を実行する — メインスレッドでバインダー/IPC の同期処理が発生し、コールドスタート時に ANR になります。runAppの後にテーマ/ロケールを読み取る — 最初のフレームが誤ったテーマで表示され、その後目に見えて切り替わることが確実になります。get_it/injectable/MultiProviderを第 2 の DI コンテナーとして使う —ProviderScopeのオーバーライドがすでに DI です。並行するコンテナーを置くと、真実の情報源が 2 つになります。PlatformDispatcher.onErrorからfalseを返す — プロセスを終了させたり、ハングさせたりする可能性のある、エンベッダーのフォールバックに制御を渡します。- クリティカルパスにスプラッシュ画面、オンボーディングのカルーセル、起動時モーダルを置く — どれも最初に操作可能なフレームの表示を遅らせます。明示的なプロダクト上の判断に基づいて表示を制御し、反射的に追加しないでください。
- Riverpod 3 で捕捉したオブジェクトをそのままログに記録する — 原因ではなく
ProviderExceptionが記録されます。 - コールドスタートを微調整する — ザイゴートのフォーク、
Application.onCreate、VM スナップショットの読み込みが大部分を占めます。これらはプラットフォーム側の処理で、Dart から計測できません。自分で調整できるのは「最初のフレームをブロックしない」ことだけです。
完了の定義
- クラッシュシンクと両方のエラーハンドラーを、例外をスローする可能性のあるコードより前にインストールしている。
- エラーハンドラーはちょうど2つで、
runZonedGuardedはどこにもない。 -
PlatformDispatcher.onErrorはtrueを返し、それ自体が例外をスローしない。 -
runAppの前に設定とテーマを読み込み、最初のフレームを正しい状態で描画する。 - 実際のインフラは
bootstrap()で構築し、ProviderScopeのoverrideWithValueで注入する。機能コードから見えるのは、例外をスローするプレースホルダープロバイダーのみ。 - ウィジェットツリーはDIにもフレーバーにも依存しない。追加のエントリーポイントで異なるのはオーバーライドだけ。
- ウォームアップは
addPostFrameCallbackとunawaitedを使って遅延させる。 - ログ出力の前に
ProviderExceptionをラップ解除し、リトライポリシーを意図的に選択する。 - ルートに
WidgetsBindingObserverを1つだけ置き、inactive/pausedで永続状態をフラッシュし、resumedで再読み込みする。initStateで登録し、disposeで解除する。 -
main()は短く保ち、追加する各行がこれらのルールに照らして正当化できることを確認する。
関連するスキル
- このスキルは、グローバルエラーハンドラーのインストールと順序付けを担当します。ハンドラーが振り分ける
Result/Failureの分類(および分離時の再ラップ)については、error-handling-typed-resultsを参照してください。 - ウォームアップとバックグラウンドのフラッシュ処理で使う
unawaited/Future の破棄に関する規律については、async-safetyを参照してください。 - プレースホルダープロバイダーと
overrideWithValueを使ったDIパターンの詳細については、state-management-riverpodを参照してください。 - レイヤーグラフにおける
bootstrap()の位置については、flutter-architectureとproject-structure-and-packagesを参照してください。 AppのMaterialApp.routerが設定するgo_routerの構成については、navigation-and-routingを参照してください。bootstrap()が待機するDBのオープン/マイグレーション処理については、run-migrationとpersistence-driftを参照してください。- 各副作用を、オーバーライドされるまでは例外をスローする注入可能なインターフェースとして配線する方法、および再開時にハンドラーが再読み込みする時間の境界となる
clockProviderについては、service-boundary-and-nativeを参照してください。 - 初回描画の前にテーマを復元する方法については、
design-system-structureを参照してください。
参考資料
- Flutter — エラーの処理:https://docs.flutter.dev/testing/errors
- Flutter — アプリの起動/パフォーマンスに関するベストプラクティス:https://docs.flutter.dev/perf/best-practices
- API —
PlatformDispatcher.onError:https://api.flutter.dev/flutter/dart-ui/PlatformDispatcher/onError.html - API —
SchedulerBinding.addPostFrameCallback:https://api.flutter.dev/flutter/scheduler/SchedulerBinding/addPostFrameCallback.html - Riverpod — プロバイダーのオーバーライドとスコープ:https://riverpod.dev/docs/concepts/scopes
Provider/ChangeNotifier 付録
公式のFlutter provider + ChangeNotifier スタックでも、同じ順序が適用されます。変わるのはルートでのDI配線だけです。
- コンポジションルート。 上記と同様に
bootstrap()で実際のインフラを構築し、ProviderScopeのオーバーライドではなく、ツリーのルートにあるMultiProviderを通じて注入します。
runApp(
MultiProvider(
providers: [
Provider<AppDatabase>.value(value: deps.db),
ChangeNotifierProvider<SettingsController>(
create: (_) => SettingsController(deps.settings)..load(),
),
Provider<NotificationGateway>.value(value: deps.notifications),
],
child: const App(),
),
);
- 例外をスローするプレースホルダーは不要です。
providerはルックアップ対象がない場合にProviderNotFoundExceptionをスローするため、配線漏れはすでに明確に失敗します。プレースホルダーを独自に作る必要はありません。 - エラーハンドラー、順序付け、遅延ウォームアップ、ゾーンを使わないルール、および
return trueは同じです。これらはフレームワークに依存しないため、DIライブラリではなくmain()に置きます。 - ウォームアップは
addPostFrameCallbackから実行し、context.read<T>()でサービスを読み込みます(1回限りのコールバック内ではwatchを使いません)。 - リトライ。 無効化すべき組み込みのプロバイダーリトライ機能はありません。
ChangeNotifierは自身の状態(例:AsyncStatus.error)を通じて読み込み失敗を通知し、UIがそれを描画します。暗黙にリトライせず、明確にエラーを示してください。