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