跳转至

Flutter/Dart 代码审查最佳实践

一个不依赖库的全面检查清单,用于审查 Flutter/Dart 应用程序。无论使用哪种状态管理解决方案、路由库或 DI 框架,这些原则都适用。


1. 整体项目健康度

  • [ ] 项目遵循一致的文件夹结构(功能优先或分层优先)
  • [ ] 适当的关注点分离:UI、业务逻辑、数据层
  • [ ] 组件中不包含业务逻辑;组件纯粹是展示层
  • [ ] pubspec.yaml 保持整洁 — 没有未使用的依赖项,版本已适当固定
  • [ ] analysis_options.yaml 包含严格的 lint 集合和严格的分析器配置
  • [ ] 生产代码中没有 print() 语句 — 使用 dart:developer 的 log() 或日志包
  • [ ] 生成的文件(.g.dart、.freezed.dart、.gr.dart)是最新的或包含在 .gitignore 中
  • [ ] 平台特定代码被隔离在抽象之后

2. Dart 语言陷阱

  • [ ] 隐式 dynamic:缺少类型注解导致 dynamic — 启用 strict-casts、strict-inference、strict-raw-types
  • [ ] 误用空安全:过度使用 !(bang 运算符),而不是适当的空检查或 Dart 3 的模式匹配(if (value case var v?))
  • [ ] 类型提升失败:在本地变量提升可以工作的地方使用 this.field
  • [ ] 捕获过宽的异常:没有 on 子句的 catch (e);始终指定异常类型
  • [ ] 捕获 Error:Error 的子类型表示 bug,不应捕获
  • [ ] 未使用的 async:标记为 async 但不 await 的函数 — 不必要的开销
  • [ ] 过度使用 late:在可空类型或构造函数初始化更安全的地方使用 late;将错误推迟到运行时
  • [ ] 循环中的字符串连接:对于重复的字符串构建,使用 StringBuffer 而不是 +
  • [ ] const 上下文中的可变状态:const 构造器类的字段不应是可变的
  • [ ] 忽略 Future 返回值:使用 await 表示意图,或显式调用 unawaited()
  • [ ] 可用 final 的地方使用 var:局部变量优先使用 final,编译时常量优先使用 const
  • [ ] 相对导入:为了一致性使用 package: 导入
  • [ ] 公开的可变集合:公共 API 应返回不可变视图,而不是原始 List/Map
  • [ ] 缺少 Dart 3 模式匹配:优先使用 switch 表达式和 if-case,而不是冗长的 is 检查和手动转换
  • [ ] 用于多个返回值的临时类:使用 Dart 3 记录 (String, int),而不是单次使用的 DTO
  • [ ] 生产代码中的 print():使用 dart:developer 的 log() 或项目的日志包;print() 没有日志级别且无法过滤

3. 组件最佳实践

组件拆分:

  • [ ] 没有单个组件的 build() 方法超过约 80-100 行
  • [ ] 组件根据封装和变化方式(重建边界)进行拆分
  • [ ] 返回组件的私有 _build*() 辅助方法被提取为单独的组件类(允许元素重用、const 传播、框架优化)
  • [ ] 当不需要可变本地状态时,优先使用无状态组件
  • [ ] 如果提取的组件可重用,则放在单独的文件中

Const 的使用:

  • [ ] 尽可能使用 const 构造器 — 防止不必要的重建
  • [ ] 对不变的集合使用 const 字面量(const []、const {})
  • [ ] 如果所有字段都是 final,则构造器声明为 const

Key 的使用:

  • [ ] 在列表/网格中使用 ValueKey 以在排序时保持状态
  • [ ] 谨慎使用 GlobalKey — 仅在确实需要访问整棵树状态时
  • [ ] 不要在 build() 内使用 UniqueKey — 每帧强制重建
  • [ ] 如果基于数据对象标识而不是单个值,则使用 ObjectKey

主题和设计系统:

  • [ ] 颜色从 Theme.of(context).colorScheme 获取 — 不要硬编码 Colors.red 或十六进制值
  • [ ] 文本样式从 Theme.of(context).textTheme 获取 — 不要使用内联 TextStyle 的原始字体大小
  • [ ] 确认深色模式兼容性 — 不要假设浅色背景
  • [ ] 间距和尺寸使用一致的设计令牌或常量,而不是魔法数字

build 方法复杂度:

  • [ ] build() 内没有网络调用、文件 I/O 或重量级计算
  • [ ] build() 内没有 Future.then() 或 async 工作
  • [ ] build() 内没有创建订阅(.listen())
  • [ ] setState() 尽可能限制在小的子树中

4. 状态管理(不依赖库)

这些原则适用于所有 Flutter 状态管理解决方案(BLoC、Riverpod、Provider、GetX、MobX、Signals、ValueNotifier 等)。

架构:

  • [ ] 业务逻辑在组件层之外 — 在状态管理组件(BLoC、Notifier、Controller、Store、ViewModel 等)内
  • [ ] 状态管理器通过依赖注入接收依赖,而不是在内部构建
  • [ ] 服务或仓库层抽象数据源 — 组件和状态管理器不应直接调用 API 或数据库
  • [ ] 状态管理器具有单一职责 — 没有处理无关关注点的“上帝”管理器
  • [ ] 组件之间的依赖关系遵循解决方案的约定:
  • Riverpod 中:提供者通过 ref.watch 依赖其他提供者是预期的 — 仅标记循环或过度纠缠的链
  • BLoC 中:BLoC 不应直接依赖其他 BLoC — 优先使用共享仓库或表示层协调
  • 其他解决方案中:遵循组件间通信的文档化约定

不可变性和值相等性(用于不可变状态解决方案:BLoC、Riverpod、Redux):

  • [ ] 状态对象是不可变的 — 通过 copyWith() 或构造器创建新实例,而不是原地突变
  • [ ] 状态类正确实现 == 和 hashCode(所有字段都包含在比较中)
  • [ ] 机制在整个项目中保持一致 — 手动覆盖、Equatable、freezed、Dart 记录或其他
  • [ ] 状态对象中的集合没有作为原始可变 List/Map 公开

响应性纪律(用于响应式突变解决方案:MobX、GetX、Signals):

  • [ ] 状态仅通过解决方案的响应式 API 突变(MobX 中的 @action、signal 中的 .value、GetX 中的 .obs)— 直接字段突变会绕过变更跟踪
  • [ ] 派生值使用解决方案的计算机制,而不是冗余存储
  • [ ] 反应和释放器被正确清理(MobX 中的 ReactionDisposer、Signals 中的 effect 清理)

状态形状设计:

  • [ ] 互斥状态使用 sealed 类型、联合变体或解决方案的内置异步状态类型(例如 Riverpod 的 AsyncValue)— 不使用布尔标志(isLoading、isError、hasData)
  • [ ] 所有异步操作将加载、成功、错误建模为不同状态
  • [ ] 所有状态变体在 UI 中全面处理 — 没有静默忽略的情况
  • [ ] 错误状态具有用于显示的错误信息;加载状态不包含旧数据
  • [ ] 不使用可变数据作为加载指示器 — 状态是明确的
// 悪い例 — ブールフラグの混乱が不可能な状態を許可する
class UserState {
  bool isLoading = false;
  bool hasError = false; // isLoading && hasErrorが表現可能!
  User? user;
}

// 良い例(イミュータブルアプローチ) — sealed型が不可能な状態を表現不可能にする
sealed class UserState {}
class UserInitial extends UserState {}
class UserLoading extends UserState {}
class UserLoaded extends UserState {
  final User user;
  const UserLoaded(this.user);
}
class UserError extends UserState {
  final String message;
  const UserError(this.message);
}

// 良い例(リアクティブアプローチ) — observableのenum + データ、リアクティビティAPIを通じた変異
// enum UserStatus { initial, loading, loaded, error }
// ソリューションのobservable/signalを使用してstatusとdataを別々にラップする

重建优化:

  • [ ] 尽可能缩小状态消费者组件(Builder、Consumer、Observer、Obx、Watch 等)的作用域
  • [ ] 使用选择器,仅在特定字段变化时重建 — 不要在所有状态发出时重建
  • [ ] 使用 const 组件阻止重建通过树传播
  • [ ] 计算/派生状态被响应式计算,而不是冗余存储

订阅和释放:

  • [ ] 所有手动订阅(.listen())在 dispose() / close() 中取消
  • [ ] 流控制器在不再需要时关闭
  • [ ] 定时器在释放生命周期中取消
  • [ ] 优先使用框架管理生命周期,而不是手动订阅(声明式构建器优于 .listen())
  • [ ] 异步回调中的 setState 之前检查 mounted
  • [ ] 不要在 await 后使用 BuildContext 而不检查 context.mounted(Flutter 3.7+) — 旧上下文会导致崩溃
  • [ ] 不要在异步间隙后使用导航、对话框或 scaffold 消息,而不确认组件仍然挂载
  • [ ] 不要将 BuildContext 存储在单例、状态管理器或静态字段中

本地与全局状态:

  • [ ] 临时 UI 状态(复选框、滑块、动画)使用本地状态(setState、ValueNotifier)
  • [ ] 状态仅提升到需要共享的程度 — 不要过度全局化
  • [ ] 功能范围的状态在功能不再活动时正确释放

5. 性能

不必要的重建

  • [ ] 未在根小部件级别调用 setState() — 将状态变更局部化
  • [ ] 使用 const 小部件来阻止重建传播
  • [ ] 在独立重绘的复杂子树周围使用 RepaintBoundary
  • [ ] 将 AnimatedBuilder 的 child 参数用于与动画独立的子树

build() 内的高成本操作

  • [ ] build() 中没有对大型集合进行排序、过滤、映射 — 在状态管理层计算
  • [ ] build() 中没有编译正则表达式
  • [ ] MediaQuery.of(context) 的使用是具体的(例如:MediaQuery.sizeOf(context))

图像优化

  • [ ] 网络图像使用缓存(适合项目的缓存解决方案)
  • [ ] 图像分辨率适合目标设备(不要为缩略图加载 4K 图像)
  • [ ] 使用 Image.asset 和 cacheWidth/cacheHeight 按显示尺寸解码
  • [ ] 为网络图像提供占位符和错误小部件

延迟加载

  • [ ] 对于大型或动态列表,使用 ListView.builder / GridView.builder 而不是 ListView(children: [...])(对于小型静态列表,具体构造函数是合适的)
  • [ ] 为大型数据集实现分页
  • [ ] 在 Web 构建中对重量级库使用延迟加载(deferred as)

其他

  • [ ] 在动画中避免使用 Opacity 小部件 — 使用 AnimatedOpacity 或 FadeTransition
  • [ ] 在动画中避免裁剪 — 预先裁剪图像
  • [ ] 不要在小部件中重写 operator == — 改用 const 构造函数
  • [ ] 谨慎使用内在尺寸小部件(IntrinsicHeight、IntrinsicWidth)(会增加额外的布局路径)

6. 测试

测试类型与预期

  • [ ] 单元测试:覆盖所有业务逻辑(状态管理器、仓库、工具函数)
  • [ ] 小部件测试:覆盖单个小部件的行为、交互和视觉输出
  • [ ] 集成测试:端到端覆盖关键用户流程
  • [ ] 黄金测试:对设计关键的 UI 组件进行像素级比较

覆盖率目标

  • [ ] 业务逻辑的行覆盖率目标为 80% 以上
  • [ ] 所有状态转换都有对应的测试(加载→成功、加载→错误、重试等)
  • [ ] 测试边缘情况:空状态、错误状态、加载状态、边界值

测试隔离

  • [ ] 外部依赖(API 客户端、数据库、服务)已被模拟或伪造
  • [ ] 每个测试文件恰好测试一个类/单元
  • [ ] 测试验证行为而不是实现细节
  • [ ] 桩只定义每个测试所需的行为(最小化打桩)
  • [ ] 测试用例之间没有共享可变状态

小部件测试质量

  • [ ] 对异步操作正确使用 pumpWidget 和 pump
  • [ ] 正确使用 find.byType、find.text、find.byKey
  • [ ] 没有依赖时序的不稳定测试 — 使用 pumpAndSettle 或显式的 pump(Duration)
  • [ ] 测试在 CI 中运行,失败会阻止合并

7. 可访问性

语义小部件

  • [ ] 在自动标签不足的地方,使用 Semantics 小部件提供屏幕阅读器标签
  • [ ] 对纯装饰性元素使用 ExcludeSemantics
  • [ ] 使用 MergeSemantics 将相关小部件合并为单个可访问元素
  • [ ] 为图像设置 semanticLabel 属性

屏幕阅读器支持

  • [ ] 所有可交互元素都可聚焦并具有有意义的说明
  • [ ] 焦点顺序合理(遵循视觉阅读顺序)

视觉可访问性

  • [ ] 文本与背景的对比度至少为 4.5:1
  • [ ] 可点击目标至少为 48x48 像素
  • [ ] 颜色不是唯一的状态指示(与图标/文本一起使用)
  • [ ] 文本会根据系统字体大小设置缩放

交互可访问性

  • [ ] 没有无操作的 onPressed 回调 — 所有按钮要么执行某些操作,要么被禁用
  • [ ] 错误字段提供修正建议
  • [ ] 用户输入数据期间上下文不会意外改变

8. 平台特定注意事项

iOS/Android 差异

  • [ ] 在适当的地方使用平台自适应小部件
  • [ ] 正确处理后退导航(Android 返回按钮、iOS 滑动返回)
  • [ ] 状态栏和安全区域由 SafeArea 小部件处理
  • [ ] 平台特定权限已在 AndroidManifest.xml 和 Info.plist 中声明

响应式设计

  • [ ] 响应式布局使用 LayoutBuilder 或 MediaQuery
  • [ ] 断点定义一致(手机、平板、桌面)
  • [ ] 文本在小屏幕上不会溢出 — 使用 Flexible、Expanded、FittedBox
  • [ ] 横屏模式已测试或被明确锁定
  • [ ] Web 特定:支持鼠标/键盘交互,并存在悬停状态

9. 安全

安全存储

  • [ ] 机密数据(令牌、凭据)使用平台安全存储(iOS Keychain、Android EncryptedSharedPreferences)
  • [ ] 不在明文存储中保存密钥
  • [ ] 考虑为敏感操作添加生物识别门禁

API 密钥处理

  • [ ] API 密钥未硬编码在 Dart 源代码中 — 使用 --dart-define、从 VCS 排除的 .env 文件或编译时配置
  • [ ] 密钥未被提交到 git — 检查 .gitignore
  • [ ] 对真正保密的密钥使用后端代理(客户端不应持有服务器密钥)

输入验证

  • [ ] 所有用户输入在发送到 API 前都经过验证
  • [ ] 表单验证使用适当的验证模式
  • [ ] 没有对用户输入进行原始 SQL 或字符串插值
  • [ ] 导航前对深度链接 URL 进行验证和清理

网络安全

  • [ ] 所有 API 调用都强制使用 HTTPS
  • [ ] 对高安全应用考虑证书固定
  • [ ] 认证令牌能够正确刷新和过期
  • [ ] 机密数据不会被记录到日志或输出中

10. 包/依赖项审查

pub.dev 包评估

  • [ ] 检查 pub 点数(目标 130+/160)
  • [ ] 作为社区信号检查 点赞 和 热度
  • [ ] 确认 pub.dev 上的发布者已 认证
  • [ ] 检查最后发布日期 — 旧包(超过 1 年)存在风险
  • [ ] 检查未解决的问题和维护者的响应时间
  • [ ] 确认许可证与项目兼容
  • [ ] 确认平台支持覆盖目标

版本约束

  • [ ] 依赖项使用 caret 语法(^1.2.3) — 允许兼容更新
  • [ ] 仅在绝对必要时固定精确版本
  • [ ] 定期运行 flutter pub outdated 以跟踪过期依赖
  • [ ] 生产 pubspec.yaml 中不要覆盖依赖 — 仅使用带注释/问题链接的临时修复
  • [ ] 最小化临时依赖数量 — 每个依赖都是攻击面

单体仓库特定(melos/workspace)

  • [ ] 内部包只从公共 API 导入 — 不要使用 package:other/src/internal.dart(会破坏 Dart 包的封装)
  • [ ] 内部包依赖使用工作区解析,而不是硬编码的 path: ../../ 相对路径
  • [ ] 所有子包共享或继承根目录的 analysis_options.yaml

11. 导航与路由

一般原则(适用于任意路由解决方案)

  • [ ] 一致地使用一种路由方法 — 不混用声明式路由器和命令式 Navigator.push
  • [ ] 路由参数有类型 — 不使用 Map<String, dynamic> 或 Object? 转换
  • [ ] 路由路径定义为常量、enum 或生成代码 — 没有散落在代码中的魔法字符串
  • [ ] 认证守卫/重定向集中管理 — 不在各个屏幕中重复
  • [ ] 深度链接已在 Android 和 iOS 上配置
  • [ ] 导航前对深度链接 URL 进行验证和清理
  • [ ] 导航状态可测试 — 路由变更可在测试中验证
  • [ ] 所有平台上的返回行为正确

12. 错误处理

框架错误处理

  • [ ] 重写 FlutterError.onError 以捕获框架错误(构建、布局、渲染)
  • [ ] 设置 PlatformDispatcher.instance.onError 用于捕获 Flutter 未捕获的异步错误
  • [ ] 为发布模式自定义 ErrorWidget.builder(用户友好,而不是红色屏幕)
  • [ ] 在 runApp 周围添加全局错误捕获包装器(例如:runZonedGuarded、Sentry/Crashlytics 包装器)

错误报告

  • [ ] 已集成错误报告服务(Firebase Crashlytics、Sentry 或同等服务)
  • [ ] 非致命错误连同堆栈跟踪一起报告
  • [ ] 状态管理错误观察者已连接到错误报告(例如:BlocObserver、ProviderObserver 或该解决方案的同等物)
  • [ ] 为调试在错误报告中附加用户可识别信息(用户 ID)

优雅降级

  • [ ] API 错误变成用户友好的错误 UI,而不是崩溃
  • [ ] 针对临时网络故障有重试机制
  • [ ] 离线状态被优雅处理
  • [ ] 状态管理的错误状态包含用于显示的错误信息
  • [ ] 原始异常(网络、解析)在到达 UI 前被映射为用户友好且本地化的消息 — 不向用户显示原始异常字符串

13. 国际化(l10n)

设置

  • [ ] 已配置本地化解决方案(Flutter 内置 ARB/l10n、easy_localization 或同等方案)
  • [ ] 支持的区域设置已在应用配置中声明

内容

  • [ ] 所有面向用户的字符串都使用本地化系统 — 小部件中没有硬编码字符串
  • [ ] 模板文件包含面向翻译者的说明/上下文
  • [ ] 复数、性别、选择使用 ICU 消息语法
  • [ ] 占位符有类型定义
  • [ ] 各区域设置之间没有缺失键

代码审查:

  • [ ] 本地化访问器在整个项目中一致使用
  • [ ] 日期、时间、数值、货币格式支持区域设置
  • [ ] 如果目标语言包括阿拉伯语、希伯来语等,则支持文本方向(RTL)
  • [ ] 本地化文本中没有字符串拼接 — 使用参数化消息

14. 依赖注入

原则(适用于任意 DI 方案):

  • [ ] 类在层边界处依赖抽象(接口),而不是具体实现
  • [ ] 依赖关系通过构造函数、DI 框架或提供者图从外部提供 — 不在内部创建
  • [ ] 注册区分生命周期:单例、工厂和延迟单例
  • [ ] 环境特定绑定(dev/staging/prod)使用配置,而不是运行时 if 检查
  • [ ] DI 图中没有循环依赖
  • [ ] 服务定位器调用(如果使用时)没有散落在整个业务逻辑中

15. 静态分析

配置:

  • [ ] analysis_options.yaml 存在并启用严格设置
  • [ ] 严格分析器设置:strict-casts: true、strict-inference: true、strict-raw-types: true
  • [ ] 包含全面的 Lint 规则集(very_good_analysis、flutter_lints 或自定义严格规则)
  • [ ] Monorepo 内的所有子包继承或共享根解析选项

应用:

  • [ ] 已提交代码中没有未解决的分析器警告
  • [ ] Lint 抑制(// ignore:)有解释原因的注释加以证明
  • [ ] flutter analyze 在 CI 中运行,并且失败会阻止合并

无论使用哪个 Lint 包都应确认的主要规则:

  • [ ] prefer_const_constructors — 小部件树性能
  • [ ] avoid_print — 使用适当的日志记录
  • [ ] unawaited_futures — 防止即发即忘异步 bug
  • [ ] prefer_final_locals — 变量级别不可变性
  • [ ] always_declare_return_types — 显式契约
  • [ ] avoid_catches_without_on_clauses — 特定错误处理
  • [ ] always_use_package_imports — 一致的导入风格

状态管理快速参考

以下表格将普遍原则映射到流行解决方案中的实现。请使用它来将审查规则适配到项目所使用的解决方案。

原则 BLoC/Cubit Riverpod Provider GetX MobX Signals 内置
状态容器 Bloc/Cubit Notifier/AsyncNotifier ChangeNotifier GetxController Store signal() StatefulWidget
UI 消费者 BlocBuilder ConsumerWidget Consumer Obx/GetBuilder Observer Watch setState
选择器 BlocSelector/buildWhen ref.watch(p.select(...)) Selector N/A computed computed() N/A
副作用 BlocListener ref.listen Consumer 回调 ever()/once() reaction effect() 回调
释放 通过 BlocProvider 自动 .autoDispose 通过 Provider 自动 onClose() ReactionDisposer 手动 dispose()
测试 blocTest() ProviderContainer 直接测试 ChangeNotifier 在测试中使用 Get.put 直接测试 Store 直接测试 signal 小部件测试

来源

本页原文 Markdown:在 AtomGit 查看·内容源自开源项目 ev/ECC