工程架构:分层、依赖注入与错误上报

架构是为人服务的:让新人三天内知道改哪里、让 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 classswitch 表达式,新增错误类型时忘记处理会直接编译失败,这比写文档可靠。

全局错误捕获与上报

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.onErrorWidget 构建与布局异常开发期保留红屏,生产期上报
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_itProviderScope 覆盖;错误用 sealed class 收敛成网络、业务、未知三类,并靠 FlutterError.onErrorPlatformDispatcher.instance.onErrorrunZonedGuarded 三处全局兜住上报;日志埋点分级、命名规范且不碰隐私,架构要跟着痛点长,不要跟着 PPT 长。

笔记加载中…