HarmonyOS影展排片连载08:订阅解绑与过期消息安全清理【共创稿事节】
影展提醒从最初的场次冲突检测,逐步增加了换场缓冲、批量调位、撤销审计、发布差异、已读水位和断线追更。消息最终能按序补齐,但订阅本身仍有结束的一天:影展闭幕、观众主动关闭关注,或者一个账号退出后切换另一个账号。如果这时直接删除本地列表,可能把尚未阅读的提醒一起清掉;如果只隐藏页面,后台请求又可能继续写入已经停用的订阅流。更隐蔽的问题是,清理历史消息后把接收游标也归零,会导致重新绑定时把已处理的消息大量拉回。
最后一篇围绕一条明确的生命周期收束工程:订阅停用、历史压缩与接收位置是三个不同操作。停用决定是否还能增量获取;压缩决定哪些本地记录可以被淘汰;游标负责保留消息接收事实。只有把三种含义拆开,才能让消息模块在关闭、重绑、账户切换和网络延迟时保持可解释的状态。

延续上一节的消息流模型
上一节的Notice以稳定id和单调递增的seq标识消息,SyncSnapshot同时保存receivedThrough与readThrough。前者是连续接收水位,后者是用户确认阅读的水位。清理逻辑必须沿用这两个字段,不能用当前数组里最大的序号反向计算接收位置,因为旧记录可以被正常淘汰,数组本身并不保存完整历史。
工程中新增model/SubscriptionState.ets、service/NoticeRetention.ets、service/SubscriptionLifecycle.ets、service/NoticeLocalStore.ets和pages/SubscriptionPage.ets。在这些文件里,应用业务仓库负责保存每个订阅流的状态,生命周期服务决定能否触发请求,压缩器是纯函数,页面则提供停用与整理入口。网络服务仍沿用前篇的增量HTTP实现,消息序号分配依然属于应用自己的服务端协议。
用户账号和流标识必须一一对应地进入存储命名空间。不同账号都可能出现seq=110,这不意味着它们共享接收进度。对于重新绑定,服务端应返回当前账号与流身份的匹配结果以及可恢复的游标范围;如果分配了新的流标识,应当以新流的起始位置初始化,而不是把旧流的数字搬过去。
// model/SubscriptionState.ets
import { Notice, SyncSnapshot } from './Notice';
export interface StoredNotice extends Notice {
receivedAtMs: number;
}
export interface SubscriptionState {
accountKey: string;
streamId: string;
active: boolean;
generation: number;
receivedThrough: number;
readThrough: number;
messages: StoredNotice[];
}
export function validState(s: SubscriptionState): boolean {
return s.accountKey.length > 0 && s.streamId.length > 0 &&
Number.isInteger(s.receivedThrough) && s.receivedThrough >= 0 &&
Number.isInteger(s.readThrough) && s.readThrough >= 0 &&
s.readThrough <= s.receivedThrough &&
Number.isInteger(s.generation) && s.generation >= 0;
}
receivedAtMs记录消息被当前应用仓库接收的时间,用于本地保留期限判断;它不同于服务器发布时间,也不参与跨午夜排片时间计算。接收时间来自可信的本地业务写入流程,异常或缺失时间戳的记录进入单独的待整理集合,避免把错误数据当作旧消息直接删除。这里继续使用前篇已经定义的Notice字段,确保新模块没有擅自改变消息身份。
压缩只能淘汰确认已读的旧记录
不少实现把“早于七天”当作唯一删除条件,实际上旧消息是否已读同样重要。例如序号105已读且超过保留期,序号109仍未读,即使109的时间戳更早也应该留下。更重要的是,删除105后仍然保留接收水位110;下一轮增量请求继续以110为基础,而不是根据数组首尾重新猜测游标。
压缩函数按已读确认和保留期限同时满足来筛选。它不触碰streamId、接收位置、阅读位置以及订阅状态。为了避免误清服务端尚未确认的乱序阅读条目,本章只使用连续已读水位;如果业务保留了前一篇的乱序已读集合,也应在统一阅读确认模型中更新后再计算可删范围。
// service/NoticeRetention.ets
import { StoredNotice, SubscriptionState } from '../model/SubscriptionState';
export interface PruneResult {
state: SubscriptionState;
removedIds: string[];
}
export function pruneExpired(state: SubscriptionState,
cutoffMs: number): PruneResult {
if (!Number.isFinite(cutoffMs) || cutoffMs <= 0) {
throw new Error('过期时间配置无效');
}
const kept: StoredNotice[] = [];
const removedIds: string[] = [];
for (const item of state.messages) {
const expired = Number.isFinite(item.receivedAtMs) &&
item.receivedAtMs > 0 && item.receivedAtMs < cutoffMs;
const confirmed = item.seq <= state.readThrough;
if (expired && confirmed) {
removedIds.push(item.id);
} else {
kept.push(item);
}
}
return { state: { ...state, messages: kept }, removedIds };
}
该函数采用新的数组返回值,方便仓库一次性替换内存快照,也便于定位哪条消息被整理。假设账号A已连续接收到110、阅读确认至108,历史缓存包含101至110。若101至105都超过保留期,整理后删掉这五条,106至110保持不变;receivedThrough=110和readThrough=108也完全不变。账号B有自己的流和状态,清理A绝不能影响B。
不要使用消息的视觉排序决定清理范围。列表可以按发布日期、电影名称或未读优先级重排,但水位是流内序号,不是界面下标。尤其在新版排期发布后,同一电影可能再次产生提醒,两条消息标题相同,却对应不同的发布修订与稳定ID。如果以标题做清理键,后来的变更消息会被误删。
停用订阅要屏蔽延迟返回的旧请求
用户点击“停止关注”时,停止之后不再拉取新提醒,但此刻可能已有一次网络请求在途。仅在按钮上设置active=false不够,旧请求返回时仍能写进缓存。为每次生命周期切换增加generation代次,可以在提交网络结果之前检查是否仍属于同一账号、同一流和同一代次。这个代次是应用内部状态数字,不是发布版本号,也不能代替服务端流身份。
// service/SubscriptionLifecycle.ets
import { SubscriptionState } from '../model/SubscriptionState';
export interface FetchToken {
accountKey: string;
streamId: string;
generation: number;
}
export function beginFetch(state: SubscriptionState): FetchToken {
if (!state.active) {
throw new Error('当前订阅已停用');
}
return { accountKey: state.accountKey, streamId: state.streamId,
generation: state.generation };
}
export function mayApply(state: SubscriptionState,
token: FetchToken): boolean {
return state.active && state.accountKey === token.accountKey &&
state.streamId === token.streamId &&
state.generation === token.generation;
}
export function deactivate(state: SubscriptionState): SubscriptionState {
return { ...state, active: false, generation: state.generation + 1 };
}
调用方应在网络响应解析、流身份校验和数据合并之前再次执行mayApply()。如果用户已解绑,即使HTTP成功返回200也放弃这次页面合并;如果账户切换导致新的状态已经装载,同样不能将原账号消息写入新账号缓存。若业务层还使用请求取消功能,取消请求可以减少资源开销,但最终写入前的代次检查仍应保留,用于处理响应与停用几乎同时发生的竞态。
停用与删除账户数据不应混为同一个按钮。用户只是暂时取消订阅时,应用可以保留已接收消息供离线阅读,直到用户主动清空或业务保留期到达;退出登录或明确清除账号数据时,则应按账户级清理策略移除相应缓存。两种操作应在产品交互中分别说明,不能把“停止提醒”偷偷扩展为“删除全部历史”。
轻量存储围绕整份状态提交
Preferences适合保存轻量配置和有限条消息的快照。单条消息逐个写键容易出现部分更新:新接收水位已经持久化,消息列表却尚未保存。更稳妥的做法是把当前账号的状态序列化为一个带版本的JSON值,并在写入完成后刷新落盘。数据量超过首选项容量或需要复杂查询时,可以换成数据库仓库,但业务层的状态契约与压缩规则无需改变。
// service/NoticeLocalStore.ets
import { preferences } from '@kit.ArkData';
import { common } from '@kit.AbilityKit';
import { SubscriptionState, validState } from '../model/SubscriptionState';
export async function saveStream(context: common.UIAbilityContext,
key: string, state: SubscriptionState): Promise<void> {
if (!validState(state) || key.length === 0) {
throw new Error('订阅状态不完整');
}
const store = await preferences.getPreferences(context,
{ name: 'notice_state' });
await store.put(`stream_${key}`, JSON.stringify(state));
await store.flush();
}
同一流的读写操作应由仓库串行处理:完成一次合并并保存以后,再允许下一次整理覆盖状态。若两次异步保存无序交叉,较旧的快照可能晚于较新的快照落盘。还需要在加载时检查JSON字段、缓存版本以及当前登录账号;解析失败时给出“本地状态需要重新核对”的提示,保留问题诊断信息,并通过服务端约定的流恢复入口重建,不能使用空数组直接覆盖可能仍有价值的未读数据。
页面把状态决策交还给业务仓库
界面展示时,账号A的订阅按钮可以变为“已停用”,同时展示接收水位110、已读水位108与五条可整理历史;账号B仍显示活跃状态和自己的接收位置32。页面组件不应自行筛选另一个账号的数组,数据仓库先根据账户范围输出可显示的行,再将它们交给@State。下例突出状态分支和按钮交互,操作事件通过已有的业务仓库执行停用、保存和整理。
// pages/SubscriptionPage.ets 的页面展示结构
@Entry
@Component
struct SubscriptionPage {
@State subscribed: boolean = true;
@State receivedThrough: number = 110;
@State readThrough: number = 108;
@State hint: string = '订阅状态可用';
build() {
Column({ space: 12 }) {
Text('影展订阅管理').fontSize(23).fontWeight(FontWeight.Bold)
Text(`连续接收 ${this.receivedThrough} · 已读 ${this.readThrough}`)
Text(this.subscribed ? '当前仍在订阅' : '订阅已停用')
.fontColor(this.subscribed ? '#187447' : '#A4552C')
Button('停止订阅')
.enabled(this.subscribed)
.onClick(() => { this.hint = '请核对停用后的缓存保留范围'; })
Button('查看可整理的历史消息')
.onClick(() => { this.hint = '整理操作保留游标与未读消息'; })
Text(this.hint).fontSize(13)
}.padding(20).width('100%')
}
}


图中的两个账号说明缓存命名空间的隔离关系。账号A停用后,页面仍能显示已经接收的历史数据;账号B保持订阅,继续根据自己的流和水位获取增量。页面示例中的按钮展示状态转移入口,业务仓库在确认停用或清理时才更改持久状态,这样失败提示可以包含具体原因,而不是仅把文字颜色改成绿色。
收尾核对要关注不变量
排查解绑后仍出现新提醒,重点核对请求发起代次、返回代次以及当前流身份;排查清理后再次出现旧消息,重点核对receivedThrough是否被错误回退;排查未读丢失,检查整理时是否误把seq > readThrough的消息当作过期项目;排查账号交叉,核对存储键是否真正包含账户范围及流身份。诊断输出建议只记录计数、游标和流的匿名标识,不写入提醒全文。
可以准备四组输入覆盖工程关键分支:已读且过期、未读但过期、同一序号重复到达以及解绑后旧响应返回。每组都对应明确的期望状态。对包含101至110的缓存,若只整理101至105,期望保留五条且游标仍为110;停用后即使旧响应带来111,也不应进入新一代状态;账号B的32与28水位应保持不变。这个核对过程也帮助确认前篇的补洞算法能够继续在历史记录压缩后的仓库上工作。
官方开发资料可参考用户首选项API、ArkUI列表开发、https://developer.huawei.com/consumer/cn/doc/HarmonyOS-Guides/arkts-watch@Watch状态观察。上述存储和页面接口是HarmonyOS提供的通用开发能力;订阅流身份、过期保留期、停用协议和请求代次属于应用的业务约定,接入时须与服务端保持一致。
至此,排片的工程链路形成闭环:发布侧用版本描述场次变化,消息侧用连续序号追回遗漏,阅读侧保存用户确认,生命周期侧确保关闭之后不再更新,整理侧只淘汰满足条件的旧记录。可靠性并不来自“把缓存全部清空”,而来自每一步都保留正确的状态含义。
更多推荐

所有评论(0)