在 CityStage 的活动列表中,用户先点击“展览”,随即又改成“全部”。如果“展览”的请求慢半拍,第二次的“全部”请求先返回,页面短暂出现了完整列表,紧接着却被第一份旧响应覆盖。这不是接口数据错误,而是请求完成顺序不等于用户操作顺序。前面三篇已经完成筛选状态归属、详情导航和收藏落盘,现在我们要把写死的活动数组改为可刷新的目录源,并让网络不佳时的界面仍然有明确的状态。

CityStage 活动目录刷新与缓存技术封面

一、把活动目录和收藏偏好分开

这次仍沿用四个活动 ID:11“光影摄影展”、12“爵士现场”、13“手作器物市集”、14“城市建筑摄影展”。前一篇的 FavoriteRepository 保存 favoriteIds,这是用户在本机主动做出的选择。本篇新增的活动目录是另一类数据:活动名称、类别、地点等来自运营内容,有可能更新。两者绝不能使用同一个 Preferences 键;目录更新不应该重置红心,收藏恢复也不应该阻止列表读取缓存。

为了区分三个概念,界面中增加 catalog(当前可显示的活动列表)、loadState(loading、fresh、cached、error)和 queryRevision(请求代数)。catalog 是当前页面展示的快照,favoriteIds 仍来自根组件的 @Provide('favorites');它们的结合只发生在 visibleActivities() 的过滤函数内。联网成功意味着拿到通过结构校验的新目录;缓存可用意味着可以展示上次已确认的数据,不能冒充最新服务端内容;两者都没有时才是真正的不可用状态。

考虑一条常见的用户路径:首次打开没有网络,若本机从未缓存过活动,页面显示“暂时无法加载,请重试”;如果昨天曾成功打开,今天离线进入,则显示昨天的活动并注明“离线内容”,但可继续使用本地搜索和收藏;恢复网络后用户主动点刷新,数据更新而收藏状态保持不变。这三种结果不要全用一个“空列表”来表达,否则错误排查根本无从开始。

技术边界也要讲清楚:本篇缓存的是浏览目录,不是票价承诺、可用库存或订单状态。即使活动名称和简介在离线时可以看,实际下单、价格校验仍必须向服务端获取实时结果,不能拿旧目录当支付依据。缓存按整个目录存储,分类和搜索仍由本地 visibleActivities() 完成,这样快速切换筛选条件不会每次都启动网络请求。

二、定义一份可验证的数据契约

从数组直接切换到 HTTP JSON,最容易遗漏的不是 await,而是“服务端到底返回了什么”。示例约定 GET /v1/events 返回 version 和 items。这是给真实后端对接的接口契约示例,不是一个已经上线的公共接口。https://api.example.com 只是需要替换的地址;没有真实服务时,可继续使用前几篇的四条本地数据进行页面联调,但不能将其写成联网成功。

// entry/src/main/ets/model/EventItem.ets
export interface EventItem {
  id: number;
  title: string;
  category: string;
  location: string;
}

export interface EventEnvelope {
  version: number;
  items: EventItem[];
}

export function validateCatalog(value: EventEnvelope): EventItem[] {
  if (value.version !== 1 || !Array.isArray(value.items)) {
    throw new Error('活动目录版本或结构错误');
  }
  const ids: Set<number> = new Set<number>();
  for (const item of value.items) {
    if (!Number.isInteger(item.id) || item.id <= 0 || ids.has(item.id)
      || typeof item.title !== 'string' || item.title.trim() === ''
      || typeof item.category !== 'string' || typeof item.location !== 'string') {
      throw new Error('活动目录包含无效或重复条目');
    }
    ids.add(item.id);
  }
  return value.items;
}

为什么连数字 ID 和重复项也要检查?如果接口意外发送了两个 ID 11,ArkUI 列表以 id 作稳定键时就可能出现复用异常;如果把字符串形式的“11”直接放进集合,前一篇数字类型的 favoriteIds.includes(item.id) 也会失效。校验是在接口边界处理数据,不应该把每个条目上的类型判断散落到多张卡片的构建函数里。

版本号只表达我们自定义的业务协议格式,不等同 HarmonyOS API 版本。未来服务端新增非必填说明字段,可以在保持结构兼容的前提下忽略;如果改变了字段语义,应升级协议并提供迁移或兼容解析。类型断言 as EventEnvelope 本身不会在运行时验证网络数据,真正守门的是后面的函数。对公开服务还应增加条目数量、文本长度等限制,避免超大响应拖垮列表布局。

DevEco Studio 中的 CityStage 目录仓储与页面预览

三、HTTP 加载完成后再决定是否替换缓存

Stage 工程使用 Network Kit 的 http.createHttp()。模块清单需要声明 ohos.permission.INTERNET;如果真实地址采用 HTTPS,还需确保服务端证书和接口域名可用。示例采用一次请求一个 HttpRequest,结束时在 finally 内 destroy()。本地 Preferences 存取逻辑沿用 A-03 的 getPreferences、put 与 flush,但换成单独的文件与键。

// entry/src/main/module.json5 的 requestPermissions 片段
{
  "requestPermissions": [
    { "name": "ohos.permission.INTERNET" }
  ]
}
// entry/src/main/ets/common/EventCatalogRepository.ets
import { http } from '@kit.NetworkKit';
import { preferences } from '@kit.ArkData';
import { common } from '@kit.AbilityKit';
import { EventEnvelope, EventItem, validateCatalog } from '../model/EventItem';

export class EventCatalogRepository {
  private context: common.UIAbilityContext;
  constructor(context: common.UIAbilityContext) { this.context = context; }

  async readCache(): Promise<EventItem[]> {
    const prefs = await preferences.getPreferences(this.context,
      { name: 'citystage_catalog' });
    const raw = String(await prefs.get('items_v1', ''));
    if (!raw) { return []; }
    return validateCatalog(JSON.parse(raw) as EventEnvelope);
  }

  async fetchFresh(url: string): Promise<EventItem[]> {
    const request = http.createHttp();
    try {
      const result = await request.request(url, {
        method: http.RequestMethod.GET,
        connectTimeout: 5000,
        readTimeout: 5000
      });
      if (result.responseCode !== 200) {
        throw new Error(`HTTP ${result.responseCode}`);
      }
      const payload = JSON.parse(String(result.result)) as EventEnvelope;
      return validateCatalog(payload);
    } finally {
      request.destroy();
    }
  }

  async saveCache(items: EventItem[]): Promise<void> {
    const prefs = await preferences.getPreferences(this.context,
      { name: 'citystage_catalog' });
    await prefs.put('items_v1', JSON.stringify({ version: 1, items: items }));
    await prefs.flush();
  }
}

这里把网络校验与缓存提交拆成了两个方法。这样页面可以先判定结果是否仍为最新请求,再决定是否写缓存,避免旧响应在页面 guard 之前抢先落盘;而遇到 HTTP 非 200、JSON 解析失败、字段非法或存储异常时,函数都会抛错,调用方决定是否读缓存。更讲究的项目可以把缓存写入失败与请求成功分别反馈,例如展示已下载的新数据但标注未持久保存;当前实现选择保守的一致性策略。需要注意的是,fetchFresh() 并没有实现取消请求,也没有提供真实后端,不能凭这段代码宣称已完成线上联调。

首屏不一定非要等网络。可以先 readCache(),有缓存就显示,并启动一次刷新;没有缓存则先显示加载骨架。对于真实服务,缓存还应保存 fetchedAt 或 ETag,用来标明是否过期和协商更新。不过“缓存时间”不能代替“当前接口可用性”,也不能把缓存中的历史库存和最新库存混淆;本篇不参与订单提交。

四、请求序号防止旧响应覆盖新操作

解决竞态最轻量的方法,是在页面层给每次刷新递增一个代数。请求启动时记下当前值,任何 await 结束后都核对“我还是最新的吗”。如果不是,就直接丢弃这个结果。不能只在成功分支检查:旧请求的失败结果也可能覆盖新页面的错误提示。序号保护的是界面提交,并不会自动终止底层网络请求,因此仍需要资源销毁与超时管理。

// entry/src/main/ets/pages/Index.ets,合入前文 Navigation 根页面
@State catalog: EventItem[] = [];
@State loadState: string = 'loading';
@State loadError: string = '';
private queryRevision: number = 0;
private eventRepo: EventCatalogRepository | undefined;

private async refreshCatalog(): Promise<void> {
  if (!this.eventRepo) { return; }
  const revision = ++this.queryRevision;
  this.loadState = 'loading';
  try {
    const fresh = await this.eventRepo.fetchFresh(
      'https://api.example.com/v1/events');
    if (revision !== this.queryRevision) { return; }
    await this.eventRepo.saveCache(fresh);
    if (revision !== this.queryRevision) { return; }
    this.catalog = fresh;
    this.loadState = 'fresh';
    this.loadError = '';
  } catch (error) {
    if (revision !== this.queryRevision) { return; }
    try {
      const cached = await this.eventRepo.readCache();
      if (revision !== this.queryRevision) { return; }
      if (cached.length > 0) {
        this.catalog = cached;
        this.loadState = 'cached';
        this.loadError = '正在显示离线内容';
      } else {
        this.loadState = 'error';
        this.loadError = '活动加载失败,请重试';
      }
    } catch (cacheError) {
      if (revision !== this.queryRevision) { return; }
      this.loadState = 'error';
      this.loadError = '活动缓存不可读取';
    }
  }
}

根页面在 aboutToAppear 中初始化 EventCatalogRepository 并调用 refreshCatalog();原有 visibleActivities() 把数据源从写死的 EVENTS 改为 this.catalog,同时继续组合 category、keyword、onlyFavorites 和 favoriteIds。详情页仍用稳定 ID 找对应对象;当目录更新后活动下架,应显示“活动暂不可用”,而不是导航到另一条数组下标。收藏仓储的键 favorites_v1 不变,因此刷新目录不会擅自清除已保存的收藏 ID。

CityStage 竖屏:缓存模式与活动列表交互示例

缓存提交还有一个更细的竞争窗口:虽然 fetchFresh() 只返回已验证目录,页面会在 saveCache() 前检查请求代数,但新一轮刷新仍可能在旧写入等待 flush() 时启动。如果允许高频并发写盘,最好让仓储串行提交,并在队列轮到某项时重新比较当前代数,确保最后一次已确认的刷新占据最终缓存。这里保留的是单页面低频手动刷新的基础实现:页面由按钮在 loadState === 'loading' 时禁用刷新,避免两次缓存写盘交错;测试竞态时可绕过按钮直接调用连续请求,验证旧响应不影响最终 UI。进入多窗口或后台自动刷新场景时,再把缓存写入协调上移到共享仓储层。

五、把刷新验证做成可复现的场景

建议准备一个可配置响应延迟的开发接口:第一次刷新返回两条目录并延迟两秒,第二次返回四条目录并延迟两百毫秒。连续点击两次后,屏幕最终必须显示四条,旧请求返回也不能把它改回两条。把新请求设置为失败、旧缓存保留四条,页面显示“正在显示离线内容”;把缓存也清空,则显示“活动加载失败,请重试”。这组数字仅是测试输入,不是设备端测量值。

还应测试服务端返回重复 ID、非法 JSON、版本号升级、空数组与 HTTP 500。空数组是合法响应,应显示“当前没有活动”,不能自动回退旧缓存;网络失败才进入离线分支。用户在正在刷新期间改变本地“演出”“展览”筛选时,筛选应作用于当前目录快照,不用再启动另一条完全相同的请求。若用户退出页面,回调仍可能完成,正式工程应在 aboutToDisappear 增加 queryRevision,使已离开页面的旧结果无效。

目录请求竞态、缓存回退与最终页面提交关系图

还有一个必要的测试:保持活动 14 已收藏,清空网络目录后重新刷新失败。即使列表没有新数据,已持久化的 ID 仍应留在收藏仓储;下次有活动 14 的有效目录时重新出现红心。若在刷新异常时顺手把收藏数组赋成空值,数据层的错误就变成了用户数据损失。反过来,当后台明确删除某条活动时,可提示“已收藏活动暂不可见”,是否清理历史收藏应由产品规则决定,而不能靠过滤出的空列表推断。

六、工程边界与下一阶段

这一轮解决的是目录获取与 UI 可见结果的关系:网络决定新内容是否可用,缓存决定断网时有没有可阅读的快照,请求序号决定哪一次异步结果有资格改变页面。收藏持久化仍然是独立的业务事实,不会因为刷新而重置。真实服务还需要签名鉴权、分页、缓存过期、后端审计和 HTTPS 证书校验,当前示例没有替代这些工作。

下一篇可以在此基础上研究活动详情的图片加载、页面生命周期和长列表渲染开销;届时再谈性能指标才有可重复的观察对象,不必拿未经运行的帧率数字做论据。对读者来说,更重要的经验是:异步 API 能否调用成功只是第一层,返回的数据什么时候可以进入当前 UI,才是面向用户体验的关键判断。

官方参考:HTTP 网络连接 API、Preferences API、Stage UIAbility 生命周期。

Logo

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

更多推荐