mcpField
A
app-startup-and-bootstrap

app-startup-and-bootstrap

github.com/zakariaf开发63150 次安装5 周前更新
代码仓库

关于 app-startup-and-bootstrap

面向 Flutter 开发者的应用启动与 bootstrap 技能,说明如何安装错误处理、注入依赖并尽早显示首帧。还规定延后预热及应用后台时持久化状态。

主要功能

  1. 01在启动早期安装错误处理器
  2. 02通过 bootstrap() 构建并注入依赖
  3. 03首帧前读取主题与设置
  4. 04首帧后执行服务预热
  5. 05应用转入后台时刷新状态
  6. 06为 Riverpod 错误设置重试策略

适用场景

  1. 01设计 Flutter 应用启动流程
  2. 02配置 Riverpod 依赖覆盖
  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。最可能抛出异常的步骤是打开数据库;如果在这之后才安装处理器,就完全背离了安装它们的初衷。
  2. 错误处理器只能有两个——不要使用 zone。 FlutterError.onError(构建/布局/绘制错误)和 PlatformDispatcher.instance.onError(未捕获的异步错误)覆盖所有路径。绝不要添加 runZonedGuarded。“三个都要”的建议是针对崩溃 SDK 的(Sentry 会在 zone 中包装其初始化);如果没有这类 SDK,zone 毫无益处,反而会带来有据可查的 zone 不匹配陷阱。Flutter 官方对此警告的修复建议就是移除 zone。
  3. PlatformDispatcher.onError 必须始终返回 true。 返回 false 会将错误转交给 embedder 回退处理,此时进程可能退出或挂起。在 kDebugMode 下使用 debugPrint 来确保错误在调试控制台中可见,不要使用 return kReleaseMode。
  4. 绝不能让错误处理器抛出异常。 用裸 try/catch (_) 包住处理器主体,并保留注释说明为何故意丢弃捕获的错误——否则有人可能会“修复”它,导致处理器内部无限递归。
  5. 在 runApp 之前读取设置/主题。 同步读取调色板、文本缩放策略、区域设置以及任何影响首帧显示的选择(只需读取少量记录,耗时不到 10 毫秒),以确保首帧显示正确。主题闪烁是明显的缺陷,不是外观上的小问题。
  6. 在组合根 bootstrap() 中构建真实基础设施,通过覆盖注入。 功能代码依赖会抛出异常的占位提供者;bootstrap() 构建真实数据库/服务,并通过 overrideWithValue 在根 ProviderScope 中覆盖它们。遗漏接线时会在启动时明确失败,绝不会返回 null。这也是测试的接入点。
  7. 将预热延后到 addPostFrameCallback;绝不要在 main() 中等待它。 任何插件/引擎预热(音频、TTS、首次网络握手)都会在主线程上同步执行耗时操作,并在冷启动路径上造成 ANR。在首帧可用后尽力启动预热。
  8. 不要阻塞首帧。 启动路径中唯一允许的 await 是无法避免的阻塞操作(打开数据库)。迁移运行期间应立即显示 UI 外壳,而不是让窗口一片空白。
  9. 记录日志前先解包 ProviderException。 Riverpod 3 会将提供者故障包装后重新抛出;记录包装异常会隐藏真正原因,并导致每条日志都显示为 ProviderException。
  10. 根据应用的故障模型调整 Riverpod 重试策略。 Riverpod 3 默认会重试失败的提供者(指数退避约 38 秒)。对于唯一可能因本地错误而失败的提供者(数据库损坏、文件缺失),设置 retry: (count, error) => null,使其立即明确失败,而不是在加载指示器后面不断重试。
  11. 通过单个生命周期观察器在后台刷新持久状态。 注册一个 WidgetsBindingObserver,并在 didChangeAppLifecycleState 中于应用进入 inactive/paused 时刷新待处理写入——操作系统可能在后台应用不再收到任何回调时将其终止;在 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

两个错误处理器

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)不导入任何具体实现,也不读取任何 flavor 标志。多个入口点(main_dev.dart、main_prod.dart、每个商店各自的 flavor)共用一个 App 和一个占位 Provider 列表;只有 bootstrap() 中的 overrides 不同,因此各个 main 文件可以逐行比较差异。测试通过使用 fake 或内存 DB 覆盖相同的 Provider,复用完全相同的接缝——不会运行实际的 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
  });
}

预热策略及其失败策略在设计上截然相反:预热失败会让首次使用稍微变慢,也可以保持静默;预热所针对的实际操作失败时,则必须明确报错。不要混淆二者。

应用生命周期:切到后台时刷新

// 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;
    }
  }
}

只注册一次观察器,并在 dispose 中移除。不要在各个功能组件中分散实现 didChangeAppLifecycleState——功能模块应在其 repository/notifier 上提供 flush(),再由根观察器调用。任何对时间敏感的代码都必须通过 clockProvider 重新读取时间(参见 service-boundary-and-native),绝不能使用 DateTime.now()。

唯一的阻塞点:打开 DB

读取十几行设置数据微不足道,可以安全地 await。真正的风险是在更新后的首次启动时执行模式迁移——在用户和应用之间进行无界操作。规则不是“让迁移变快”,而是立即绘制 UI 外壳,而不是显示空白窗口,并在迁移前创建快照,以便在迁移失败时恢复。迁移流程参见 run-migration,连接设置参见 persistence-drift。

反模式

  • 用 runZonedGuarded 包裹 runApp——没有崩溃 SDK 时,只会带来 zone 不匹配警告,没有其他好处;这两个处理器已经覆盖所有路径。
  • 在 main() 中 await service.warmUp()——同步 binder/IPC 开销发生在主线程上 → 冷启动时触发 ANR。
  • 在 runApp 后读取主题/区域设置——必然导致首帧使用错误主题,随后出现明显的切换。
  • 将 get_it / injectable / MultiProvider 作为第二个 DI 容器——ProviderScope 的 overrides 本身就是 DI;并行容器会成为第二个事实来源。
  • 从 PlatformDispatcher.onError 返回 false——会将控制权交给 embedder 的回退处理,它可能会终止或挂起进程。
  • 在关键路径上显示启动画面、引导轮播或启动时模态框——每一种都会延迟首个可用帧;只有经过明确的产品决策后才启用,绝不要条件反射式地添加。
  • 在 Riverpod 3 中记录捕获到的原始对象——记录的是 ProviderException,而不是其原因。
  • 过度优化冷启动——zygote fork、Application.onCreate 和 VM 快照加载占据主要耗时,属于平台开销,无法从 Dart 侧测量;你唯一能控制的是“不要阻塞首帧”。

完成标准

  • 在任何可能抛出异常的代码运行前,安装崩溃收集器和两个错误处理器。
  • 恰好有两个错误处理器;任何地方都不得使用 runZonedGuarded。
  • PlatformDispatcher.onError 返回 true,且自身不会抛出异常。
  • 在 runApp 前读取设置和主题;首帧显示正确。
  • 在 bootstrap() 中构建真实基础设施,并通过 ProviderScope 的 overrideWithValue 注入;功能代码只能看到会抛出异常的占位 Provider。
  • Widget 树不感知 DI,也不感知 flavor;额外的入口点只能在 overrides 上有所不同。
  • 通过 addPostFrameCallback 和 unawaited 延迟预热。
  • 日志记录前先解包 ProviderException;有意选择重试策略。
  • 一个根级 WidgetsBindingObserver 在 inactive/paused 时刷新持久化状态,并在 resumed 时重新读取;在 initState 中注册,在 dispose 中移除。
  • main() 保持简短,其中每一行新增代码都必须能依据这些规则说明其必要性。

相关技能

  • 本技能负责安装和排序全局错误处理器;只有在需要了解处理器要路由到的 Result/Failure 分类(以及隔离区重新包装)时,才参阅 error-handling-typed-results。
  • 预热和后台刷新调用依赖的 unawaited/Future 丢弃规范,请参阅 async-safety。
  • 关于占位 Provider + overrideWithValue 的 DI 模式详解,请参阅 state-management-riverpod。
  • 关于 bootstrap() 在分层结构中的位置,请参阅 flutter-architecture 和 project-structure-and-packages。
  • 关于 App 的 MaterialApp.router 所配置的 go_router,请参阅 navigation-and-routing。
  • 关于 bootstrap() 等待的数据库打开/迁移流程,请参阅 run-migration 和 persistence-drift。
  • 关于如何将每种副作用连接为可注入、覆盖前会抛出异常的接口,以及恢复处理器重新读取的 clockProvider 时间接缝,请参阅 service-boundary-and-native。
  • 关于在首帧绘制前恢复主题,请参阅 design-system-structure。

参考资料

Provider / ChangeNotifier 附录

官方 Flutter provider + ChangeNotifier 技术栈也遵循相同的顺序;只有根节点的 DI 接线方式有所变化。

  • 组合根。 按上述方式在 bootstrap() 中构建真实基础设施,然后在树根通过 MultiProvider 注入,而不是使用 ProviderScope overrides:
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,因此遗漏接线本身就会明确报错——无需自行编写占位对象。
  • 错误处理器、执行顺序、延迟预热、禁止使用 zone 的规则和 return true 都完全相同——这些与框架无关,应放在 main() 中,而不是 DI 库中。
  • 预热通过 addPostFrameCallback 触发,并使用 context.read<T>() 读取服务(一次性回调中绝不使用 watch)。
  • 重试。 provider 没有内置的重试功能需要禁用;ChangeNotifier 通过自身状态(例如 AsyncStatus.error)呈现加载失败,由 UI 渲染——应明确显示错误,而不是静默重试。

评论

支持 Markdown