ArkTS + HarmonyOS 请求可靠性:离线缓存与会话状态一致性(连载05/06)【共创稿事节】
用户打开任务中心,地铁里的网络正好断开。页面要么一直转圈,要么弹一个“请求失败”;可就在一分钟前,列表还成功加载过。把上次结果留在本地似乎很简单:保存一份 JSON,连不上网就拿出来。真正麻烦的却是另一个场景:账号甲浏览过的任务列表已经缓存,随后用户退出并登录乙;当网络再次不可用时,如果缓存只按 URL 匹配,乙就可能看到甲留下的记录。

这是请求可靠性工程的第五篇。前文已经有 LoadStatus 的传输/协议/业务分层、LatestQuery 最新查询票据、SessionStore 的 AuthSnapshot(accountId/accessToken/revision),以及只针对符合契约的 GET 进行有限重试的 readWithRetry()。这一次不再增加另一套网络客户端,而是在这些接口外增加有作用域、有时效、有来源标记的缓存读取策略。离线体验的目标是让用户知道自己看到的是什么时候的数据,而不是假装旧数据刚从服务器返回。
缓存命中不能只看 URL
一个任务列表的语义至少包含用户身份、租户或业务空间、筛选参数、页面大小与排序方式。/api/tasks?stage=all 对不同账号可能返回完全不同的记录;同一账号更换角色或权限,也可能看到另一个数据集合。服务端的 ETag、业务修订号和本地生成的查询票据又各有职责:ETag 用于服务端实体版本协商,业务修订号用于任务写入冲突,查询票据用于页面判断旧响应是否还有效。它们不能互相替代。

本篇的缓存键逻辑上包含 accountId、scope 和 queryKey,缓存内容再记录 schemaVersion 与 writtenAt。这里的 scope 代表应用自己约定的租户/角色/工作空间组合;示例默认单一工作空间时可以传入 default,接入多租户业务必须替换成服务端认可的作用域。由于示例仍需保留最小代码,磁盘文件采用一份当前作用域的快照;切换用户时既要检查保存内容的归属,也应执行显式清理,不能依靠“恰好写了不同的键”绕过退出清除策略。
还有一个必须先做的判断:是否允许把任务列表持久化。本例只适用于经过数据分类批准、不包含隐私或敏感字段的展示任务摘要。Preferences 是轻量键值持久化能力,不应被误认为专用的敏感数据加密容器。如果任务标题包含个人信息、内部审批内容或者涉密材料,应转向符合业务加密、密钥管理、访问控制和清除要求的专用方案;网络断开时无法提供安全离线能力,也优于把私有内容直接写到普通偏好文件里。
给离线快照完整的身份与时间约束
接口使用上一章的 RemoteTask { id, title, stage };AuthSnapshot 沿用原先的 SessionStore。下面展示的是独立于网络的缓存仓库,文件放在 entry/src/main/ets/cache/TaskSnapshotCache.ets。preferences.getPreferences、put、get、flush 与 delete 都是 @kit.ArkData 已公开的能力。通过页面的 UIAbilityContext 获取上下文,不要求额外的网络权限;网络模块的 ohos.permission.INTERNET 声明仍按首篇保留。
import { preferences } from '@kit.ArkData'
import { RemoteTask } from '../network/TaskApi'
import { AuthSnapshot } from '../network/SessionStore'
export interface CachedSnapshot {
schemaVersion: number
owner: string
scope: string
queryKey: string
writtenAt: number
tasks: RemoteTask[]
}
export class TaskSnapshotCache {
private readonly store: preferences.Preferences
private readonly key: string = 'visible_tasks_v1'
private readonly maxAgeMs: number = 15 * 60 * 1000
constructor(store: preferences.Preferences) {
this.store = store
}
static async open(context: Context): Promise<TaskSnapshotCache> {
const store: preferences.Preferences =
await preferences.getPreferences(context, 'task_snapshot')
return new TaskSnapshotCache(store)
}
async write(auth: AuthSnapshot, scope: string,
queryKey: string, tasks: RemoteTask[]): Promise<void> {
const payload: CachedSnapshot = {
schemaVersion: 1,
owner: auth.accountId,
scope: scope,
queryKey: queryKey,
writtenAt: Date.now(),
tasks: tasks
}
await this.store.put(this.key, JSON.stringify(payload))
await this.store.flush()
}
async read(auth: AuthSnapshot, scope: string,
queryKey: string): Promise<CachedSnapshot | undefined> {
const raw = await this.store.get(this.key, '')
if (typeof raw !== 'string' || raw.length === 0) { return undefined }
let item: CachedSnapshot
try {
item = JSON.parse(raw) as CachedSnapshot
} catch (error) { return undefined }
if (!item || item.schemaVersion !== 1 ||
item.owner !== auth.accountId || item.scope !== scope ||
item.queryKey !== queryKey || !Array.isArray(item.tasks) ||
typeof item.writtenAt !== 'number') { return undefined }
const age: number = Date.now() - item.writtenAt
if (age < 0 || age > this.maxAgeMs) { return undefined }
if (!item.tasks.every((t: RemoteTask) =>
typeof t.id === 'string' && typeof t.title === 'string' &&
typeof t.stage === 'number')) { return undefined }
return item
}
async clear(): Promise<void> {
await this.store.delete(this.key)
await this.store.flush()
}
}
maxAgeMs=15分钟 是展示策略的配置示例,不是 HarmonyOS 提供的缓存寿命,也不是服务器数据保证的有效期。生产中要根据业务风险确定:天气或公开资讯可以容忍短时间陈旧,审批、交易和库存页面则可能必须拒绝离线展示。这里没有直接把 AuthSnapshot.revision 写进持久化缓存键,因为它代表当前进程的登录代际,重启后的同一账号可能获得新代际;真正的隔离依靠已认证的账号与业务 scope,而应用当前会话是否仍有效仍通过运行期 SessionStore.isCurrent() 验证。
缓存写入还要考虑异常顺序。如果网络请求已经返回新数据,但随后 Preferences.flush() 失败,页面可以显示从服务器得到的新数据,却不能声称“已保存离线副本”;应把存储错误单独记录为本地缓存状态。反过来,旧请求晚到即使通过了 HTTP 200,也不能在新会话中写缓存;否则虽然 UI 被 LatestQuery 阻断,磁盘文件还是被旧账号污染。
三种来源,三种不同的页面提示
把显示态分为 live(服务器新结果)、offline(合法的本地快照)和 empty(没有可信内容)。每次进入刷新前先生成最新查询票据与认证快照;网络成功后校验协议,再确定页面和缓存写入资格;网络因为暂时性错误耗尽重试预算时,才尝试读取匹配当前身份与查询条件的缓存。认证失败不能退回老缓存假装用户仍有访问权,业务拒绝也不能直接被“离线兜底”吞掉。因此需要为第四篇的 ReadResult 增加一个由请求结果分类产生的 fallbackAllowed 字段:传输异常或服务端暂时性失败在达到重试上限后可以根据产品策略尝试缓存,永久性 HTTP 错误、协议解析错误、认证错误则为 false。这应在 getOnce() 的明确错误类型分支中设置,不能通过匹配错误信息字符串推断。

下面是结合前文 readWithRetry() 的页面编排核心。首先给网络结果增加结构化的缓存回退资格,并明确应用层 JSON 校验;fallbackAllowed 是本系列的业务结果字段,不是 HarmonyOS SDK 属性:
// 从 B04 的 ReadResult 演进:
export interface ReadResult {
kind: 'ok' | 'auth' | 'failed' | 'stale'
payload: string
message: string
fallbackAllowed: boolean
}
// readWithRetry 返回失败时:
// permanent/协议失败 -> fallbackAllowed: false
// temporary 耗尽重试 -> fallbackAllowed: true
// ok/auth/stale -> fallbackAllowed: false
interface TaskEnvelope {
code: number
message: string
data: RemoteTask[]
}
function parseTaskEnvelope(payload: string): RemoteTask[] {
const item: TaskEnvelope = JSON.parse(payload) as TaskEnvelope
if (!item || typeof item.code !== 'number' || item.code !== 0 ||
typeof item.message !== 'string' || !Array.isArray(item.data) ||
!item.data.every((t: RemoteTask) =>
typeof t.id === 'string' && typeof t.title === 'string' &&
typeof t.stage === 'number')) {
throw new Error('任务响应结构不符合接口契约')
}
return item.data
}
此处 parseTaskEnvelope() 是延续前文自定义的协议校验函数,依旧与系统 API 无关。cache 由组件初始化阶段取得的 TaskSnapshotCache.open(context) 创建,notice 与 tasks 是前文已建立的页面 @State 字段。编排里每一个异步等待之后都重新检查两道门,避免查询更新期间发生账号切换。
private stillOwns(ticket: number, auth: AuthSnapshot): boolean {
return this.latest.isCurrent(ticket) && sessionStore.isCurrent(auth)
}
private async refreshWithOffline(scope: string,
queryKey: string): Promise<void> {
const ticket: number = this.latest.begin()
const auth: AuthSnapshot | undefined = sessionStore.snapshot()
if (!auth) {
this.tasks = []
this.notice = '请先登录'
return
}
const result: ReadResult = await readWithRetry(
this.endpoint, auth, () => this.latest.isCurrent(ticket))
if (!this.stillOwns(ticket, auth)) { return }
if (result.kind === 'auth') {
sessionStore.clearIfCurrent(auth)
await this.cache.clear()
this.tasks = []
this.notice = '会话已失效,请重新登录'
return
}
if (result.kind === 'ok') {
let next: RemoteTask[]
try { next = parseTaskEnvelope(result.payload) }
catch (error) { this.notice = '响应内容不符合契约'; return }
if (!this.stillOwns(ticket, auth)) { return }
this.tasks = next
this.notice = '已同步最新任务'
try {
await this.cache.write(auth, scope, queryKey, next)
} catch (error) {
if (this.stillOwns(ticket, auth)) {
this.notice = '任务已更新,离线副本保存失败'
}
}
return
}
if (result.kind === 'failed' && !result.fallbackAllowed) {
this.notice = result.message
return
}
if (result.kind === 'failed' && result.fallbackAllowed) {
const snapshot = await this.cache.read(auth, scope, queryKey)
if (!this.stillOwns(ticket, auth)) { return }
if (snapshot) {
this.tasks = snapshot.tasks
this.notice = '当前显示离线缓存,请联网刷新'
} else {
this.tasks = []
this.notice = '网络不可用,且没有可用的离线数据'
}
}
}
如果要实现完全可运行的独立示例,可以把这一片段放入前文的 Index 页面并导入 ReadResult、RemoteTask、AuthSnapshot、SessionStore、LatestQuery 和缓存模块,复用先前已经实现的 HTTP 解码器。这里特意没有在网络层新增“自动离线”布尔开关,因为是否显示缓存是页面的业务判断,不是请求 SDK 的通用义务。缓存结果仍要经过服务端字段结构的约束,若将来增加任务描述、权限字段或分页标记,必须同步升级 schema,并对旧缓存执行迁移或失效处理。
退出登录不只是清空一个 token
一次完整的退出动作需要推进 SessionStore 代际、令 LatestQuery 失效、立即清除页面旧任务,并清除本地缓存。要特别注意异步清除与并发刷新之间的交错:如果退出后立即登录另一个账号,新会话开始写入缓存,旧退出动作的 clear() 不得再反过来删除新会话快照。实际工程应让缓存的写、清除操作经过单写队列或存储事务管理,并在写入之前重复确认账号归属;上面的简化仓库描述了数据格式和读取规则,队列治理是落地时必须补齐的并发约束。
另一类问题是“网络已经恢复,但用户还在看缓存”。不要把连接状态变化直接等同于数据已经成功更新。可靠的策略是把网络恢复作为重新请求的触发条件,真正收到符合当前会话和当前查询票据的新响应,才将 offline 标识切换为 live。若服务端返回冲突或数据版本倒退,就按业务修订号进行重新获取,而不是让本地 writtenAt 更晚的数据天然获胜。

验证时至少覆盖四个路径:同一账号断网命中未过期快照;账号 A 退出后账号 B 不能读取 A 的数据;缓存已过期或 schema 不匹配时显示明确空态;旧请求成功晚到但当前会话已经更替时,UI 与缓存都不得被覆盖。再加上 401、403、业务错误、缓存写入失败和应用重新启动的情况,可以暴露大部分“看着能离线,实际会串数据”的实现问题。每条测试只需定义输入顺序、预期页面来源与缓存变化,不应把没有实际运行的时延或命中率写成测试成绩。
这次从“请求是否成功”走到了“数据是否可信且仍有归属”。离线缓存不是给旧结果延长生命的借口,而是一条额外的数据来源,必须遵守身份、查询、时效和安全边界。下一篇会把前五篇的失败分类、晚到拦截、会话切换、有限重试与缓存恢复串成完整回归测试路线。官方资料:Preferences 接口、HTTP 数据请求、HarmonyOS 文档中心。
更多推荐

所有评论(0)