一张活动卡片从列表进入详情、完成收藏、退回列表,最后重新打开应用仍然显示刚才的选择,这条看似普通的用户路径穿过了 CityStage 的多个状态边界。前五篇分别解决筛选状态所有权、Navigation 详情跳转、Preferences 收藏落盘、目录异步请求竞态,以及 LazyForEach 的稳定键与图片失败回退。收尾阶段真正要回答的问题是:当这些单独成立的能力同时遇到断网、重建、重复请求和坏数据时,页面是否仍给出可解释的结果?如果一个功能通过了自己的按钮测试,却破坏了另一个功能,就不能算工程交付完成。

CityStage 跨状态回归工程封面

一、把五篇积累的约定收束成一条数据链

CityStage 继续使用四个示例活动:ID 11 是“光影摄影展”,12 是“爵士现场”,13 是“手作器物市集”,14 是“城市建筑摄影展”。稳定 ID 贯穿列表键、详情参数和收藏集合,展示顺序则由分类、关键字与“只看收藏”筛选决定。用户选中 ID 14 后,即使网络目录把它从第四项调整到第一项,收藏仍应绑定活动 14,而不是数组下标 3。尤其在图片异步加载时,业务身份也不能用海报地址代替,因为同一活动可更换海报。

我们明确区分三条数据生命线。目录是运营数据,其加载状态用 loading/fresh/cached/error 描述,离线时允许读取经过校验的最近快照;收藏是用户本机偏好,由文件 citystage_favorites 的 favorites_v1 保存,写盘成功才把新集合对外发布;导航路径是本次界面会话状态,由 Navigation 管理,跳转时传业务 ID 而不是整个可变活动对象。三者在页面上会合,却不应该被塞进同一个 JSON 文件。目录失败不等于用户取消收藏,收藏保存失败也不应触发重新下载目录。

为避免交付时只盯着某一个页面,先规定三项可追踪的不变量。第一,任意可显示的目录快照中,活动 ID 都是唯一正整数,名称与类别经过校验;第二,筛选只改变可见子集,不修改目录与收藏的原始副本;第三,过期网络响应不能覆盖较新的查询,甚至不能覆盖刚刚从本地缓存恢复的页面。对于这些约束,纯函数和明确状态转换比在按钮里层层嵌套异常处理更容易审阅。

既有 EventItem 模型和 EventFeedSource 数据源不用重新命名。本篇在 entry/src/main/ets/common 增加 CatalogGate.ets,专管请求的接收门槛;仓储仍负责网络与 Preferences I/O,界面仍负责 @State catalog 和 loadState。这样拆分的另一个收益是,测试无需真正访问外网,就能通过传入不同顺序的响应证明竞态处理有没有遗漏。

二、请求过期与数据无效,必须区分判断

不少页面直接在 await fetchFresh() 后赋值给 this.catalog。只要用户连续切换“展览”“全部”,慢请求就可能后到,把错误类别显示在当前筛选下。本系列第四篇已引入 queryRevision;现在把相关规则抽成一个可复用的门禁对象。它只判断本次响应能不能进入页面状态,不承担发请求、写缓存或绘图责任。校验函数沿用第四篇已经验证过的 validateCatalog(),任何抛出的格式错误都在仓储边界解释为响应无效。

// entry/src/main/ets/common/CatalogGate.ets
// 目录模型由仓储在接入前验证
export class CatalogGate {
  private current: number = 0;

  begin(): number {
    this.current += 1;
    return this.current;
  }

  accept(token: number): boolean {
    return token === this.current;
  }

  isLatest(token: number): boolean {
    return token === this.current;
  }
}

accept() 只检查请求序号;实际调用必须先通过原有 validateCatalog() 校验内容、重复 ID 和必填字段。将版本门禁和模型校验分开,是为了让错误日志能够准确区分“网络响应过期”和“服务器数据破坏”。例如两次请求分别得到 token 8 和 9,先返回 token 9,就接受新列表;后到的 token 8 仍可正常完成 HTTP 资源释放,但不得修改页面、写入缓存,也不得改变页面的 loadState。若 token 9 的 JSON 无效,应按当前请求失败处理,而非回退去接受 token 8。

下面是根页面合并现有仓储后的控制片段。已有的 category、keyword、onlyFavorites、favoriteIds、EventFeedSource 和 Navigation 栈仍照前文保留,这里不另造第二个 Entry。网络请求的成功路径必须先验证、再落缓存、最后发布页面快照;缓存写入出错允许显示已验证的本次网络快照,但要以单独日志记录保存失败,不能假装离线副本已经更新。

// entry/src/main/ets/pages/Index.ets 的增量方法
private gate: CatalogGate = new CatalogGate();
@State catalog: EventItem[] = [];
@State loadState: string = 'loading';

private async refreshCatalog(): Promise<void> {
  const token: number = this.gate.begin();
  this.loadState = 'loading';
  try {
    const fresh: EventItem[] = await this.repo.fetchFresh(this.apiUrl);
    if (!this.gate.accept(token)) { return; }
    this.catalog = fresh;
    this.loadState = 'fresh';
    try { await this.repo.saveCache(fresh); }
    catch (_) { /* 页面仍可展示本次合格快照 */ }
  } catch (_) {
    if (!this.gate.isLatest(token)) { return; }
    try {
      const cached: EventItem[] = await this.repo.readCache();
      this.catalog = cached;
      this.loadState = cached.length > 0 ? 'cached' : 'error';
    } catch (_) {
      this.loadState = 'error';
    }
  }
}

生产代码还应当处理页面销毁:根组件离开后可让门禁版本递增,使等待中的结果自然失效。缓存读取同样是一段异步操作;如果用户在读取期间又触发新刷新,缓存返回前须再次检查 isLatest(token)。这两个边界是在合并前五篇时最值得补进代码评审的地方,不能只测试服务端返回的顺序。真实工程还需要请求超时、HTTP 状态检查和资源 destroy(),这些保持沿用第四篇 EventCatalogRepository 的实现。

CityStage 在 DevEco Studio 中检查目录门禁与页面预览

三、页面需要说清“数据从哪里来”

网络失联时,用户仍可能通过本机缓存查看展览信息,但页面应清楚标注“离线目录”,并避免用缓存里的过期库存或票价承诺可售状态。CityStage 在这个系列中属于活动发现与收藏示例,未实现真正购票支付;因此在详情里可以提供收藏、浏览、重试,而不能凭缓存状态直接显示“支付成功”。把示例边界写进产品规则,比画一套没有订单接口的购买流程更加可靠。

建议将错误呈现分成四种:loading 展示骨架和禁用操作;fresh 展示实时通过校验的活动;cached 允许浏览及收藏但露出“离线目录”提示和主动刷新入口;error 显示明确的空态及“重新加载”。所谓“没有活动”与“加载失败”必须分开:服务端返回合法空数组是业务空结果,网络超时且无缓存才是加载失败。屏幕阅读和大字体情况下,提示文字不能只依赖颜色差异。

这次的应用画面选择离线缓存情形:顶部显示“CityStage · 活动票务”,一条浅橙色状态栏写“离线目录 · 显示上次保存的活动”,下方四张活动卡依次为“光影摄影展”“爵士现场”“手作器物市集”“城市建筑摄影展”,活动 14 的红心保持选中。这个状态有明确工程含义:目录与收藏可以来自不同的存储文件,页面必须经过两者的恢复门槛再允许交互。图中不渲染未经核实的票价、真实演出日期或订单号。

CityStage 离线目录、收藏与列表恢复的竖屏界面

收藏按钮尤其需要与页面读盘状态配合。第一次打开应用,目录可能先显示缓存,但 favorites_v1 尚在读取;这时不能先显示所有空心图标并允许点击,再突然替换为之前的红心。根组件的 ready 在收藏读取完成前应保持 false;如果读取失败,应提示“收藏暂不可用”,不能默认把 [] 写回覆盖旧数据。写盘期间的 saving 标志和前文的“先保存再发布”规则仍然成立。为了避免一组重复点击同时进入仓储,多窗口或多个页面的写入操作还要考虑仓储级串行化,而不是完全依赖某张按钮上的 disabled。

四、从单页测试改成跨状态回归矩阵

把交付标准写成用户动作、输入条件和预期结果,能迫使实现者面对不方便的失败路径。第一组验证目录:清除应用数据,构造返回四个合法活动,确认 ID 排列、标题与分类;再返回重复 ID 11、缺少标题或非法 JSON,预期不更新已展示的合格快照。第二组验证并发:依次发出查询 A、B,让 B 先到、A 后到,页面应保留 B 的结果。第三组验证持久化:收藏 ID 14,进入详情再返回,关闭后重新启动,预期仍为 14;构造 flush() 失败则应保留旧收藏和可重试提示。

第四组专门测试离线体验:先在线成功获取四项活动,再断网重启,屏幕展示“离线目录”与四项活动;断网但无缓存时显示错误重试,而不是空白列表。第五组测试 LazyForEach:对目录排序、筛选、插入活动后,卡片图片与标题仍按 id.toString() 对应;将一张海报地址改成无效资源,只应出现“暂无海报”占位,不会交换收藏状态。第六组是视图尺寸:系统大字体与窄屏下提示能够换行,标题不遮住收藏按钮,离线状态不被底部导航吞掉。

特别要把失败动作穿插在成功动作之间:先收藏、再断网、再进入详情、再触发刷新、最后返回列表。若只把网络、收藏和图片三个模块各点一次,就不能证明组合下的用户体验。日志适合记录不包含用户隐私的阶段标签,如 catalog_accepted、catalog_stale、favorite_save_failed 和 poster_fallback;这些是建议的观察点,不代表本篇已经取得真机 HiLog。必要时用 DevEco 断点与注入式仓储替身控制 Promise 返回顺序,而不是试图依赖真实网络随机延迟碰运气。

CityStage 组合场景验收与数据降级决策矩阵

五、工程收官最重要的是把边界写清楚

从团队交付角度,目录模型、存储仓储、查询门禁、视图数据源、收藏状态所有权、Navigation 参数应拥有各自清晰的入口与责任。缓存应记录来自哪次合法响应、何时更新;收藏应记录写盘是否成功;导航应使用稳定业务 ID;渲染层应知道图片失败只是短期的 UI 状态。这样的分层不是为了把文件拆得越多越好,而是保证有人替换服务端 API、另一个人改动卡片视觉时,不会悄悄改变用户的收藏事实。

回归结果不应通过“界面看着正常”直接盖章。可复现的单元测试需要覆盖请求序号和数据校验,组件测试覆盖筛选及稳定键,设备测试覆盖 Preferences 重启恢复、网络权限、前后台切换与图片失败。真实上线前还需补齐接口签名、缓存有效期、数据合规、无障碍细节,以及目标 SDK 和设备上的编译运行检查。本系列示例不提供可交易的票务库存与支付闭环,也不声称获得了任何帧率或实际网络性能结果;这些能力需要对应服务端契约与测试证据。

六篇文章最终沉淀的不是一个花哨的票务页面,而是一组能经得住状态变化的约定:业务 ID 稳定、用户偏好落盘、网络结果按代接收、列表节点按身份复用、失败路径有界面解释。把这些约定守住,CityStage 才具备从教学示例继续演进为实际活动发现产品的基础。若以后加入账号同步和订单业务,仍需另行设计服务端权威状态与事务边界,不能直接把 Preferences 当成云端交易事实。

官方资料:Navigation 组件、LazyForEach 循环渲染、Preferences 用户首选项、HTTP 网络请求。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐