mcpField
A
app-startup-and-bootstrap

app-startup-and-bootstrap

github.com/zakariaf開発63150 回インストール5 週間前に更新
リポジトリ

app-startup-and-bootstrap について

Flutter開発者向けの起動・bootstrapガイドで、エラー処理、依存注入、初回フレームの早期表示を扱います。起動後のウォームアップや、バックグラウンド移行時の状態保存も説明します。

主な機能

  1. 01起動直後にエラーハンドラーを設定
  2. 02bootstrap()で依存を構築・注入
  3. 03初回フレーム前にテーマと設定を読み込み
  4. 04初回フレーム後にサービスをウォームアップ
  5. 05バックグラウンド移行時に状態をフラッシュ
  6. 06Riverpodエラーの再試行方針を設定

活用シーン

  1. 01Flutterアプリの起動フロー設計
  2. 02Riverpodの依存オーバーライド設定
  3. 03ウォームアップによる初回フレーム遅延の防止
  4. 04アプリ一時停止時の保留中書き込みへの対応

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/zakariaf/flutter-skills --skill app-startup-and-bootstrap

インストール後、Claude Code で/app-startup-and-bootstrapを入力して読み込まれたか確認します。

詳細

アプリの起動とブートストラップ

main() の役割は、クラッシュを捕捉する仕組みを設置し、最初のフレームに必要な最小限の状態を読み込み、実際の依存関係をツリーに接続して、runApp に引き継ぐことだけです。高速で、順序が明確で、失敗を隠せないようにします。コストのかかる処理はすべて、最初のフレームの後に実行するか、起動経路から完全に切り離します。

絶対に守るルール

  1. エラーハンドラーを最初に設定し、例外が発生する可能性のある処理より前に置きます。 WidgetsFlutterBinding.ensureInitialized() の直後に、クラッシュログの出力先、FlutterError.onError、PlatformDispatcher.instance.onError を設定します。最も例外が発生しやすいのは DB のオープンです。その後にハンドラーを設定すると、そもそもの目的が逆転してしまいます。
  2. エラーハンドラーは 2 つだけにし、ゾーンは使いません。 FlutterError.onError(ビルド/レイアウト/ペイントのエラー)と PlatformDispatcher.instance.onError(捕捉されない非同期エラー)ですべての経路をカバーできます。runZonedGuarded は絶対に追加しないでください。「3 つすべてが必要」という助言は、クラッシュ SDK 向けのものです(Sentry は初期化処理をゾーンでラップします)。そのような SDK がなければ、ゾーンには何の利点もなく、ゾーンの不一致という、文書化された危険を招くだけです。この警告に対する Flutter 自身の解決策は、ゾーンを削除することです。
  3. PlatformDispatcher.onError は常に true を返します。 false を返すとエンベッダーのフォールバックに処理が渡り、プロセスが終了したりハングしたりする可能性があります。デバッグコンソールへの出力には return kReleaseMode ではなく、kDebugMode のもとで debugPrint を使います。
  4. エラーハンドラーから例外を投げないでください。 本体を裸の try/catch (_) で囲み、破棄するエラーが意図的なものである理由を説明するコメントを残します。そうしないと、誰かが「修正」しようとして、ハンドラー内で無限再帰が発生するようにしてしまいます。
  5. runApp の前に設定/テーマを読み込みます。 パレット、テキストスケールのポリシー、ロケール、最初の描画に関わる選択は、同期的に読み込みます(数行程度なら 10ms 未満です)。これにより、最初のフレームが正しく描画されます。誤ったテーマが一瞬表示されるのは、見た目だけの問題ではなく、目に見える不具合です。
  6. コンポジションルートの bootstrap() で実際のインフラを構築し、オーバーライド経由で注入します。 機能コードは、例外を投げるプレースホルダープロバイダーに依存します。bootstrap() で実際の DB/サービスを構築し、ルートの ProviderScope で overrideWithValue を使ってオーバーライドします。配線漏れがあれば、null を返すのではなく、起動時に明確に失敗します。これによりテスト用の差し替え箇所も確保できます。
  7. ウォームアップは addPostFrameCallback に遅延し、main() では絶対に await しません。 プラグインやエンジンのウォームアップ(オーディオ、TTS、初回のネットワークハンドシェイクなど)は、メインスレッドで同期的にコストを発生させ、コールドスタート経路で ANR を引き起こします。最初の使用可能なフレームの後に、ベストエフォートで実行します。
  8. 最初のフレームをブロックしないでください。 起動経路で唯一の await は、避けられないブロッカーである DB のオープンだけです。マイグレーションの実行中に空白のウィンドウを表示するのではなく、すぐに UI シェルを表示します。
  9. ログ出力の前に ProviderException をアンラップします。 Riverpod 3 はプロバイダーの失敗をラップして再スローします。ラッパーをログに記録すると、本当の原因が隠れ、どのログも ProviderException と表示されてしまいます。
  10. アプリの失敗モデルに合わせて Riverpod のリトライを調整します。 Riverpod 3 は、失敗したプロバイダーをデフォルトで再試行します(指数バックオフで約 38 秒)。唯一の失敗要因がローカルのバグ(破損した DB、ファイルの欠落など)であるプロバイダーでは、retry: (count, error) => null を設定し、スピナーの裏で再試行を繰り返すのではなく、すぐに明確に失敗させます。
  11. 単一のライフサイクルオブザーバーで、バックグラウンド時に永続状態をフラッシュします。 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 を参照してください。

参考資料

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がそれを描画します。暗黙にリトライせず、明確にエラーを示してください。

コメント