工程架构:分层、依赖注入与错误上报
架构是为人服务的:让新人三天内知道改哪里、让 bug 能定位、让上线后能看到崩溃。Flutter 项目最常见的错不是“架构太糙”,而是一开始就太重——四层十几个接口,需求还没跑通维护成本先上来了。本章给出一个能演进的中间方案:按功能分目录、轻量依赖注入、统一错误模型与全局上报,最后谈什么时候才该加层。
分层与目录组织
lib/
├── main.dart # 入口:初始化、错误上报、runApp
├── app.dart # MaterialApp 与路由
├── core/ # 跨功能基础设施:env、errors、logger、网络客户端
├── features/product/ # 按功能切分,一个功能一个目录
│ ├── data/product_repository.dart # 网络与缓存读写
│ ├── domain/product.dart # 纯 Dart 模型
│ └── ui/product_list_page.dart # 页面与 Widget
| 层 | 职责 | 允许依赖 |
|---|---|---|
| UI | 渲染、交互、loading 与错误态 | 领域模型与状态层 |
| 领域 | 类型、值对象、业务规则(纯 Dart) | 不依赖 Flutter,便于单测 |
| 数据 | 网络、数据库、缓存的读写与转换 | 领域模型 |
规矩只有一条:UI 不直接调 HTTP,数据层不 import Flutter。做到这两点,界面可换、接口可换、逻辑可测。
依赖注入:先手工,再考虑框架
// ① 构造函数注入(推荐起步):零依赖、可测试性最好,形如
// const ProductListPage({super.key, required this.repository});
// ② get_it:注册单例,适合对象多、层级深的中大型项目
final getIt = GetIt.instance;
void setupLocator() {
getIt.registerLazySingleton<ApiClient>(() => ApiClient(baseUrl: Env.apiBase));
getIt.registerFactory<ProductRepository>(() => HttpProductRepository(getIt<ApiClient>()));
}
// ③ Riverpod:自带作用域与测试覆盖,无需全局清理
final productRepoProvider = Provider<ProductRepository>(
(ref) => HttpProductRepository(ref.watch(apiClientProvider)),
);
// 测试里直接替换:productRepoProvider.overrideWithValue(FakeProductRepository())
取舍标准很简单:测试里需要临时替换依赖才上框架,不需要就用构造函数,过早引入 DI 框架只会多一堆样板代码。
统一错误模型
// core/errors.dart:把各类异常收敛成三种,UI 只需处理这三种
sealed class AppError implements Exception {
const AppError(this.message);
final String message;
}
class NetworkError extends AppError { // 超时、断网、5xx
const NetworkError(super.message, {this.statusCode});
final int? statusCode;
}
class BizError extends AppError { // 服务端返回的 code 非 0
const BizError(super.message, {required this.code});
final int code;
}
class UnknownError extends AppError { // 兜底:解析失败、未预期异常
const UnknownError(super.message);
}
// UI 侧按类型给反馈,而不是把原始异常字符串丢给用户
String describe(AppError e) => switch (e) {
NetworkError() => '网络不太顺畅,请稍后重试',
BizError(:final message) => message,
UnknownError() => '出了点问题,我们已经记录',
};
sealed class 配 switch 表达式,新增错误类型时忘记处理会直接编译失败,这比写文档可靠。
全局错误捕获与上报
void main() {
runZonedGuarded(() { // ④ Zone 兜底:其他异步异常
WidgetsFlutterBinding.ensureInitialized();
FlutterError.onError = (details) { // ① 框架层:build、layout、paint
FlutterError.presentError(details); // 开发期保留红屏
if (Env.isProd) Sentry.captureException(details.exception, stackTrace: details.stack);
};
PlatformDispatcher.instance.onError = (error, stack) { // ② 引擎层:平台通道等
Sentry.captureException(error, stackTrace: stack);
return true; // 返回 true 表示已处理,避免崩溃
};
runApp(const MyApp());
}, (error, stack) => Sentry.captureException(error, stackTrace: stack));
}
| 上报点 | 覆盖范围 | 注意 |
|---|---|---|
FlutterError.onError | Widget 构建与布局异常 | 开发期保留红屏,生产期上报 |
PlatformDispatcher.instance.onError | 平台通道、异步未捕获异常 | 必须返回 true 才算处理 |
runZonedGuarded | 其他 Zone 内异步异常 | 与 ensureInitialized 配合 |
| Sentry / Crashlytics | 崩溃聚合与符号化 | 不上传符号表栈会乱码 |
日志与埋点规范
| 级别 | 用途 | 生产环境 |
|---|---|---|
| debug | 开发细节,如请求体 | 关闭 |
| info | 关键流程节点,如“进入结算” | 采样上报 |
| warn / error | 可恢复异常与功能异常 | 上报并告警 |
埋点命名用 模块_对象_动作 三段式(如 product_detail_click_buy),全小写加下划线,禁止把中文文案当事件名。隐私是红线:不上报手机号、身份证、精确位置、明文 token;需要用户维度时用服务端下发的匿名 ID。上报前统一过一遍脱敏函数,比事后审计便宜得多。
代码规范
# analysis_options.yaml
include: package:flutter_lints/flutter.yaml
linter:
rules:
prefer_const_constructors: true
avoid_print: true # 用统一 logger,禁止裸 print
require_trailing_commas: true
提交前跑 dart format . 与 flutter analyze,并在 CI 上强制。规则不要一次开上百条,官方 flutter_lints 加三五条自己踩过坑的规则最实用,条目过多只会让人想办法绕过。
架构演进建议
- 第一步:
lib/下按features/切分,UI 与网络代码分开,这一步能用很久。 - 第二步:抽
core/放环境、错误、日志、网络客户端,避免每个功能各写一套。 - 第三步:出现“同一份数据多处使用且需要缓存与失效”时,再引入状态管理与仓储接口。
- 第四步:只有业务规则本身复杂(计价、多端共用逻辑)时,才值得上 UseCase 与完整 Clean Architecture;判断信号是某一层只有一行转发、没有任何逻辑,那一层就是多余的。
常见坑
| 坑 | 现象 | 正确做法 |
|---|---|---|
| UI 直接调 HTTP | 换接口要改页面,无法 mock | 一律经仓储层 |
| 全局单例满天飞 | 测试互相污染、无法替换 | 构造函数注入或 Provider 覆盖 |
| 原始异常展示给用户 | 用户看到 SocketException | 统一错误模型加友好文案 |
| 装了崩溃 SDK 不上传符号表 | 崩溃栈全是乱码 | 构建时输出并上传符号表 |
小结:架构从“按功能分目录、UI 不碰网络、数据层不依赖 Flutter”起步就够,依赖注入先用构造函数、测试需要替换依赖时再上 get_it 或 ProviderScope 覆盖;错误用 sealed class 收敛成网络、业务、未知三类,并靠 FlutterError.onError、PlatformDispatcher.instance.onError 与 runZonedGuarded 三处全局兜住上报;日志埋点分级、命名规范且不碰隐私,架构要跟着痛点长,不要跟着 PPT 长。