排期变更提醒发布以后,观众会陆续打开消息页。有的人逐条看完,有的人从最新消息进入详情,也有人把筛选条件切换到上周,再回来继续阅读。如果只是为每条提醒保存一个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标为已读。

DevEco Studio中的水位计算与历史列表

版本筛选与界面生命周期

消息页支持“全部、未读、历史”三种视图,再叠加版本区间。版本号取自发布快照,时间排序使用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简单取最大值作为全部已读,因为两端收到的消息集合可能不同。对于消息量大的场景,历史分页需要稳定游标及服务端查询条件;本地列表只负责呈现当前页,存储层仍保留完整的逻辑顺序。这样既能保持通知操作轻量,也能让观众随时查回某次排期变更的前因后果。

相关资料

Logo

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

更多推荐