实战:一个完整应用的落地过程

前面各章讲的是零件,本章把它们装成一台能跑的车:一个“商品/文章列表 + 详情 + 收藏 + 本地缓存 + 搜索 + 主题切换 + 错误重试”的应用。目标不是写得多,而是每一步都能对应到前面章节的结论——目录怎么分、数据怎么流、缓存放哪、错误在哪兜、最后按什么清单发版。

需求拆解与目录结构

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.builderitemExtent,图片加 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_iconsflutter_native_splash 生成,iOS 不带透明通道
权限只保留真正用到的权限,iOS 文案写清用途
版本号pubspec.yamlversion 与构建号递增
混淆与符号表--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 把它固化成流水线。

笔记加载中…