ArkTS + HarmonyOS 活动目录异步刷新:缓存回退与请求竞态【票务系列 04】【共创稿事节】
在 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 本身不会在运行时验证网络数据,真正守门的是后面的函数。对公开服务还应增加条目数量、文本长度等限制,避免超大响应拖垮列表布局。

三、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。

缓存提交还有一个更细的竞争窗口:虽然 fetchFresh() 只返回已验证目录,页面会在 saveCache() 前检查请求代数,但新一轮刷新仍可能在旧写入等待 flush() 时启动。如果允许高频并发写盘,最好让仓储串行提交,并在队列轮到某项时重新比较当前代数,确保最后一次已确认的刷新占据最终缓存。这里保留的是单页面低频手动刷新的基础实现:页面由按钮在 loadState === 'loading' 时禁用刷新,避免两次缓存写盘交错;测试竞态时可绕过按钮直接调用连续请求,验证旧响应不影响最终 UI。进入多窗口或后台自动刷新场景时,再把缓存写入协调上移到共享仓储层。
五、把刷新验证做成可复现的场景
建议准备一个可配置响应延迟的开发接口:第一次刷新返回两条目录并延迟两秒,第二次返回四条目录并延迟两百毫秒。连续点击两次后,屏幕最终必须显示四条,旧请求返回也不能把它改回两条。把新请求设置为失败、旧缓存保留四条,页面显示“正在显示离线内容”;把缓存也清空,则显示“活动加载失败,请重试”。这组数字仅是测试输入,不是设备端测量值。
还应测试服务端返回重复 ID、非法 JSON、版本号升级、空数组与 HTTP 500。空数组是合法响应,应显示“当前没有活动”,不能自动回退旧缓存;网络失败才进入离线分支。用户在正在刷新期间改变本地“演出”“展览”筛选时,筛选应作用于当前目录快照,不用再启动另一条完全相同的请求。若用户退出页面,回调仍可能完成,正式工程应在 aboutToDisappear 增加 queryRevision,使已离开页面的旧结果无效。

还有一个必要的测试:保持活动 14 已收藏,清空网络目录后重新刷新失败。即使列表没有新数据,已持久化的 ID 仍应留在收藏仓储;下次有活动 14 的有效目录时重新出现红心。若在刷新异常时顺手把收藏数组赋成空值,数据层的错误就变成了用户数据损失。反过来,当后台明确删除某条活动时,可提示“已收藏活动暂不可见”,是否清理历史收藏应由产品规则决定,而不能靠过滤出的空列表推断。
六、工程边界与下一阶段
这一轮解决的是目录获取与 UI 可见结果的关系:网络决定新内容是否可用,缓存决定断网时有没有可阅读的快照,请求序号决定哪一次异步结果有资格改变页面。收藏持久化仍然是独立的业务事实,不会因为刷新而重置。真实服务还需要签名鉴权、分页、缓存过期、后端审计和 HTTPS 证书校验,当前示例没有替代这些工作。
下一篇可以在此基础上研究活动详情的图片加载、页面生命周期和长列表渲染开销;届时再谈性能指标才有可重复的观察对象,不必拿未经运行的帧率数字做论据。对读者来说,更重要的经验是:异步 API 能否调用成功只是第一层,返回的数据什么时候可以进入当前 UI,才是面向用户体验的关键判断。
更多推荐

所有评论(0)