ArkData + HarmonyOS 票据归档:前后台恢复与偏好写入边界(连载04/06)【共创稿事节】
一个票据归档应用,用户在首页打开“仅看待归档”,又改成按日期升序;马上切到其他应用,再回到票据列表。有时页面展示了旧条件,有时刚才点过的设置却已经保存。若把问题简单归为“应用进入后台后没有及时保存”,处理方向很容易走偏。真正要弄清楚的是:页面当前显示的选择、写入队列中等待的修改、以及 Preferences 已经提交的数据,究竟哪一份才算已生效。

这一篇沿用前三篇的数据协议,不会换成另外一种存储。receipt_filter_settings 是 Preferences 文件名,receipt_filter_v2 是保存 JSON 配置的键;结构仍是 FilterPrefsV2{version, onlyPending, category, sort},旧字段 only_pending 只用于历史迁移。第三篇新增的 ReceiptWriteQueue 将修改任务串行执行,写入后调用 flush(),如果失败尝试回到上一份有效配置。本篇给这个队列补上一套清楚的前后台边界:进后台之前的写入要如何排队、回到前台后怎样判断是否需要重读,以及哪些操作根本不应该依赖生命周期回调。
先把“切到后台”和“页面销毁”区分开
Stage 模型的 UIAbility 有 onForeground()、onBackground() 等生命周期回调,应用页面自身也有 aboutToAppear()、aboutToDisappear()。两组回调不代表相同的事件:用户从列表进入详情,可能触发页面变化,未必表示应用整体进入后台;系统将 UIAbility 切到后台,也不等于每个页面都重新初始化。更不能把窗口变窄或多窗口调整一律认成后台切换。若每个回调都新建一个偏好存储对象,再同时读取和写回“默认设置”,原来用于保证顺序的队列就失去意义。
在业务上可以定义三种状态:committed 表示已完成持久化流程的最新有效配置;pending 表示已经排队但还没有完成写盘的修改;view 表示用户现在看到的界面。上一阶段的演示采用“写盘成功后才把新值发布给 UI”,于是 view 与 committed 通常相同,pending 通过数字单独提示。这是一个保守却容易检查的模式。用户快速连续点击按钮时,动作已经受理,但不意味着每次点击都能立刻宣布持久化成功。
如果要把页面做成乐观更新,即按钮一按就立即切换视觉状态,也完全可以设计,但它多出一套回滚约定:失败时恢复哪个版本、后续操作已排队又该如何重新计算,都得写清楚。对筛选偏好这样低频且容易理解的设置,先让落盘成功成为 UI 正式提交点,往往能减少不必要的状态争议。

更重要的是,应用切后台并不是一个可靠的“最后保存时刻”。回调能够用于释放资源、记录诊断或提示队列尽快完成,但进程可能随时受到系统调度;不能把数据持久化的唯一入口放在 onBackground()。在用户点击变更时就启动串行写入,在成功后才返回确认,生命周期回调仅做辅助协调。这样即使没有走到某个预想的退出路径,已成功提交的偏好也有可追踪的状态。
给旧队列加一条明确的屏障,而不是再建第二个队列
第三篇的 ReceiptWriteQueue 把每次操作拼接到 tail: Promise<void> 上,并用失败吞并分支保证后续队列还能继续。这个设计要继续保持单实例。这里给它增加两个小接口:一是等待当前已入队任务结束,二是读取是否进入需要修复的状态。它们都是我们自己的业务封装方法,并非 HarmonyOS SDK 新 API。
// 以下方法增加到上一阶段的 ReceiptWriteQueue 类内部
// tail、needsRepair、enqueue 仍沿用原文件中的实现
async whenIdle(): Promise<void> {
await this.tail;
}
isHealthy(): boolean {
return !this.needsRepair;
}
这段代码的语义非常具体。调用 whenIdle() 的一刻,队列已有的任务最终会完成或进入失败分支;这不表示未来不会再有新任务入队。tail 原本就会在每次 enqueue 后更新,屏障应当在业务入口停止继续提交新操作、或只作为诊断观察时使用。不能把“等待队列当前为空”误写成一次跨进程事务,更不能推断调用该方法可以保证系统在退出时一定给足执行时间。
再设计一个极小的共享会话协调器,让首页、设置页以及 UIAbility 的生命周期回调能使用同一份队列。它负责管理初始化 Promise,防止两个页面差不多同时出现时分别创建两个 ReceiptWriteQueue。这里采用进程内单例思路,是同一 UIAbility 作用域内的复用约定;真正多进程写同一 Preferences 文件不是这一设计的目标,官方 API 对多进程并发有专门警告。
// entry/src/main/ets/model/ReceiptPrefSession.ets
import { common } from '@kit.AbilityKit';
import { FilterPrefsV2 } from './ReceiptFilterStore';
import { ReceiptWriteQueue, FilterEdit } from './ReceiptWriteQueue';
export class ReceiptPrefSession {
private static active: ReceiptPrefSession | null = null;
private readonly queue: ReceiptWriteQueue;
private starting: Promise<FilterPrefsV2> | null = null;
private constructor(context: common.UIAbilityContext) {
this.queue = new ReceiptWriteQueue(context);
}
static get(context: common.UIAbilityContext): ReceiptPrefSession {
if (ReceiptPrefSession.active === null) {
ReceiptPrefSession.active = new ReceiptPrefSession(context);
}
return ReceiptPrefSession.active;
}
initialize(): Promise<FilterPrefsV2> {
if (this.starting === null) {
this.starting = this.queue.open();
}
return this.starting;
}
async update(edit: FilterEdit): Promise<FilterPrefsV2> {
await this.initialize();
return this.queue.enqueue(edit);
}
async settleQueuedWrites(): Promise<void> {
await this.queue.whenIdle();
}
healthy(): boolean { return this.queue.isHealthy(); }
}
这里复用 FilterEdit,继续由队列在执行时拿到前一份已提交的配置,再生成新值。不要在按钮点击瞬间先读取一个陈旧的 this.filters,把它当成后续所有排队操作的输入;否则两次排队请求可能都基于同一份旧快照,第二次覆盖第一次。前文已经把 edit(current) 设计成队列内执行,就是为避免这个竞争。
共享实例也有明确生命周期:如果未来应用含多个互不关联的 UIAbility,或者用户需要同时切换账号与数据目录,就不能不分上下文永久复用同一个静态对象。那时应通过能识别账户、上下文或会话边界的服务注册机制来管理实例,并在身份切换时先处理待提交操作。对现在的单 UIAbility、单本地文件演示,保持一个队列实例才是重点。
前台回来以后,别让旧读取覆盖刚提交的设置
页面初始化时使用 ReceiptPrefSession.initialize(),拿到恢复结果再解锁编辑入口。用户提交筛选变更后,只有 update() Promise 成功返回才更新页面展示;失败时保留最后一份已提交的内容,并给出修复或重试提示。整个页面不会在 aboutToAppear() 的每一次执行都强制重新读取 Preferences,因此不会出现“异步读盘晚回来,盖掉刚刚写完的设置”这种无谓竞争。
// entry/src/main/ets/pages/ReceiptResumePage.ets
import { common } from '@kit.AbilityKit';
import { FilterPrefsV2, defaultFilters } from '../model/ReceiptFilterStore';
import { ReceiptPrefSession } from '../model/ReceiptPrefSession';
@Entry
@Component
struct ReceiptResumePage {
@State filters: FilterPrefsV2 = defaultFilters();
@State ready: boolean = false;
@State pending: number = 0;
@State notice: string = '正在准备筛选偏好…';
private session: ReceiptPrefSession | null = null;
aboutToAppear(): void {
this.session = ReceiptPrefSession.get(
getContext(this) as common.UIAbilityContext);
this.restore();
}
private async restore(): Promise<void> {
try {
this.filters = await this.session!.initialize();
this.notice = '筛选偏好已准备好';
this.ready = true;
} catch (err) {
this.notice = '偏好初始化失败,请检查配置';
this.ready = false;
}
}
private async togglePending(): Promise<void> {
if (!this.ready || this.session === null) { return; }
this.pending += 1;
try {
this.filters = await this.session.update((current: FilterPrefsV2) => ({
...current, onlyPending: !current.onlyPending
}));
this.notice = '设置已保存';
} catch (err) {
this.notice = this.session.healthy() ?
'保存失败,请重试' : '存储需要检查,请重新进入设置';
} finally { this.pending -= 1; }
}
build() {
Column({ space: 16 }) {
Text('票据归档').fontSize(26).fontWeight(FontWeight.Bold)
Button(this.filters.onlyPending ? '仅看待归档:开启' : '仅看待归档:关闭')
.enabled(this.ready && this.pending === 0)
.onClick(() => { this.togglePending(); })
Text(`排序:${this.filters.sort} 分类:${this.filters.category}`)
Text(`排队操作:${this.pending}`)
Text(this.notice).fontSize(14).fontColor('#64748B')
}.width('100%').padding(20)
}
}
这个页面接线把一项复杂性留在队列:连续两次切换时,返回的 Promise 分别对应两个提交。生产页面如果同时让多个提交在途,还要避免较早请求的 UI 回调晚于较新请求,造成界面短暂倒退。这里可以由页面限制一次一个按钮修改,或者在协调器提供最新提交序号并只发布最新完成状态。队列只保证写入顺序,并不自动保证所有页面回调显示顺序。 这是存储顺序与界面展示顺序必须分开的原因。
生命周期侧的接线要更克制。例如在 EntryAbility 中,onBackground() 可以触发对当前队列的观察,并捕获异常,但不应该把新建队列、执行迁移、写入默认值塞进去。为了不给读者制造“后台保存一定成功”的错觉,下面仅展示同一进程调用关系:
// 可放在现有 EntryAbility 类的 onBackground() 方法中
const session: ReceiptPrefSession = ReceiptPrefSession.get(this.context);
session.settleQueuedWrites().catch(() => {
// 记录必要的诊断信息;下一次前台进入仍应核对可用配置
});
这段代码需要在已有 EntryAbility.ets 导入 ReceiptPrefSession,并与原本的生命周期实现合并,不是一个新的系统回调。它不能成为唯一保存入口,而且不应在后台无限等待或轮询。偏好是否可用,取决于每次用户操作期间 put 与默认 XML 存储模式下的 flush 是否完成;应用重新进入后还可以检查配置版本和字段值域,决定是否显示正常设置或修复提示。

回归测试不能只靠“切出去再切回来”
测试应采用能分辨原因的序列。先进入应用,等待 initialize() 完成;改动“仅看待归档”,观察按钮状态是否在保存成功后改变。再快速连续触发两个修改,检查队列执行的输入是否来自最新已提交对象,而不是两次都从旧对象复制。完成后进入后台、回到前台,偏好值应与最后一次成功提交一致。若 UI 提示保存失败,不能拿刚才点击过按钮这件事证明偏好已落盘。
第二组验证失败:让存储层模拟 flush 返回失败,检查 UI 是否保留最后一份成功值;再模拟恢复写入也失败,needsRepair 应阻止后续普通修改,避免“继续覆盖损坏配置”。这里说的是可在工程中通过接口替身设计的故障注入步骤,不依赖未经验证的性能数字。必要时可以在 ReceiptWriteQueue 中将 Preferences 访问封装为可替换接口,单元测试就能精确制造“先失败、恢复成功”“先失败、恢复也失败”的分支,而不需要把设备真的弄到存储不足。
第三组检查生命周期和多入口。打开设置页又返回首页,不能产生两个互相不认识的队列;首页仍在加载时用户连续点击,应被禁用或排队,不允许被晚来的读取覆盖。将窗口从宽屏改成窄屏,不能触发重置偏好;切换到后台再回来,也不应自动把已有 receipt_filter_v2 刷成默认值。最后再检查一个升级用户:文件里同时有旧 only_pending 与有效新版配置时,继续以新版为准,生命周期恢复不得重走迁移分支。
至此,票据归档助手的偏好存储从“把一个开关写下来”发展到“兼容旧版本、串行处理修改、前后台恢复保持一致”。接下来适合进一步处理多条件查询的真实票据数据与偏好之间的契约,或者为存储异常建立更可观察的诊断路径。不要让一个简单 Preferences 文件承载账单详情、附件、用户账号与多进程事务,那些是不同的数据治理问题。把小规模偏好做稳,比给它贴上“全端同步”标签更有价值。
相关官方资料:
更多推荐

所有评论(0)