16 · 工程化与调试
目标:能本地出包,能自己排查常见报错,知道每次提交前该跑什么。
开篇:前端概念对照
| 前端 | 本项目 |
|---|---|
npm / pnpm | fvm flutter pub get |
package.json | pubspec.yaml |
package-lock.json | pubspec.lock |
| nvm(Node 版本管理) | fvm(Flutter 版本管理) |
vite build | scripts/build.sh |
| TypeScript 代码生成 | build_runner(.g.dart / .freezed.dart) |
| ESLint | fvm flutter analyze |
| Jest / Vitest | fvm flutter test |
| Chrome DevTools | Flutter DevTools |
| vConsole | DevFloatingButton |
console.log | AppLogger.I.d()(禁止 print()) |
| husky pre-commit | scripts/precheck.sh |
一、fvm:Flutter 版本管理
为什么必须用
团队要求 Flutter 版本完全一致。fvm 通过 .fvm/fvm_config.json 锁定版本。
⚠️ 所有命令都要加 fvm 前缀:
fvm flutter pub get # 不是 flutter pub get
fvm flutter run
fvm flutter analyze lib
fvm flutter test
fvm dart run build_runner build --delete-conflicting-outputs常用命令
fvm --version # 查看 fvm 版本
fvm flutter --version # 查看当前项目锁定的 Flutter 版本
fvm list # 列出已安装的 Flutter 版本
fvm install 3.x.x # 安装指定版本
fvm use 3.x.x # 切换项目使用的版本首次拉取项目
fvm install # 按 .fvm/fvm_config.json 安装对应 Flutter
fvm flutter pub get # 拉依赖二、build_runner:代码生成
什么时候必须跑
改了以下任一类文件后:
@freezed模型(.dart)@riverpod的 ViewModel / provider- Retrofit 的
@RestApiApiService assets/images/下新增/删除图片- Figma token 同步后(L1 生成)
命令
# 标准(推荐)
fvm dart run build_runner build --delete-conflicting-outputs
# 监听模式(开发时改模型频繁)
fvm dart run build_runner watch --delete-conflicting-outputs
# 清理后重建(遇到诡异的生成问题时)
fvm dart run build_runner clean
fvm dart run build_runner build --delete-conflicting-outputs生成物清单(全部要提交到 git)
| 文件 | 来源 |
|---|---|
*.freezed.dart | freezed 模型 |
*.g.dart | riverpod / retrofit / json_serializable |
lib/generated/assets.gen.dart | flutter_gen 图片资源 |
lib/he_components/src/themes/app_colors.gen.dart | Figma token(L1) |
⚠️ 这些文件进 git —— 这是项目明确规范,好处是 CI 和队友无需重新生成。
常见坑
| 现象 | 原因 | 解法 |
|---|---|---|
| 新方法「不存在」 | 忘了跑 build_runner | 跑 build |
| 生成文件冲突 | 上次生成残留 | 加 --delete-conflicting-outputs |
part 声明缺失 | 忘了写 part 'xxx.g.dart'; | 补上 |
| 生成很慢 | 文件多 | 用 watch 模式,或先 clean |
三、测试
现状
test/ 目录镜像 lib/ 结构:
test/
├── app/ config/ core/ data/(20) domain/
├── features/ helpers/ routing/ scripts/
├── he_components/(18) theme/ utils/
└── ui/(81) ← widget test 主要在这里运行
# 全部
fvm flutter test
# 单个文件
fvm flutter test test/ui/core/widgets/paged_list_body_test.dart
# 单个用例(按名字匹配)
fvm flutter test --plain-name "首页空态"
# 覆盖率
fvm flutter test --coverage重点可参考的用例
| 用例 | 学什么 |
|---|---|
test/ui/core/widgets/paged_list_body_test.dart | Widget test 标准 boilerplate |
test/ui/device/view_models/device_list_view_model_test.dart | ViewModel 单测 |
test/data/repositories/device/device_repository_test.dart | Repository 测试(Mock 切换) |
test/core/i18n/runtime_i18n_engine_test.dart | 纯 Dart 类单测(无 Flutter 依赖) |
Widget test 标准模板
⚠️ 本项目测试必须提供完整的 ProviderScope / 主题 / i18n 三件套,否则会报 No ProviderScope found 或 Null check operator used on a null value。
标准 boilerplate(取自 test/ui/core/widgets/paged_list_body_test.dart:42-67):
void main() {
late AppThemeData appThemeData;
setUpAll(RuntimeI18nDelegate.preloadForTests); // ① 预加载 i18n
setUp(() {
appThemeData = AppThemeData.light;
});
Widget buildTestWidget(Widget child) {
return ProviderScope( // ② Riverpod
child: MaterialApp(
theme: AppTheme.light(appThemeData).copyWith(
extensions: [ // ③ 主题三扩展
TDTheme.defaultData(),
appThemeData,
AppTokens(appThemeData: appThemeData)
],
),
localizationsDelegates: TestI18nHelper.localizationsDelegates, // ④ i18n
supportedLocales: TestI18nHelper.supportedLocales,
locale: const Locale('zh'),
home: Scaffold(body: child),
),
);
}
testWidgets('描述', (tester) async {
await tester.pumpWidget(buildTestWidget(MyWidget()));
expect(find.text('xxx'), findsOneWidget);
});
}四个要素缺一不可:ProviderScope + AppTheme + AppTokens + TestI18nHelper。
常用断言
expect(find.text('文本'), findsOneWidget);
expect(find.byType(HeButton), findsNWidgets(2));
expect(find.byKey(const Key('k')), findsNothing);
await tester.tap(find.byType(HeButton));
await tester.pump(); // 重建一帧
await tester.pumpAndSettle(); // 等待所有动画结束
await tester.enterText(find.byType(HeInput), 'abc');
await tester.drag(find.byType(ListView), const Offset(0, -300));测试桩:造假的 PagedListBody
paged_list_body_test.dart:19-40 展示了如何给抽象类做桩:
class _FakeBody extends PagedListBody<String> {
const _FakeBody({required this.state, this.listHeader});
final AsyncValue<PagedState<String>> state;
final Widget? listHeader;
@override
AsyncValue<PagedState<String>> watchState(WidgetRef ref) => state;
@override
Future<void> refresh(WidgetRef ref) async {}
@override
Future<void> loadMore(WidgetRef ref) async {}
@override
Widget itemBuilder(BuildContext context, String item, int index) => Text(item);
@override
Widget? buildListHeader(BuildContext context, WidgetRef ref) => listHeader;
}这个技巧很实用:测 Widget 时不用真接接口,直接给个假状态。
四、调试工具
DevFloatingButton(开发悬浮按钮)
非 release 模式自动出现(lib/main.dart:398):
if (!kReleaseMode) const DevFloatingButton(),功能入口在 lib/ui/devtools/(78 个文件),包含:
- 组件展示(各 He 组件的 demo 页)
- 请求抓包查看
- 环境切换
- Mock 开关
- 主题/语言切换
网络抓包
lib/main.dart:82:
NetworkInspectorConfig.apply(); // chucker 抓包(debug/profile)非 release 模式启用,可在 App 内查看请求/响应详情。
Flutter DevTools
fvm flutter pub global activate devtools
fvm flutter pub global run devtools或运行 App 后,在 IDE 里打开 DevTools。
常用面板:
| 面板 | 用途 |
|---|---|
| Widget Inspector | 查看 Widget 树、布局边界 |
| Performance | 帧耗时、掉帧分析 |
| Memory | 内存泄漏排查 |
| Network | 网络请求 |
| Logging | 日志输出 |
日志规范
⚠️ 禁止 print()。统一用 AppLogger:
import 'package:haierenergy/utils/app_logger.dart';
AppLogger.I.d('调试信息');
AppLogger.I.i('普通信息');
AppLogger.I.w('警告');
AppLogger.I.e('错误: $e');为什么禁 print:
- release 包也会输出,有性能与安全风险
- 无法分级、无法关闭
- 项目有守护脚本检查
五、脚本全家桶
scripts/ 目录:
| 脚本 | 用途 |
|---|---|
precheck.sh | 构建前预检(最重要) |
build.sh | 出包(唯一入口) |
run_prod.sh | 生产环境运行 |
check_project.dart | 统一守护(imports/UI/phone/i18n/iOS) |
check_figma_tokens.dart | Figma token 漂移检测 |
check_apifox_contracts.dart | Apifox 契约校验 |
check_ui_dto_imports.dart | UI 层 DTO 导入审计 |
fetch_iconfont.sh | 拉取远程 iconfont |
find_figma_component.dart / scan_figma_components.dart | Figma 组件扫描 |
precheck.sh:每次提交前必跑
bash scripts/precheck.sh # 完整
bash scripts/precheck.sh --quick # 快速(跳过 analyze/test)完整模式依次检查:
- build_runner 生成文件同步(重新生成并 diff,不一致则失败)
- Figma token 漂移(
check_figma_tokens.dart) - 项目守护(
check_project.dart:imports / UI / phone / i18n / iOS) flutter analyze(要求 0 error)flutter test(全部测试通过)
任何一项失败则退出码 1,构建中止。
出包:只走 build.sh
⚠️ 禁止直接 fvm flutter build。
scripts/build.sh android --release
scripts/build.sh ios --release
scripts/build.sh android --release --env prod三个入口与 --env
| 命令 | 效果 |
|---|---|
fvm flutter run | 默认入口,环境走运行时解析 |
fvm flutter run -t lib/main_test.dart | 强制测试环境后端 |
fvm flutter run -t lib/main_prod.dart | 强制生产环境后端 |
scripts/build.sh android --release --env prod | 出生产包 |
⚠️ iOS debug 包的坑
iOS --debug 实际输出 profile 包。真机禁止 JIT,debug 包无法脱离调试器安装运行。
判断开发工具链用 !kReleaseMode;release 专属逻辑用 kReleaseMode。
六、常见工作流
日常开发循环
# 1. 拉代码后
fvm flutter pub get
# 2. 改了模型/provider/ApiService后
fvm dart run build_runner build --delete-conflicting-outputs
# 3. 开发中
fvm flutter run -t lib/main_test.dart # 连测试环境
# 4. 改完自查
fvm flutter analyze lib
fvm flutter test
# 5. 提交前
bash scripts/precheck.sh加了新图片
# 1. 放到 assets/images/<feature>/
# 2. 确认 pubspec.yaml 注册了该目录
# 3. 重新生成
fvm dart run build_runner build --delete-conflicting-outputs
# 4. 引用
Assets.images.<feature>.<name>.image()加了新接口
见 09-数据层链路 的 8 步清单,其中第 4 步是跑 build_runner。
七、自检清单
- [ ] 所有命令都加了
fvm前缀 - [ ] 知道改了
@freezed/@riverpod/@RestApi后要跑 build_runner - [ ] 知道生成文件(
.g.dart/.freezed.dart)要提交到 git - [ ] 能写出 Widget test 的标准 boilerplate(四要素)
- [ ] 知道
precheck.sh检查哪 5 项 - [ ] 出包只走
scripts/build.sh - [ ] 用
AppLogger,不用print() - [ ] 知道 iOS debug 实际是 profile 包