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 foundNull 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/(78 个文件),包含:

  • 组件展示(各 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 · 常见陷阱与速查表