实战:一个完整应用的落地过程
前面各章讲的是零件,本章把它们装成一台能跑的车:一个“商品/文章列表 + 详情 + 收藏 + 本地缓存 + 搜索 + 主题切换 + 错误重试”的应用。目标不是写得多,而是每一步都能对应到前面章节的结论——目录怎么分、数据怎么流、缓存放哪、错误在哪兜、最后按什么清单发版。
需求拆解与目录结构
lib/
├── main.dart # 入口:错误上报初始化、环境校验、runApp
├── app.dart # MaterialApp:主题、路由、本地化
├── core/ # env、errors、logger、dio_client、storage
├── features/ # catalog(列表/搜索)、detail、favorite、settings
│ # 每个功能内部再分 data / domain / ui
└── shared/ # 骨架屏、错误视图、空态
验收标准很简单:新人接一个需求,能一眼说出该改哪个目录;删掉一个功能目录后应用仍能编译。
数据流:Repository → Notifier → UI
| 角色 | 职责 | 不该做的事 |
|---|---|---|
| Repository | 请求、缓存、模型转换、错误归类 | 不碰 BuildContext、不弹提示 |
| Notifier(状态层) | 加载中/成功/失败三态、分页、刷新 | 不拼字符串、不做网络细节 |
| UI | 渲染三态、触发动作 | 不发请求、不写缓存逻辑 |
// features/catalog/data/article_repository.dart
class ArticleRepository {
ArticleRepository(this._api, this._cache);
final ApiClient _api;
final LocalCache _cache;
// 先给缓存、再补网络:界面立刻有内容,刷新失败也不至于白屏
Future<List<Article>> load({bool refresh = false}) async {
final cached = refresh ? const <Article>[] : await _cache.readArticles();
if (cached.isNotEmpty) return cached;
final list = await _api.getArticles(); // 异常已在 ApiClient 内转成 NetworkError
await _cache.saveArticles(list);
return list;
}
}
// ---- 状态层:StateNotifier + AsyncValue 管理三态 ----
final articleListProvider = StateNotifierProvider.autoDispose<
ArticleListNotifier, AsyncValue<List<Article>>>(
(ref) => ArticleListNotifier(ref.read(articleRepoProvider)),
);
// load() 内部用 AsyncValue.guard 包住仓储调用,异常自动变 AsyncError,
// UI 只需 when(loading: / error: / data:),不必写 try/catch。
列表分页与缓存策略
Future<void> loadMore() async {
if (!_hasMore || state.isLoading) return;
final next = await _repo.loadPage(_page + 1);
if (next.isEmpty) _hasMore = false; // 空页表示到底,避免无限请求
state = AsyncData([...state.value ?? [], ...next]); // 追加而不是整体替换
}
缓存分三层:内存(会话内最快)→ 本地 JSON(离线可用)→ 服务端。过期策略按数据特性定:列表 5 分钟、详情 1 小时、收藏永久本地。列表用 ListView.builder 加 itemExtent,图片加 cacheWidth,滚动时避免整页重建。
详情页:加载、错误与重试
final detail = ref.watch(articleDetailProvider(id));
return detail.when(
loading: () => const SkeletonDetail(),
error: (e, _) => ErrorView(
message: e is AppError ? e.message : '加载失败,请重试',
onRetry: () => ref.invalidate(articleDetailProvider(id)), // 重试即重新请求
),
data: (article) => ArticleDetailView(article: article),
);
错误态必须给两个出口:重试与返回,再配合 RefreshIndicator 支持下拉刷新;空列表要区分“真的没有数据”和“搜索无结果”,文案不同,用户感受完全不同。
收藏与本地存储
class FavoriteRepository {
FavoriteRepository(this._prefs);
final SharedPreferences _prefs;
Set<String> ids() => (_prefs.getStringList('favorites') ?? []).toSet();
Future<void> toggle(String id) async {
final set = ids();
set.contains(id) ? set.remove(id) : set.add(id);
await _prefs.setStringList('favorites', set.toList()); // 量大时换 sqflite 或 Isar
}
}
收藏是本地优先的功能:点击立即改内存状态并刷新 UI,再异步落盘;不要等写盘完成才更新界面,那会让按钮有“延迟感”。
主题切换与持久化
沿用第 16 章的方案:ColorScheme.fromSeed 生成亮暗两套主题,themeMode 由设置页控制并把 index 存进 SharedPreferences,启动时读回;入口提供“跟随系统/亮色/暗色”三态,这才是用户真正需要的选择。
测试点
void main() {
test('网络失败时 Repository 仍返回缓存内容', () async {
final repo = ArticleRepository(FakeApi(alwaysFail: true), FakeCache(['a1']));
expect((await repo.load()).first.id, 'a1'); // 有缓存就不该白屏
});
testWidgets('列表为空时展示空态与重试', (tester) async {
await tester.pumpWidget(ProviderScope(
overrides: [articleListProvider.overrideWith((ref) => FakeEmptyNotifier())],
child: const MaterialApp(home: ArticleListPage())));
await tester.pumpAndSettle();
expect(find.byKey(const Key('retry')), findsOneWidget); // 空态必须留重试出口
});
}
优先级:Repository 单测(缓存命中、错误归类、分页边界)> Widget 测试(列表、空态、错误态)> 一条端到端链路(列表→详情→收藏)。跑 flutter test --coverage 看覆盖率,别为凑数字写无意义断言。
打包发布检查清单
| 项目 | 检查内容 |
|---|---|
| 图标与启动图 | flutter_launcher_icons、flutter_native_splash 生成,iOS 不带透明通道 |
| 权限 | 只保留真正用到的权限,iOS 文案写清用途 |
| 版本号 | pubspec.yaml 的 version 与构建号递增 |
| 混淆与符号表 | --obfuscate --split-debug-info 并归档 symbols 目录 |
| 隐私与上报 | 隐私政策 URL 可访问且与上报一致;崩溃上报已验证可收到测试崩溃 |
| 环境 | 确认打包注入的是 prod 的 --dart-define,包体经 --analyze-size 核对 |
从 0 到上架
| 阶段 | 关键动作 | 完成标志 |
|---|---|---|
| 1 范围 | 明确端与核心页面,砍掉非必要功能 | 需求清单 + 页面草图 |
| 2 工程 | flutter create、lint、目录、环境变量、主题 | 三端可跑通空壳 |
| 3 数据层 | 模型、Repository、错误模型、缓存策略 | 仓储单测通过 |
| 4 界面 | 列表、详情、搜索、收藏、设置接状态管理 | Widget 测试通过 |
| 5 平台能力 | 权限、通知、分享、深链 | 真机验证通过 |
| 6 质量 | 单测 + Widget + 集成测试,profile 模式查掉帧 | 覆盖率达标且无 jank |
| 7 发布 | 签名、CI 自动出包、TestFlight/内测、商店材料 | 审核通过并可下载 |
小结:一个能上线的应用 = 按功能切分的目录 + “Repository 管数据与缓存、Notifier 管三态、UI 只管渲染”的数据流 + 明确的分页与缓存过期策略 + 每个错误态都有重试出口 + 本地优先的收藏与可持久化的主题;测试优先覆盖仓储与空/错态,最后照发布清单从图标、权限、版本号、符号表、隐私政策到环境注入逐项核对,再用 CI 把它固化成流水线。