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

发布数据和编辑数据分开管理
我在工程结构上把前几篇使用的场次模型继续保留,但为发布侧增加单独的快照模型。编辑页可以反复调整场次,已发布的列表则始终指向最近一次发布快照。否则管理员只是拖动一个场次,观众列表就跟着变化,后台还没确认的安排会提前暴露,审计记录也很难回答“哪个版本曾对外展示”。
页面和业务代码可以放在以下位置,组件负责呈现,服务负责差异与持久化,模型负责字段和约束:
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、快照比较、去重消息键与写入边界构成同一条路径。后续实现完整的观众通知时,可以继续沿用这组变化数据,而不必重写排期规则。
把对外展示的“发布时间”与场次自身的“放映开始时间”拆开,也能降低界面歧义。场次修改发生在下午,下一次预定放映却是凌晨,提醒排序应该采用发布事件发生的顺序;详情页展示放映时间时,仍使用带日期的当地时间。若后续服务端统一保存时间戳,客户端负责按影展所在时区转换展示,不能因为管理员手机时区变化就让跨午夜场次落到错误日期。发布失败后的重试也应使用新的草稿修订,而不是沿用上一次失败时捕获的对象引用。这样消息、快照和排期表才能真正指向同一个版本。
相关资料
更多推荐


所有评论(0)