Skip to content

16 · 工程化与调试 ​

目标:能本地出包,能自己排查常见报错,知道每次提交前该跑什么。


开篇:前端概念对照 ​

前端本项目
npm / pnpmfvm flutter pub get
package.jsonpubspec.yaml
package-lock.jsonpubspec.lock
nvm(Node 版本管理)fvm(Flutter 版本管理)
vite buildscripts/build.sh
TypeScript 代码生成build_runner(.g.dart / .freezed.dart)
ESLintfvm flutter analyze
Jest / Vitestfvm flutter test
Chrome DevToolsFlutter DevTools
vConsoleDevFloatingButton
console.logAppLogger.I.d()(禁止 print())
husky pre-commitscripts/precheck.sh

一、fvm:Flutter 版本管理 ​

为什么必须用 ​

团队要求 Flutter 版本完全一致。fvm 通过 .fvm/fvm_config.json 锁定版本。

⚠️ 所有命令都要加 fvm 前缀:

bash
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

常用命令 ​

bash
fvm --version                # 查看 fvm 版本
fvm flutter --version        # 查看当前项目锁定的 Flutter 版本
fvm list                     # 列出已安装的 Flutter 版本
fvm install 3.x.x            # 安装指定版本
fvm use 3.x.x                # 切换项目使用的版本

首次拉取项目 ​

bash
fvm install          # 按 .fvm/fvm_config.json 安装对应 Flutter
fvm flutter pub get  # 拉依赖

二、build_runner:代码生成 ​

什么时候必须跑 ​

改了以下任一类文件后:

  • @freezed 模型(.dart)
  • @riverpod 的 ViewModel / provider
  • Retrofit 的 @RestApi ApiService
  • assets/images/ 下新增/删除图片
  • Figma token 同步后(L1 生成)

命令 ​

bash
# 标准(推荐)
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.dartfreezed 模型
*.g.dartriverpod / retrofit / json_serializable
lib/generated/assets.gen.dartflutter_gen 图片资源
lib/he_components/src/themes/app_colors.gen.dartFigma 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 主要在这里

运行 ​

bash
# 全部
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.dartWidget test 标准 boilerplate
test/ui/device/view_models/device_list_view_model_test.dartViewModel 单测
test/data/repositories/device/device_repository_test.dartRepository 测试(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):

dart
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。

常用断言 ​

dart
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 展示了如何给抽象类做桩:

dart
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):

dart
if (!kReleaseMode) const DevFloatingButton(),

功能入口在 lib/ui/devtools/(85 个文件),包含:

  • 组件展示(各 He 组件的 demo 页)
  • 请求抓包查看
  • 环境切换
  • Mock 开关
  • 主题/语言切换

网络抓包 ​

lib/main.dart:82:

dart
NetworkInspectorConfig.apply();   // chucker 抓包(debug/profile)

非 release 模式启用,可在 App 内查看请求/响应详情。

Flutter DevTools ​

bash
fvm flutter pub global activate devtools
fvm flutter pub global run devtools

或运行 App 后,在 IDE 里打开 DevTools。

常用面板:

面板用途
Widget Inspector查看 Widget 树、布局边界
Performance帧耗时、掉帧分析
Memory内存泄漏排查
Network网络请求
Logging日志输出

日志规范 ​

⚠️ 禁止 print()。统一用 AppLogger:

dart
import 'package:haierenergy/utils/app_logger.dart';

AppLogger.I.d('调试信息');
AppLogger.I.i('普通信息');
AppLogger.I.w('警告');
AppLogger.I.e('错误: $e');

为什么禁 print:

  1. release 包也会输出,有性能与安全风险
  2. 无法分级、无法关闭
  3. 项目有守护脚本检查

五、脚本全家桶 ​

scripts/ 目录:

脚本用途
precheck.sh构建前预检(最重要)
build.sh出包(唯一入口)
run_prod.sh生产环境运行
check_project.dart统一守护(imports/UI/phone/i18n/iOS)
check_figma_tokens.dartFigma token 漂移检测
check_apifox_contracts.dartApifox 契约校验
check_ui_dto_imports.dartUI 层 DTO 导入审计
fetch_iconfont.sh拉取远程 iconfont
find_figma_component.dart / scan_figma_components.dartFigma 组件扫描

precheck.sh:每次提交前必跑 ​

bash
bash scripts/precheck.sh           # 完整
bash scripts/precheck.sh --quick   # 快速(跳过 analyze/test)

完整模式依次检查:

  1. build_runner 生成文件同步(重新生成并 diff,不一致则失败)
  2. Figma token 漂移(check_figma_tokens.dart)
  3. 项目守护(check_project.dart:imports / UI / phone / i18n / iOS)
  4. flutter analyze(要求 0 error)
  5. flutter test(全部测试通过)

任何一项失败则退出码 1,构建中止。

出包:只走 build.sh ​

⚠️ 禁止直接 fvm flutter build。

bash
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。


六、常见工作流 ​

日常开发循环 ​

bash
# 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

加了新图片 ​

bash
# 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 包

下一步 ​

→ 17 · 常见陷阱与速查表


相关文档 ​