HarmonyOS影展排片连载06:订阅已读水位与历史版本筛选【共创稿事节】
排期变更提醒发布以后,观众会陆续打开消息页。有的人逐条看完,有的人从最新消息进入详情,也有人把筛选条件切换到上周,再回来继续阅读。如果只是为每条提醒保存一个read布尔值,列表规模变大时容易重复写入;如果直接把“用户打开过的最大版本号”当作全部已读的凭据,又会错误吞掉中间尚未阅读的提醒。上一节已经将站内消息与排期发布快照绑定,这一节沿用同一份稳定场次ID,给订阅者增加连续已读水位、乱序确认集合和历史版本筛选。

从发布版本到个人收件序列
影展排期可能经历v14、v15、v16多个版本,但某位观众只关注一部影片。他在v15没有收到消息,v16才遇到一次改厅;若直接按照全局版本号推断阅读进度,就会把“没有投递”与“存在但未读”混为一谈。这里为每个订阅者生成连续的收件序号seq,仅用于自己的已读水位;releaseRevision仍保留为发布历史筛选条件。两种序号用途不同,不能互相替代。
工程沿用FilmSchedule的场次与快照模型,增加model/InboxNotice.ets、service/InboxAssembler.ets、service/ReadCursor.ets、service/InboxRepository.ets和pages/NoticeHistoryPage.ets。组装服务负责去重,已读服务处理连续序列,存储层负责落盘,页面只持有当前列表快照和筛选状态。初次装载、切换订阅对象、返回前台都应重新从同一来源计算显示集合,而不是把上次列表的行下标当作已读状态。
稳定消息键由订阅者、场次、发布修订和变更种类共同构成;单次发布对同一场次的同种变化只落一条。历史版本重放、页面重建、重复点击刷新都必须得到同一业务键。这里沿用上一节的ScheduleChange对象,数据组装不依赖ArkUI组件生命周期。
export interface InboxNotice {
key: string;
viewerId: string;
screeningId: string;
seq: number;
releaseRevision: number;
changeKind: string;
summary: string;
publishedAtMs: number;
}
export interface NoticeChange {
screeningId: string;
changeKind: string;
summary: string;
releaseRevision: number;
publishedAtMs: number;
}
export function appendNotices(
viewerId: string, followed: string[], changes: NoticeChange[],
existing: InboxNotice[]
): InboxNotice[] {
const subscribed: Set<string> = new Set<string>(followed);
const keys: Set<string> = new Set<string>(existing.map((n: InboxNotice) => n.key));
const result: InboxNotice[] = existing.slice();
let seq: number = existing.reduce((max: number, n: InboxNotice) =>
Math.max(max, n.seq), 0);
const ordered = changes.slice().sort((a: NoticeChange, b: NoticeChange) =>
a.releaseRevision - b.releaseRevision || a.screeningId.localeCompare(b.screeningId));
ordered.forEach((change: NoticeChange) => {
if (!subscribed.has(change.screeningId)) return;
const key = `${viewerId}:${change.releaseRevision}:${change.screeningId}:${change.changeKind}`;
if (keys.has(key)) return;
seq += 1;
result.push({ key, viewerId, seq, screeningId: change.screeningId,
releaseRevision: change.releaseRevision, changeKind: change.changeKind,
summary: change.summary, publishedAtMs: change.publishedAtMs });
keys.add(key);
});
return result;
}
这里要求existing属于同一订阅者;若仓库存放多名用户的消息,需要在调用前按viewerId分组,并让收件序号从该用户最近的最大值继续。changes按发布修订排序,避免网络响应顺序造成序号漂移。生产环境还应让存储端以业务键保障并发写入的幂等性,因为两个页面同时构建消息时,仅靠内存Set无法处理竞争。
连续水位和乱序确认分开保存
连续水位表示“从第1条直到第N条均已确认”。观众直接打开第6条,不应让第4条、第5条自动变已读。因此需要另存一个稀疏确认集合:点击第6条先记录pending=[6],只有第4条和第5条陆续确认后,才可推动连续水位。随着水位向前移动,把已经被覆盖的稀疏序号移除,避免长期累积无效数据。
export interface ReadCursor {
watermark: number;
outOfOrder: number[];
}
export function markRead(cursor: ReadCursor, seq: number, maximumSeq: number): ReadCursor {
if (!Number.isInteger(seq) || seq < 1 || seq > maximumSeq) {
throw new Error('消息序号不在当前收件范围内');
}
if (seq <= cursor.watermark) return cursor;
const extra: Set<number> = new Set<number>(cursor.outOfOrder);
extra.add(seq);
let next: number = cursor.watermark;
while (extra.has(next + 1)) {
next += 1;
extra.delete(next);
}
return { watermark: next,
outOfOrder: Array.from(extra).filter((n: number) => n > next)
.sort((a: number, b: number) => a - b) };
}
export function isRead(cursor: ReadCursor, notice: InboxNotice): boolean {
return notice.seq <= cursor.watermark || cursor.outOfOrder.includes(notice.seq);
}
这个函数有两个边界值得单独检查。其一,点击的是已经确认的旧消息,返回当前水位即可,不产生新写入;其二,传入不存在的序号要报错,不能因为界面复用了旧索引而意外推进水位。若某条记录因权限或留存策略从列表中移除,后台应决定是否把它视为已归档,再以明确事件维护收件序列,不能仅靠过滤后的数组长度修正maximumSeq。
历史列表的“未读”是展示条件,不是删除条件。即使用户在新版本阅读了旧消息,发布快照仍需要保留,以便解释提醒为何产生。历史筛选也不能改变水位,特别是只看v15时,不应该把屏幕上暂时看不到的v16标为已读。

版本筛选与界面生命周期
消息页支持“全部、未读、历史”三种视图,再叠加版本区间。版本号取自发布快照,时间排序使用publishedAtMs,两者不要以设备当前日期替代。筛选函数不修改原始消息数组,保证用户切换条件后能重新得到完整列表;列表的唯一键必须保持notice.key不变,避免行复用导致消息卡片状态串位。
export type InboxMode = 'ALL' | 'UNREAD' | 'HISTORY';
export interface InboxFilter {
mode: InboxMode;
minRevision: number;
maxRevision: number;
}
export function selectNotices(
all: InboxNotice[], cursor: ReadCursor, filter: InboxFilter
): InboxNotice[] {
if (filter.minRevision > filter.maxRevision) {
throw new Error('发布版本筛选区间无效');
}
return all.filter((n: InboxNotice) => {
if (n.releaseRevision < filter.minRevision ||
n.releaseRevision > filter.maxRevision) return false;
const read = isRead(cursor, n);
if (filter.mode === 'UNREAD') return !read;
if (filter.mode === 'HISTORY') return read;
return true;
}).sort((a: InboxNotice, b: InboxNotice) =>
b.publishedAtMs - a.publishedAtMs || b.seq - a.seq);
}
这里的“历史”明确指已读记录;如果业务需要展示“所有旧发布版本”,可以另外设置OLDER_RELEASE过滤类型,不应悄悄复用HISTORY改变原有含义。筛选版本时还要考虑v15没有该用户订阅变化的情况:版本区间内没有消息是正常空态,不能自动生成一条通知补齐数量。界面可以显示当前已读水位与筛选范围,但不要把它当作消息是否已经同步到其他设备的凭据。
ArkUI页面使用@State维护筛选状态和派生列表,ForEach以消息业务键区分卡片。生命周期上,aboutToAppear适合触发收件加载与状态重建;从详情页返回时可重新读取游标,避免页面缓存覆盖新数据。@State属性修改后会驱动对应内容更新,不必直接操纵列表子组件。
@Entry
@Component
struct NoticeHistoryPage {
@State items: InboxNotice[] = [];
@State cursor: ReadCursor = { watermark: 0, outOfOrder: [] };
@State mode: InboxMode = 'ALL';
private allItems: InboxNotice[] = [];
aboutToAppear(): void {
this.refreshView();
}
private refreshView(): void {
this.items = selectNotices(this.allItems, this.cursor,
{ mode: this.mode, minRevision: 1, maxRevision: 999999 });
}
build() {
Column({ space: 12 }) {
Text(`连续已读至 ${this.cursor.watermark}`).fontSize(18)
Row({ space: 8 }) {
Button('全部').onClick(() => { this.mode = 'ALL'; this.refreshView(); })
Button('未读').onClick(() => { this.mode = 'UNREAD'; this.refreshView(); })
Button('历史').onClick(() => { this.mode = 'HISTORY'; this.refreshView(); })
}
List({ space: 8 }) {
ForEach(this.items, (n: InboxNotice) => {
ListItem() {
Column({ space: 4 }) {
Text(`v${n.releaseRevision} · ${n.summary}`)
Text(isRead(this.cursor, n) ? '已读' : '未读')
}.padding(12).width('100%')
}
}, (n: InboxNotice) => n.key)
}.layoutWeight(1)
}.padding(16).width('100%').height('100%')
}
}
这段页面只呈现派生列表,实际工程在仓库加载完成后给allItems与cursor赋值并调用refreshView。点击通知需要经过服务层的markRead,再把新游标持久化并刷新视图;不要在渲染阶段执行写盘,也不要把页面中的暂存数组当作唯一来源。页面被系统回收后,重新从仓库装载消息与游标才能恢复同一阅读状态。
持久化次序和异常恢复
单机站内提醒可以用ArkData Preferences保存较小的游标快照。Preferences是键值持久化机制,支持getPreferencesSync取得实例、put更新值、flush落盘。应用使用Stage模型时传入有效Context,并保证存储目录可访问。考虑到多进程并发访问Preferences存在数据损坏风险,读写约束在同一进程中的单个收件仓库执行,避免从多个服务同时写一个文件。
import { preferences } from '@kit.ArkData';
import { common } from '@kit.AbilityKit';
export class CursorRepository {
private store: preferences.Preferences;
constructor(context: common.UIAbilityContext) {
this.store = preferences.getPreferencesSync(context, { name: 'festival_read_cursor' });
}
async load(viewerId: string): Promise<ReadCursor> {
const raw = await this.store.get(viewerId, '');
if (typeof raw !== 'string' || raw.length === 0) {
return { watermark: 0, outOfOrder: [] };
}
const data = JSON.parse(raw) as ReadCursor;
if (!Number.isInteger(data.watermark) || data.watermark < 0 ||
!Array.isArray(data.outOfOrder)) {
throw new Error('本地已读记录格式无效');
}
return data;
}
async save(viewerId: string, cursor: ReadCursor): Promise<void> {
await this.store.put(viewerId, JSON.stringify(cursor));
await this.store.flush();
}
}
一次点击应先形成新游标并提交保存,flush成功后再更新可见已读状态。如果写盘失败,保留上一份游标并给出重试入口;先把UI改为已读、随后丢弃存储异常,会在下次启动时让消息重新变为未读。多个快速点击可以进入串行队列,或者用当前游标为基础累积确认后批量提交。不得让较旧的异步完成结果覆盖较新的水位。
调试路径应围绕数据事实而不是某一个视觉颜色:准备版本v14、v15、v16的消息,构造一个用户只收到v14和v16的收件序列;点击第2条,观察稀疏集合与连续水位的不同;再点击第1条,确认水位推进至2并清空已覆盖的集合。随后切换“未读”和“历史”,核查筛选前后业务键不变。还应检查非法序号、重复插入同版本消息、存储损坏、页面重新装载和消息分页时的排序稳定性。HiLog中记录viewerId的脱敏标识、seq、releaseRevision和水位变化即可,不输出不必要的个人信息。

工程上的后续空间
站内消息的阅读进度与发布快照构成两条正交的状态:发布版本解释“观众看到的是哪一版排期”,收件水位解释“观众确认阅读到自己的第几条”。由稳定消息键实现幂等组装,由连续水位加乱序集合解决跳读,由版本过滤保留可追溯历史。下一篇可以在此基础上进一步考虑发布撤回、订阅关系变更以及跨终端同步冲突,而不必重写跨午夜和换场冲突判定。
实际项目中,跨设备同步应让服务端维护用户维度的收件序列,并在合并时采用明确的水位与离散确认集合规则。不能把两台设备上的最大releaseRevision简单取最大值作为全部已读,因为两端收到的消息集合可能不同。对于消息量大的场景,历史分页需要稳定游标及服务端查询条件;本地列表只负责呈现当前页,存储层仍保留完整的逻辑顺序。这样既能保持通知操作轻量,也能让观众随时查回某次排期变更的前因后果。
相关资料
更多推荐

所有评论(0)