Flutter/Dart 代码审查最佳实践
全面、与库无关的清单,用于审查 Flutter/Dart 应用程序。这些原则适用于任何状态管理解决方案、路由库或依赖注入框架。
1. 项目整体健康度
- 项目遵循一致的文件夹结构(功能优先或层级优先)
- 关注点分离得当:UI、业务逻辑、数据层
- widget 中不含业务逻辑;widget 纯粹是表现性的
-
pubspec.yaml保持整洁 — 没有未使用的依赖,版本已适当锁定 -
analysis_options.yaml包含严格的 lint 规则集,并启用了严格的分析器设置 - 生产代码中没有
print()语句 — 使用dart:developerlog()或日志包 - 生成的文件(
.g.dart、.freezed.dart、.gr.dart)是最新版本或已加入.gitignore - 平台特定代码已隔离在抽象层之后
2. Dart 语言陷阱
- 隐式
dynamic:缺少类型注解导致dynamic— 启用strict-casts、strict-inference、strict-raw-types - 空安全误用:过度使用
!(感叹号操作符)代替正确的空检查或 Dart 3 模式匹配(if (value case var v?)) - 类型提升失败:在局部变量提升有效的情况使用了
this.field - 过度捕获:
catch (e)没有on子句;始终指定异常类型 - 捕获
Error:Error子类型表示 bug,不应被捕获 - 未使用的
async:标记为async但从未await的函数 — 带来不必要的开销 - 过度使用
late:在可空类型或构造函数初始化更安全的地方使用了late;将错误推迟到运行时 - 循环中的字符串拼接:使用
StringBuffer代替+进行迭代字符串构建 -
const上下文中的可变状态:const构造函数类中的字段不应是可变的 - 忽略
Future返回值:使用await或显式调用unawaited()来表明意图 - 使用
var而final也可行:优先对局部变量使用final,对编译时常量使用const - 相对导入:使用
package:导入以保持一致性 - 暴露可变集合:公共 API 应返回不可修改的视图,而不是原始的
List/Map - 缺少 Dart 3 模式匹配:优先使用
switch表达式和if-case而不是冗长的is检查和手动类型转换 - 为返回多个值创建一次性类:使用 Dart 3 的记录
(String, int)代替一次性 DTO - 生产代码中的
print():使用dart:developerlog()或项目的日志包;print()没有日志级别,无法被过滤
3. Widget 最佳实践
Widget 分解:
- 没有单个 widget 的
build()方法超过约 80-100 行 - Widget 根据封装性和变化方式(重建边界)进行拆分
- 返回 widget 的私有
_build*()辅助方法被提取到单独的 widget 类中(这有助于元素复用、常量传播和框架优化) - 在没有可变局部状态时,优先使用无状态 widget 而非有状态 widget
- 可复用的提取 widget 应放在单独的文件中
常量使用:
- 尽可能使用
const构造函数 — 防止不必要的重建 - 对于不变的集合使用
const字面量(const []、const {}) - 当所有字段都是
final时,构造函数声明为const
Key 使用:
- 在列表/网格中使用
ValueKey以在重新排序时保持状态 - 谨慎使用
GlobalKey— 仅当确实需要跨 widget 树访问状态时 - 避免在
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 等)。
架构:
- 业务逻辑位于 widget 层之外 — 在状态管理组件中(BLoC、Notifier、Controller、Store、ViewModel 等)
- 状态管理器通过依赖注入接收依赖,而不是内部构造它们
- 服务或仓储层抽象数据源 — widget 和状态管理器不应直接调用 API 或数据库
- 状态管理器具有单一职责 — 没有处理不相关业务的“上帝”管理器
- 跨组件依赖遵循该解决方案的约定:
- 在 Riverpod 中:provider 通过
ref.watch依赖于其他 provider 是预期的 — 只标记循环或过度纠缠的链 - 在 BLoC 中:bloc 不应直接依赖于其他 bloc — 优先使用共享仓储或表示层协调
- 在其他解决方案中:遵循文档化的跨组件通信约定
- 在 Riverpod 中:provider 通过
不可变性与值相等(适用于不可变状态解决方案:BLoC、Riverpod、Redux):
- 状态对象是不可变的 — 通过
copyWith()或构造函数创建新实例,绝不就地修改 - 状态类正确实现
==和hashCode(比较中包含所有字段) - 整个项目中的机制保持一致 — 手动覆盖、
Equatable、freezed、Dart 记录等 - 状态对象内部的集合不作为原始可变的
List/Map暴露
响应式规范(适用于响应式可变解决方案:MobX、GetX、Signals):
- 仅通过解决方案的响应式 API 修改状态(MobX 中的
@action、signal 上的.value、GetX 中的.obs)— 直接字段修改会绕过变更跟踪 - 派生值使用解决方案的计算机制,而不是冗余存储
- 反应和释放器已正确清理(MobX 中的
ReactionDisposer,Signals 中的 effect 清理)
状态形状设计:
- 互斥状态使用密封类、联合变体或解决方案内置的异步状态类型(例如 Riverpod 的
AsyncValue)— 而不是布尔标志(isLoading、isError、hasData) - 每个异步操作将加载、成功和错误建模为独立状态
- 所有状态变体在 UI 中得到穷尽处理 — 没有静默忽略的情况
- 错误状态携带错误信息用于显示;加载状态不携带陈旧数据
- 可空数据不作为加载指示器使用 — 状态是明确的
// 糟糕 — 布尔标志的“汤”允许了不可能的状态
class UserState {
bool isLoading = false;
bool hasError = false; // isLoading && hasError 是可表示的!
User? user;
}
// 好(不可变方式)— 密封类型使不可能的状态不可表示
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);
}
// 好(响应式方式)— 可观察枚举 + 数据,通过响应式 API 进行修改
// enum UserStatus { initial, loading, loaded, error }
// 使用您解决方案的可观察对象/signal 分别包装 status 和 data
重建优化:
- 状态消费者 widget(Builder、Consumer、Observer、Obx、Watch 等)范围尽可能窄
- 使用选择器仅在特定字段更改时重建 — 而不是在每次状态发出时
- 使用
constwidget 阻止重建在 widget 树中传播 - 计算/派生状态是响应式计算的,而不是冗余存储的
订阅与释放:
- 所有手动订阅 (
.listen()) 在dispose()/close()中被取消 - 不再需要时关闭 Stream 控制器
- 定时器在释放生命周期中被取消
- 框架管理的生命周期优先于手动订阅(声明式 builder 优于
.listen()) - 异步回调中在
setState之前进行mounted检查 - 在
await之后,未检查context.mounted前不要使用BuildContext(Flutter 3.7+)— 过期的 context 会导致崩溃 - 在异步间隙后,未验证 widget 是否仍然挂载,不要执行导航、弹出对话框或显示 Scaffold 消息
-
BuildContext绝不应存储在单例、状态管理器或静态字段中
局部状态与全局状态:
- 瞬态 UI 状态(复选框、滑块、动画)使用局部状态(
setState、ValueNotifier) - 共享状态仅提升到所需的高度 — 不要过度全局化
- 功能范围的状态在功能不再活跃时被正确释放
5. 性能
不必要的重建:
-
setState()不在根 widget 级别调用 — 局部化状态更改 - 使用
constwidget 阻止重建传播 - 在独立重绘的复杂子树周围使用
RepaintBoundary - 对于独立于动画的子树,使用
AnimatedBuilder的child参数
Build 中的昂贵操作:
-
build()中没有排序、过滤或映射大集合 — 在状态管理层计算 -
build()中没有正则表达式编译 -
MediaQuery.of(context)的使用是具体的(例如MediaQuery.sizeOf(context))
图片优化:
- 网络图片使用缓存(适用于项目的任何缓存解决方案)
- 为目标设备使用合适分辨率的图片(不要为缩略图加载 4K 图片)
- 使用
Image.asset并指定cacheWidth/cacheHeight以显示尺寸解码 - 为网络图片提供占位图和错误 widget
懒加载:
- 对于大型或动态列表,使用
ListView.builder/GridView.builder而不是ListView(children: [...])(对于小型、静态列表,具体的构造函数是可以的) - 对于大型数据集实现分页
- 在 Web 构建中使用延迟加载(
deferred as)来处理重量级库
其他:
- 避免在动画中使用
Opacitywidget — 使用AnimatedOpacity或FadeTransition - 避免在动画中进行裁剪 — 预裁剪图片
- 不要在 widget 上重写
operator ==— 改用const构造函数 - 谨慎使用固有尺寸 widget(
IntrinsicHeight、IntrinsicWidth)(会引入额外的布局传递)
6. 测试
测试类型与期望:
- 单元测试:覆盖所有业务逻辑(状态管理器、仓储、工具函数)
- Widget 测试:覆盖单个 widget 的行为、交互和视觉输出
- 集成测试:端到端覆盖关键用户流程
- 黄金测试:对设计关键的 UI 组件进行像素级完美比较
覆盖率目标:
- 业务逻辑的行覆盖率目标达到 80%+
- 所有状态转换都有对应的测试(加载→成功、加载→错误、重试等)
- 测试了边缘情况:空状态、错误状态、加载状态、边界值
测试隔离:
- 外部依赖(API 客户端、数据库、服务)被模拟或伪造
- 每个测试文件只测试一个类/单元
- 测试验证行为,而不是实现细节
- 存根仅定义每个测试所需的行为(最小存根)
- 测试用例之间没有共享的可变状态
Widget 测试质量:
- 正确使用
pumpWidget和pump处理异步操作 - 适当使用
find.byType、find.text、find.byKey - 没有依赖时序的脆弱测试 — 使用
pumpAndSettle或显式的pump(Duration) - 测试在 CI 中运行,且失败会阻止合并
7. 可访问性
语义 widget:
- 在自动标签不足时,使用
Semanticswidget 提供屏幕阅读器标签 - 对纯装饰性元素使用
ExcludeSemantics - 使用
MergeSemantics将相关 widget 合并为单个可访问元素 - 图片设置了
semanticLabel属性
屏幕阅读器支持:
- 所有交互式元素都可聚焦,并具有有意义的描述
- 焦点顺序是符合逻辑的(遵循视觉阅读顺序)
视觉可访问性:
- 文本与背景的对比度 >= 4.5:1
- 可点击目标至少为 48x48 像素
- 颜色不是状态的唯一指示符(同时使用图标/文字)
- 文本随系统字体大小设置缩放
交互可访问性:
- 没有无效的
onPressed回调 — 每个按钮要么执行操作,要么被禁用 - 错误字段建议修正
- 用户输入数据时,上下文不会意外改变
8. 平台特定问题
iOS/Android 差异:
- 在合适的地方使用平台自适应 widget
- 正确处理返回导航(Android 返回按钮、iOS 滑动返回)
- 通过
SafeAreawidget 处理状态栏和安全区域 - 在
AndroidManifest.xml和Info.plist中声明了平台特定权限
响应式设计:
- 使用
LayoutBuilder或MediaQuery实现响应式布局 - 断点定义一致(手机、平板、桌面)
- 文本不会在小屏幕上溢出 — 使用
Flexible、Expanded、FittedBox - 横向方向已测试或明确锁定
- Web 专用:支持鼠标/键盘交互,存在悬浮状态
9. 安全
安全存储:
- 敏感数据...