外观
第十二章:工程化与架构(让你的 App “能迭代、能维护、能测试”)
12.1 本章目标(验收标准)
完成后你需要能:
- 用“分层”把 UI/业务/数据访问解耦
- 定义稳定的错误模型(AppError/Failure),UI 只展示友好文案
- 为 Riverpod notifier 写单元测试(不跑真 UI 也能测)
- 为关键页面写一个最小 widget test
12.2 核心概念:移动端项目不是写页面,是写系统
当你的功能变多(网络/权限/相机/本地库),最容易崩的是:
- UI 直接操作 dio/sqflite
- 错误处理散落各处
- 无法写测试,回归靠手点
本章给你一个“够用且不重”的架构模板:
features/:按业务域拆domain:纯业务模型/规则(尽量无依赖)data:repository/数据源(db/http)ui:页面与组件
12.3 推荐目录(落地可维护)
示例(你可以逐步迁移,不需要一次到位):
lib/
core/
errors/
app_error.dart
network/
app_dio.dart
features/
notes/
domain/
note.dart
data/
notes_repository.dart
ui/
notes_home_page.dart
note_edit_page.dart
notes_provider.dart
main.dartWeb 对比:
- 类似 React 项目的 feature-based structure(按功能模块,而不是按文件类型)
12.4 错误处理:统一错误模型 + UI 显示友好提示
你第 7 章已经有 AppError,本章建议把它放到 core/errors,并坚持一个原则:
- repository 只抛
AppError(或你的 Failure 类型) - UI 不直接展示 raw exception(避免用户看到一堆英文栈)
示例:
dart
class AppError implements Exception {
final String message;
final Object? cause;
const AppError(this.message, {this.cause});
}在 UI:
- 展示
message cause用于日志
12.5 Riverpod 可测试的关键:依赖注入(Provider 覆盖)
12.5.1 为什么要“可替换的 repository”
真实 repository 会访问 SQLite/网络。 测试时你希望:
- 用内存假数据(Fake)替代真数据库
- 测 notifier 的业务逻辑
12.5.2 示例:写一个 Fake Repository
创建 test/fakes/fake_notes_repository.dart:
dart
// 注意:把下面的包名 `fe_to_flutter_notes` 替换成你 pubspec.yaml 里的 name。
import 'package:fe_to_flutter_notes/features/notes/domain/note.dart';
class FakeNotesRepository {
final List<Note> _data = [];
Future<List<Note>> list() async => List.unmodifiable(_data);
Future<void> upsert(Note note) async {
_data.removeWhere((n) => n.id == note.id);
_data.add(note);
_data.sort((a, b) => b.createdAt.compareTo(a.createdAt));
}
Future<void> deleteById(String id) async {
_data.removeWhere((n) => n.id == id);
}
}注意:你的实际工程 import 路径会不同;这里演示思路。
12.6 单元测试:测试 notifier 的增删改
12.6.1 关键工具:ProviderContainer
在 Riverpod 里测试通常用:
ProviderContainer(overrides: [...])- 直接读 notifier 并调用方法
12.6.2 示例测试(最小可跑)
创建 test/notes_notifier_test.dart:
dart
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
void main() {
test('sample: ProviderContainer works', () {
final container = ProviderContainer();
addTearDown(container.dispose);
// 这里先演示 container 可用。
// 你需要在自己的工程里把 notesProvider / repositoryProvider 覆盖成 fake。
expect(container, isNotNull);
});
}把它升级成真实测试的步骤(不省略):
- 把你的
notesRepositoryProvider设计成可覆盖(Provider) - 测试里写一个
FakeNotesRepository ProviderContainer(overrides: [notesRepositoryProvider.overrideWithValue(fakeRepo)])- 调用
await container.read(notesProvider.notifier).add(...) - 断言
container.read(notesProvider).value的内容
12.7 Widget Test:确保页面能渲染与响应
最小 widget test 目标:
- 首页能渲染“空状态”
- 点
+能打开编辑页(或弹窗)
你需要:
- 把路由与 provider 注入到测试环境
- 用 fake repository 提供稳定数据
如果你不想处理 package 导入,也可以在示例阶段把 fake 类写进测试文件里(等架构稳定后再拆文件)。
12.8 实战小练习(必须做)
练习 A:为 NotesAsyncNotifier 写一个真实的 add/delete 测试
验收:
- add 后列表长度 +1
- delete 后列表长度 -1
练习 B:加一个“全局错误提示组件”
- 写一个函数
showAppErrorSnackBar(context, message) - 网络页/存储页出错时统一用它提示
12.9 常见坑
- provider 里直接 new 具体实现且不可覆盖 → 测试很难写
- UI 里做 I/O(db/http) → 无法复用、无法测试
- 错误直接
print→ 线上无法定位;至少保证 message + cause 有记录位置
