影展排期系统到了发布阶段,最容易被忽略的不是时间表如何显示,而是“正在编辑的安排”和“观众已经看到的安排”之间究竟差了什么。上篇已经用反向变更记录解决了管理员误操作的撤销问题,但排片被撤销、换厅或改时之后,观众端仍可能停留在较早的场次信息。要把这种差异转成可阅读的提醒,发布动作必须有一个清楚的边界:先得到完整的候选排期,确认它满足影厅占用规则,再比较上次发布快照,最后形成新的发布版本与站内提醒。

排期发布差异与提醒流程

发布数据和编辑数据分开管理

我在工程结构上把前几篇使用的场次模型继续保留,但为发布侧增加单独的快照模型。编辑页可以反复调整场次,已发布的列表则始终指向最近一次发布快照。否则管理员只是拖动一个场次,观众列表就跟着变化,后台还没确认的安排会提前暴露,审计记录也很难回答“哪个版本曾对外展示”。

页面和业务代码可以放在以下位置,组件负责呈现,服务负责差异与持久化,模型负责字段和约束:

entry/src/main/ets/
├── model/Screening.ets
├── model/ReleaseSnapshot.ets
├── service/ReleaseDiff.ets
├── service/LocalReleaseStore.ets
├── pages/ReleaseReviewPage.ets
└── pages/InboxPage.ets

快照内保留稳定的场次ID、影片名称、影厅、绝对开始分钟和时长。绝对分钟沿用第一篇的处理方式:跨午夜场次不用单独拆成两条记录,比较时也不会因为日期切换而出现误判。修订号承担排序作用,场次ID承担身份作用,不能用某一行在列表中的下标代替ID。

第一段代码解决的是“两个快照如何得出业务差异”。新增、取消和变更分别处理;标题没有变化、只是排序发生改变的场次,不应被当成一次排期通知。

export interface Screening {
  id: string;
  film: string;
  hallId: string;
  startMinute: number;
  durationMinute: number;
}

export type ChangeKind = 'ADDED' | 'CANCELLED' | 'MOVED';

export interface ScheduleChange {
  id: string;
  kind: ChangeKind;
  before?: Screening;
  after?: Screening;
}

export function compareRelease(oldRows: Screening[], newRows: Screening[]): ScheduleChange[] {
  const oldById: Map<string, Screening> = new Map();
  const newById: Map<string, Screening> = new Map();
  oldRows.forEach((row: Screening) => oldById.set(row.id, row));
  newRows.forEach((row: Screening) => newById.set(row.id, row));
  const changes: ScheduleChange[] = [];
  newRows.forEach((now: Screening) => {
    const before = oldById.get(now.id);
    if (!before) {
      changes.push({ id: now.id, kind: 'ADDED', after: now });
    } else if (before.hallId !== now.hallId ||
               before.startMinute !== now.startMinute ||
               before.durationMinute !== now.durationMinute ||
               before.film !== now.film) {
      changes.push({ id: now.id, kind: 'MOVED', before, after: now });
    }
  });
  oldRows.forEach((before: Screening) => {
    if (!newById.has(before.id)) {
      changes.push({ id: before.id, kind: 'CANCELLED', before });
    }
  });
  return changes;
}

这里把影片名或片长修改也纳入MOVED,是为了保持消息分类精简,界面文案会根据实际字段变化选择“影厅调整”“放映时间变更”或“信息更新”。如果仅凭JSON.stringify比较两个数组,排序变化会产生大量不必要的提醒;如果只比较开始时间,则无法发现同一时间从一号厅搬到二号厅的操作。两个索引都按ID建立,规模增长后仍能保持可理解的比较开销。

从差异事件生成站内提醒

变化对象解决“改了什么”,提醒对象还需要解决“通知谁”和“同一版本是否重复生成”。这里先使用应用内的订阅列表:用户可以在本机关注一个场次,发布后在站内消息页面看到变化。后续如果需要向不同账户同步,可以让服务端按订阅关系分发;目前的本地实现只负责生成同一应用的数据记录,不把本地消息等同于跨终端通知。

消息需要同时保存发布版本、场次ID和变化类别。重复点击发布按钮或者页面重新打开时,不能再次插入相同版本的消息。用version:screeningId:kind建立稳定业务键,再用已存在键集合排除重复项,确保读取、保存和列表更新共用一份结果。

export interface InboxNotice {
  key: string;
  screeningId: string;
  version: number;
  text: string;
  read: boolean;
}

export function buildNotices(
  changes: ScheduleChange[], followedIds: string[], version: number,
  current: InboxNotice[]
): InboxNotice[] {
  const followed = new Set<string>(followedIds);
  const known = new Set<string>(current.map((n: InboxNotice) => n.key));
  const added: InboxNotice[] = [];
  changes.forEach((change: ScheduleChange) => {
    if (!followed.has(change.id)) return;
    const key = `${version}:${change.id}:${change.kind}`;
    if (known.has(key)) return;
    const film = change.after?.film ?? change.before?.film ?? '该影片';
    const label = change.kind === 'ADDED' ? '新增放映' :
      (change.kind === 'CANCELLED' ? '场次取消' : '排期发生变更');
    added.push({ key, screeningId: change.id, version,
      text: `${film}:${label},请查看最新排期`, read: false });
    known.add(key);
  });
  return current.concat(added);
}

提醒文字保持克制:列表显示变更摘要,进入详情页才展示旧厅位、原开始时间和新安排。这样既避免长消息挤占首屏,也能防止旧消息带着早已失效的时间引导用户。用户点击已读时只更新消息的read,不能改动发布快照。取消关注是订阅关系变化,不等于清除历史提醒,两个动作也应当分开。

开发环境中的发布差异处理

保持发布版本和提醒同进退

单独保存发布快照,再保存消息队列,看似方便,实际容易发生前一项写入完成、后一项失败的情况。对于小规模的站内本地状态,可以把发布版本、快照和提醒合并为一个对象,通过Preferences中的同一个键写入。这是轻量数据方案;场次量很大、涉及多个账户或需要多条件查询时,再把发布流程迁移到带事务能力的数据存储层。

第三段代码解决“成功发布后怎样保存完整状态”。函数收到的候选场次已经通过前一篇复用的冲突与缓冲校验。并发按钮用页面忙碌状态拦截,业务层仍校验预期修订号,避免过时编辑页覆盖较新的发布版本。

import { preferences } from '@kit.ArkData';

export interface ReleaseState {
  version: number;
  screenings: Screening[];
  notices: InboxNotice[];
}

export async function saveRelease(
  context: Context, expectedVersion: number,
  draft: Screening[], followed: string[]
): Promise<ReleaseState> {
  const prefs = await preferences.getPreferences(context, { name: 'film_release' });
  const raw = await prefs.get('state', '') as string;
  const state: ReleaseState = raw ? JSON.parse(raw) as ReleaseState :
    { version: 0, screenings: [], notices: [] };
  if (state.version !== expectedVersion) {
    throw new Error('发布版本已变化,请重新载入排期');
  }
  const changes = compareRelease(state.screenings, draft);
  const version = state.version + 1;
  const next: ReleaseState = {
    version,
    screenings: draft.map((row: Screening) => ({ ...row })),
    notices: buildNotices(changes, followed, version, state.notices)
  };
  await prefs.put('state', JSON.stringify(next));
  await prefs.flush();
  return next;
}

Preferences适合保存数量有限的键值数据,put后继续调用flush,读写异常交给调用方统一处理。这里的修订判断保证页面按当前状态提交;如果存在多个进程同时写入,普通读改写并不具备数据库事务的并发保证,应将跨进程发布职责交给可序列化的存储服务。还有一个细节:draft.map要复制对象,防止编辑页随后修改同一引用,影响当前视图中的快照。

页面的生命周期也要与发布动作保持一致。aboutToAppear读取最新版本,读入失败时呈现重试入口;点击发布先关闭重复触发,再依次校验冲突、比较变化、保存快照。写入成功才把页面的版本号推进,失败则仍保留编辑草稿和旧快照,便于排查。

第四段代码展示发布按钮与状态更新的边界。冲突判断checkHallConflicts是前面文章已经建立的业务函数;页面应调用它而不是在发布页重新编写一套时间计算。

@Entry
@Component
struct ReleaseReviewPage {
  @State version: number = 0;
  @State draft: Screening[] = [];
  @State busy: boolean = false;
  @State errorText: string = '';
  private followedIds: string[] = ['film-01', 'film-03'];

  async publish(): Promise<void> {
    if (this.busy) return;
    this.busy = true;
    this.errorText = '';
    try {
      if (checkHallConflicts(this.draft).length > 0) {
        throw new Error('仍有放映厅占用冲突');
      }
      const next = await saveRelease(getContext(this), this.version,
        this.draft, this.followedIds);
      this.version = next.version;
    } catch (err) {
      this.errorText = err instanceof Error ? err.message : '发布失败';
    } finally {
      this.busy = false;
    }
  }

  build() {
    Column({ space: 12 }) {
      Text(`候选排期版本:${this.version + 1}`)
      if (this.errorText.length > 0) Text(this.errorText).fontColor(Color.Red)
      Button(this.busy ? '正在处理' : '确认发布')
        .enabled(!this.busy).onClick(() => { void this.publish(); })
    }.padding(20)
  }
}

页面示例需要从已有服务模块导入Screening、saveRelease、checkHallConflicts;getContext(this)由ArkUI组件上下文提供。正式工程还可以把加载状态、空数据、权限不可用等情况映射成不同页面反馈,但业务提交顺序不能倒过来。对于同一个订阅场次,如果仅更新片长,也需要将旧时长和新时长放进详情,避免把所有修改描述成换厅。

观众端站内排期变更消息

调试应该围绕哪几种状态

不需要为每条提醒单独设计一次复杂测试,先沿版本边界检查更有效:相同快照重复发布是否产生空差异;跨午夜场次换厅后是否只对应一个稳定ID;只调整列表排序是否产生消息;已读提醒是否在下一次发布后仍保留;未通过冲突校验时版本号是否保持不变。查看日志可使用releaseVersion、changeCount、noticeCount和blockingConflictCount四个字段,不应把订阅人的个人信息写入普通日志。

还有两个容易遗漏的恢复分支。其一,打开消息页后才刷新快照,界面必须基于消息版本展示历史变更,不拿当前场次信息倒填旧提醒。其二,管理员撤销一次调位之后再次发布,新的版本号应该再次生成新的差异,旧版本的提醒不删除,历史才能完整连接上篇的撤销轨迹。

最后把发布页和观众页的职责分开来看:管理员关心的是候选排期是否能成为下一版快照,观众关心的是自己关注的场次相对上次发布变化了什么。稳定ID、快照比较、去重消息键与写入边界构成同一条路径。后续实现完整的观众通知时,可以继续沿用这组变化数据,而不必重写排期规则。

把对外展示的“发布时间”与场次自身的“放映开始时间”拆开,也能降低界面歧义。场次修改发生在下午,下一次预定放映却是凌晨,提醒排序应该采用发布事件发生的顺序;详情页展示放映时间时,仍使用带日期的当地时间。若后续服务端统一保存时间戳,客户端负责按影展所在时区转换展示,不能因为管理员手机时区变化就让跨午夜场次落到错误日期。发布失败后的重试也应使用新的草稿修订,而不是沿用上一次失败时捕获的对象引用。这样消息、快照和排期表才能真正指向同一个版本。

相关资料

Logo

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

更多推荐