ArkUI + HarmonyOS 影厅选座:库存版本刷新与过期回执隔离 -03【共创稿事节】

座位 C1 明明已经被另一位观众买走,页面却可能在下一次刷新后重新把它显示成紫色。这不是颜色组件的故障,而是两次异步响应到达顺序颠倒:较新的库存先返回,较旧的库存随后抵达,又把界面覆盖成了过期状态。一个看似普通的“刷新座位”按钮,只要接入真实网络,就不能再被理解成简单地替换整个数组。
SeatFlow 在前两篇已经建立统一的基础模型:3号厅19:30场次以 hall3-1930 标识,座位为 A~E 五排、每排六座;初始已售 B3、C2、D5、E1;A~D 排单价45元,E排35元;一次最多选择四个位置。第二篇的 LocalSeatGateway.check() 负责示例 C1 被新订单占用后,局部移除冲突选择,保留 A1、A2,并将金额从135元重算为90元。本篇不再重复“如何点选格子”,而把工程关注点转向库存版本、异步响应新旧关系、重新加载与选择合并,让同一套业务模型能承受刷新重入。
刷新不是替换整页,而是合并两种不同来源的事实
页面里至少同时存在两种信息。一种是服务器知道的库存:哪些座位已经不可售,以及这份快照属于哪个场次和版本。另一种是用户本机的选择意图:他倾向购买 A1、A2、C1。这两种信息的可信等级不同。库存决定座位是否可选;本机意图只能在新库存允许时被保留。把这两部分全都放进一份网络返回的 CinemaSeat[],会让刷新吞掉用户刚刚点击的内容。
更安全的做法是让服务端只返回权威字段。示例继续使用前三态 Free、Selected、Sold 作为页面视觉状态,但 Selected 不允许来自库存接口;它由本地选择集合派生。正式票务系统如果要区分暂时保留与最终售出,应另建服务器确认的预留状态,不能凭这三个枚举推定已锁座。
interface SeatSnapshot {
showId: string
version: number
soldIds: string[]
}
interface CinemaSeat {
id: string
price: number
status: SeatStatus
}
enum SeatStatus {
Free = 'free',
Selected = 'selected',
Sold = 'sold'
}
version 必须由权威库存源保证同一场次内可比较且单调递增。不能直接把本地 Date.now() 当成版本:用户设备时间可以不准,不同设备的时钟也未必一致。生产接口还应给出缓存策略与快照生成时刻;本文用一个确定性的本地网关示例合并逻辑,不假装存在远端交易服务。

给响应做两道门禁:请求代际与库存版本
过期响应有两种。一种发生在页面自身:用户连续点击“刷新库存”,第2次请求先完成,第1次晚到。另一种发生在数据源层:即便第2次请求晚发,也可能命中旧缓存,携带比页面当前版本更低的库存。只比较请求序号不能解决后者;只比较库存版本则可能让页面结束时错误地关闭新请求的加载提示。两道校验各管一件事。
在原有 SeatPage.ets 中新增 refreshSeq、loadedVersion 和 refreshing。为了延续上一轮的状态模型,selectedSeats 与 selectedTotal 仍从 this.seats 派生,toggleSeat()、createSeats()、checkSelection() 保留原有业务边界。后续代码展示本篇的增量函数;把它放入此前页面结构体,而不是新建第二个同名 @Entry。
@State private loadedVersion: number = 1
@State private refreshing: boolean = false
private refreshSeq: number = 0
// 沿用前篇已有的 this.message 与 this.gateway,不重复声明
private async refreshSnapshot(): Promise<void> {
const sequence: number = ++this.refreshSeq
this.refreshing = true
this.message = '正在刷新库存…'
try {
const snapshot: SeatSnapshot = await this.gateway.fetchLatest()
if (sequence !== this.refreshSeq) { return }
if (snapshot.showId !== 'hall3-1930') {
this.message = '场次不匹配,已忽略响应'
return
}
if (snapshot.version <= this.loadedVersion) {
this.message = '当前已经是最新库存版本'
return
}
this.mergeSnapshot(snapshot)
} catch (error) {
if (sequence === this.refreshSeq) {
this.message = '刷新失败,保留当前选择'
}
} finally {
if (sequence === this.refreshSeq) { this.refreshing = false }
}
}
在这个实现中,sequence 代表当前页面实例最后发起的刷新;旧请求完成后不会触碰新请求的界面状态。loadedVersion 则代表已经应用到页面的数据版本,未通过版本比较的响应不允许回退。注意 refreshSeq 不是后端幂等标识,也不能阻止另一台设备购买座位。真实服务端仍需要单独处理并发购买、库存扣减和支付一致性。
为了让开发者复现,测试网关可以固定返回版本2的快照,并故意把 C1 追加到初始已售集合。它不需要网络权限或服务器。第二篇“确认座位”已经通过 LocalSeatGateway.check() 认识 C1,本篇的 fetchLatest() 是另一个只读取库存快照的方法;两者属于同一示例服务的不同职责。
class LocalSeatGateway {
async fetchLatest(): Promise<SeatSnapshot> {
return {
showId: 'hall3-1930',
version: 2,
soldIds: ['B3', 'C2', 'D5', 'E1', 'C1']
}
}
}
实际项目应将第二篇已有的 check() 方法保留在同一个 LocalSeatGateway 中,上面只展示新增的 fetchLatest(),不应重新声明一个覆盖旧功能的类。这个返回值属于测试夹具:版本2是示例编号,不能理解为华为系统版本,也不能当成真实影院库存。
用集合交集做最小合并,金额只从座位状态派生
把 C1 从本地选择中剔除,只修改 C1.status 是不够的。刷新可能一次告知多个座位变成已售,也可能重新加载后遇到更完整的库存版本。如果写多个硬编码 if (id === 'C1'),后续扩展只能不断打补丁。正确的合并关系是:新快照的已售集合优先;仍可售的旧选中座位继续保持选中;剩余位置恢复可选。
private mergeSnapshot(snapshot: SeatSnapshot): void {
const known: Set<string> = new Set<string>(
this.seats.map((s: CinemaSeat) => s.id))
const sold: Set<string> = new Set<string>(
snapshot.soldIds.filter((id: string) => known.has(id)))
const chosen: Set<string> = new Set<string>(
this.selectedSeats.map((s: CinemaSeat) => s.id))
const removed: string[] = Array.from(chosen).filter(
(id: string) => sold.has(id))
this.seats = this.seats.map((seat: CinemaSeat) => ({
...seat,
status: sold.has(seat.id) ? SeatStatus.Sold :
chosen.has(seat.id) ? SeatStatus.Selected : SeatStatus.Free
}))
this.loadedVersion = snapshot.version
this.phase = 'idle'
this.message = removed.length > 0
? `库存已更新,移除:${removed.join('、')}`
: `库存已更新至版本 ${snapshot.version}`
}
此处先以已有座位 ID 过滤 soldIds,避免接口错误的 Z9 被渲染为新行;Set 同时去除了重复的售出 ID。对接真实 API 时还需要校验类型、数组长度和合法 ID 格式,否则 snapshot.soldIds 若非数组就会在调用 filter() 时失败。mergeSnapshot() 目前只承担已经通过基本校验的对象,网络边界上的数据验证应该放在调用它之前。
在处理库存刷新之前,建议维护一条清晰的模型不变量:同一座位在一份 CinemaSeat[] 中只出现一次;同一个 ID 不可能同时属于已售和已选;票数不超过四;总金额仅依赖当前已选的座位单价。每一次刷新都应该重新满足这四个条件。这样测试失败时可以检查数据而不是猜测按钮颜色。列表渲染使用稳定的座位 ID 作为 ForEach 的键,避免按数组下标复用错误单元。初始数据固定30项,网格应设置为六列;如果未来改为不规则影厅,价格和可选性不能再从行号位置推导,而需要服务端维护独立的座位配置。
票数和金额依旧不是可以单独赋值的状态。例如旧选择 A1、A2、C1 是3座、135元;收到版本2后,C1 变为 Sold,旧选择 A1、A2 被保留,selectedSeats 自动变为2个,selectedTotal 自动变为90元。这比维护一个可随手修改的 this.total -= 45 更可靠,因为后续若丢失 E2,其票价又是35元。
get selectedSeats(): CinemaSeat[] {
return this.seats.filter((seat: CinemaSeat) =>
seat.status === SeatStatus.Selected)
}
get selectedTotal(): number {
return this.selectedSeats.reduce((sum: number, seat: CinemaSeat) =>
sum + seat.price, 0)
}
重复进入页面时,刷新必须和本机选择生命周期配合
真实选座页经常经历离开、返回、再进入。页面重新创建时可能丢失此前的本机临时选择;页面没有销毁时则可能仍在等待上一个响应。需要把“什么时候恢复用户意图”定义清楚:本示例只保留当前页面实例里的选择,不承诺跨进程恢复;如果将来把选中 ID 保存到 Preferences,也必须先刷新库存,再做交集,绝不能直接把保存的 C1 染成选中。
界面中可以把按钮放在座位网格与确认区之间,让操作路径简明。
Text(`库存版本 v${this.loadedVersion}`)
.fontSize(13).fontColor('#667085')
Button(this.refreshing ? '刷新中…' : '刷新库存')
.enabled(!this.refreshing && this.phase !== 'checking')
.onClick(() => { this.refreshSnapshot() })
Text(this.message).fontSize(13).fontColor('#6E5BD6')
Text(`已选 ${this.selectedSeats.length} 座 · 合计 ¥${this.selectedTotal}`)
刷新期间禁用再次点击是最小化策略;refreshSeq 防护依然要保留,因为将来加入自动刷新、网络超时、页面重新激活时,可能从不同路径启动请求。原先的 checkSelection() 与刷新按钮不能并发修改同一份 seats。当前示例将 checking 禁止刷新,刷新期间也应在 checkSelection() 的入口加 if (this.refreshing) { return }。这是一个双向门禁,不是只把按钮置灰就能解决,因为程序仍可能通过其他方法调用。

把“新旧响应”拆成四个可复现的检查场景
场景一,页面初始为库存版本1,手动选择 A1、A2、C1,点击刷新。fetchLatest() 返回版本2后,C1 转灰、A1/A2 保持紫色、票数变2、金额变90,消息指出 C1 被移除。场景二,再点刷新,同一版本2再次返回,不应该重复改动状态,只提示当前已是最新版本。场景三,构造 version: 1 的缓存响应,即使它晚到也不能把 C1 改回可选。场景四,将 showId 故意改成另一场次,例如 hall4-2000,即使版本更高也必须拒绝合并。
若要构造“旧请求晚到”,可以在调试专用网关中给不同请求返回不同的 Promise,并分别控制解决顺序:先返回序号2的新快照,再返回序号1的旧快照。观察 refreshSeq 是否丢弃后一份响应,并确认旧响应不会改写 refreshing 和 message。这属于测试设计,不是已经做过的实测。完整工程还应检查异常抛出后的按钮是否恢复可用,以及空 soldIds 是否仍能保持合法数据。
调试时可以在请求入口和版本检查处设置断点,记录四个值:页面请求序号、响应版本、当前已应用版本、所选择座位 ID。不要把 message 的中文文案当成唯一检查依据,因为它可能正确而底层数组仍旧错误。重复点击、切到后台再返回等手势也应纳入手工验证序列。使用真实后端以后,最好给库存响应附带可审计的 showId 和版本,并为锁座请求定义专门的冲突码;网络超时不等于座位已售。
本篇的最终边界非常清楚:页面是库存快照的消费者,不是库存真相的生产者。refreshSeq 保证单页面异步刷新不会被旧响应反向覆盖;version 保证数据源的过期内容不压倒已应用快照;mergeSnapshot() 则只保留仍合法的本机选择。下一篇可以在这个合并基础上完成 SeatFlow 的工程收尾,把错误展示、用户重试和服务端锁座协议的接入边界整理成一条可复查的流程。
参考:Grid/GridItem 组件布局、ArkUI 状态管理总览、https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/arkts-observed-and-objectlink/@Observed 与 @ObjectLink。
更多推荐

所有评论(0)