灯光模拟HarmonyOS应用实战-22-页面首次加载为何要先初始化 Preferences 再刷新统计
首页上的“练习、合格、错题”看起来只是三个数字,实际上它们是持久化初始化时序的最终投影。顺序写反时,应用不会一定崩溃,反而更容易出现迷惑现象:历史页明明有旧记录,冷启动首页却显示三个 0;等用户完成一次新练习后,统计又突然恢复。
灯光模拟应用用一条很短的 Promise 链规避了这个问题:先取得 Preferences 实例,再读取历史记录,最后用 @State records 驱动统计重算。本文不把“先后顺序”停留在口号上,而是逐段解释首次构建、异步回调、兜底空数组和派生统计之间的真实关系。

三个 0 可能是正常首帧,也可能是一次被掩盖的读取失败
Index 页给记录数组的初始值是空数组:
@State records: PracticeRecord[] = [];
private store: PracticeStore = new PracticeStore();
因此页面第一次执行 build() 时,统计区允许暂时显示 0。这本身不是错误。真正的验收点是:Preferences 准备完成后,持久化记录能否被读出并写回 records,从而触发二次渲染。
要区分两种状态:
| 看到 0 的时刻 | 是否合理 | 下一步应发生什么 |
|---|---|---|
| 冷启动刚出现的首帧 | 可以接受 | 初始化完成后刷新成真实数字 |
| 初始化与读取已经结束 | 有历史时不合理 | 检查是否提前读取、解析失败或存储未初始化 |
| 存储确实没有历史 | 合理 | 继续保持 0,并显示对应空态 |
| 存储初始化异常但被兜底 | 不能冒充“没有数据” | 正式产品应增加可观测状态或日志 |
当前源码选择“可用优先”:读取失败时回退空数据,不让首页崩溃。文章后面会说明如何在不改变这一原则的前提下增加错误可见性。
aboutToAppear 建立正确的因果顺序
下面是 Index.ets 的真实启动代码:
aboutToAppear(): void {
const context = this.getUIContext().getHostContext() as common.UIAbilityContext;
this.store.init(context).then(() => {
this.refreshData();
});
}
它表达的是“init 完成后才调用 refreshData”,而不是“页面必须等数据读完才显示”。根据 ArkUI 自定义组件生命周期,aboutToAppear 在组件实例创建后、build() 之前触发;但这里启动的是异步任务,then 不会阻塞首帧构建。
所以准确时序是:
Index创建,records采用空数组默认值;aboutToAppear()获取UIAbilityContext并启动初始化;- 页面可以先用默认状态完成构建;
preferences.getPreferences()完成;- Promise 回调执行
refreshData(); records被替换,依赖它的统计组件更新。

这里最关键的是第 4、5 步不能倒置。首帧是否显示加载态属于产品选择,但真实读取必须发生在存储句柄可用之后。
PracticeStore.init 只负责拿到持久化入口
PracticeStore 将 Preferences 声明为可选成员,并在初始化失败时保持 undefined:
const STORE_NAME: string = 'kemusan_exam_store';
export class PracticeStore {
private store?: preferences.Preferences;
async init(context: common.UIAbilityContext): Promise<void> {
if (this.store) {
return;
}
try {
this.store = await preferences.getPreferences(context, STORE_NAME);
} catch (err) {
this.store = undefined;
}
}
}
这个方法拥有三个清晰边界:
- 页面提供有效的
UIAbilityContext,服务不在内部猜测上下文; - 已经取得实例后再次调用会立即返回;
- 初始化异常不会直接把页面 Promise 变成未处理拒绝。
与此同时,当前实现没有向调用者返回“成功/失败”状态,也没有记录错误详情。这意味着 then 被调用只代表 init() 已结束,不等价于一定拿到了存储实例。后续读方法的兜底会继续保证页面可用,但无法区分“真的没有历史”和“初始化失败”。这是当前源码的容错边界,不应被包装成完整的错误提示体系。
为什么不能在 init 之前直接 listRecords
listRecords() 最终会调用一个私有读取方法。当前真实逻辑可概括为:
private async getString(key: string, fallback: string): Promise<string> {
if (!this.store) {
return fallback;
}
try {
const value = await this.store.get(key, fallback);
return typeof value === 'string' ? value : fallback;
} catch (err) {
return fallback;
}
}
如果页面抢在 init() 之前调用 listRecords(),this.store 还是 undefined,于是 getString() 会立刻返回 '[]'。JSON 解析成功、页面不报错、统计也能计算——只是计算的是一个“看起来合法”的空数组。
最危险的反例不是崩溃,而是下面这种没有第二次刷新的代码:
// 反例:两个异步操作并行启动,读取可能先拿到兜底空数组
aboutToAppear(): void {
const context = this.getUIContext().getHostContext() as common.UIAbilityContext;
this.store.init(context);
this.refreshData();
}
一旦 refreshData() 先走到 getString(),页面就把空数组写入 records;随后 init() 虽然成功,也没有任何动作重新读取历史。这个故障很难从异常堆栈发现,因为每一步都“成功返回”了。
listRecords 是原始记录到页面状态的唯一读路径
初始化完成后,refreshData() 不自行读取键值或解析 JSON,而是调用服务层:
private refreshData(): void {
this.store.listRecords().then((records: PracticeRecord[]) => {
this.records = records;
});
}
PracticeStore.listRecords() 内部读取 exam_history,把旧结构规范化为 PracticeRecord,再按 createdAt 从新到旧排序。页面得到的不是一段原始 JSON,而是一组已经过兼容处理的领域记录。
这种分层有实际收益:
- JSON 损坏时由服务统一回退空数组;
- 旧记录缺少
subject、mode等字段时由normalizeRecord()补默认值; - 首页、历史页和筛选逻辑共同消费同一份
records,不会各自解析出不同结果。

图中 records 才是页面会话内的真值;统计数字是从它派生出来的展示结果,不应被单独持久化后再尝试双向同步。
getStats 从同一批记录重算,不手工补计数
PracticeStore.getStats() 遍历记录,根据 passed 计算通过和错题数量:
getStats(records: PracticeRecord[]): PracticeStats {
let passed = 0;
let wrong = 0;
for (let index = 0; index < records.length; index++) {
if (records[index].passed) {
passed++;
} else {
wrong++;
}
}
return {
total: records.length,
passed,
wrong
};
}
页面中的 getStats() 只是把当前 records 交给服务计算。这样做比“答对就把合格数加一、答错就把错题数加一”更可靠,因为以下场景都会改变统计基础:
- 冷启动重新读取历史;
- 清空全部记录;
- 旧数据迁移或兼容;
- 一次写入后服务返回截断到 100 条的最新记录;
- 某条记录未来增加修改或删除能力。
当前统计面板会分别调用 this.getStats() 读取三个字段。由于历史上限是 100,这个开销可控;若以后数据量增大,可在普通方法中一次计算后映射到页面状态,但不要在 @Builder 内声明临时变量破坏 ArkTS 声明式语法。
写入后的刷新链路同样不能只改数字
应用完成练习后,addSimpleRecord() 调用 store.addRecord()。服务先读取旧记录、把新记录放到数组头部、保留最多 100 条,再写入 Preferences 并返回最新数组;页面随后替换 records。
this.store.addRecord(record).then((records: PracticeRecord[]) => {
this.records = records;
});
这条写路径和首次读路径遵循同一个原则:页面状态来自服务返回的完整记录快照,而不是页面猜测“总数应该加一”。因此,持久化、容量上限和统计三者不会各维护一套计数。
需要诚实说明当前实现的另一个边界:putString() 捕获了写入或 flush() 异常,但 addRecord() 仍可能把内存数组返回给页面。也就是说,当前会话看起来新增成功,不代表进程重启后一定能读到。若要做发布级可靠性,应让写入结果显式携带成功状态,并用重启回读验证,而不是只看当前界面数字。
从当前实现升级到可观测加载状态
如果产品不希望首帧短暂显示 0,可以加一个非常小的加载状态。以下代码是建议方案,不是当前 The_kemusan 已有实现;页面与后面的 Promise<boolean> 初始化接口需要一起迁移:
@State records: PracticeRecord[] = [];
@State historyLoadState: string = 'loading';
async aboutToAppear(): Promise<void> {
const context = this.getUIContext().getHostContext() as common.UIAbilityContext;
this.historyLoadState = 'loading';
try {
const initialized = await this.store.init(context);
if (!initialized) {
this.historyLoadState = 'failed';
return;
}
this.records = await this.store.listRecords();
this.historyLoadState = 'ready';
} catch (err) {
this.historyLoadState = 'failed';
}
}
对应的 PracticeStore.init() 显式返回 boolean,页面必须检查 false 分支,不能只依靠 catch。否则初始化内部捕获异常后,页面仍会把失败误记为 ready:
async init(context: common.UIAbilityContext): Promise<boolean> {
if (this.store) {
return true;
}
try {
this.store = await preferences.getPreferences(context, STORE_NAME);
return true;
} catch (err) {
this.store = undefined;
return false;
}
}
这套布尔方案能把“初始化失败,可重试”与加载中、初始化成功后的空记录区分开,但还不是所有读取错误的完整可观测方案。当前 listRecords() 和 getString() 仍会把读取或解析异常回退为空数组;若要区分“确实无历史”和“读取损坏”,还应让读取接口返回明确的结果状态,而不是把空数组直接视为正常读取。当前源码只实现可用性兜底,上面的建议仅补齐初始化失败的可见性。
迁移检查:不要漏掉上下文、异步顺序和回读
把这一模式迁移到其他 HarmonyOS 项目时,可按以下顺序执行:
- 在服务中统一保存
Preferences实例,页面不直接散落键名。 - 从
UIContext获取宿主UIAbilityContext,传入服务初始化。 await init()或在.then()内开始读取,禁止并行抢跑。- 服务返回规范化记录,页面一次性替换
@State数组。 - 统计从记录重算,不维护独立持久化计数。
- 写入后关闭并重开页面,必要时重启应用,验证
flush()后的数据确实存在。
如果一个页面会在导航返回或应用回前台时继续复用,还要根据页面架构评估 onPageShow 或 NavDestination 生命周期刷新。aboutToAppear 适合组件实例创建前后的初始化,但不会因为应用每次从后台回前台就必然重新创建组件。不要把“首次初始化”和“每次重新可见”混为一谈。
验证矩阵:用时序而不是肉眼猜测
| 用例 | 准备数据 | 操作 | 预期 |
|---|---|---|---|
| 首次安装 | 无历史 | 启动应用 | 初始化结束后总数仍为 0,无异常 |
| 冷启动回读 | 预置 3 条通过、2 条失败 | 结束进程再启动 | 总数 5、合格 3、错题 2 |
| 慢初始化 | 在调试环境记录时间戳 | 启动并观察首帧与回调 | 可先显示默认态,随后只刷新到真实快照 |
| 提前读取反例 | 临时把刷新移到 init 外 | 冷启动 | 可复现先读到空数组,用于证明顺序问题;验证后还原 |
| JSON 异常 | 调试构造非法历史字符串 | 启动 | 页面不崩溃,记录回退空数组 |
| 旧记录兼容 | 缺少新增字段的历史 | 启动 | normalizeRecord() 补默认字段,统计仍正确 |
| 写入持久化 | 完成一次练习 | 重启应用 | 新纪录仍存在,统计一致 |
| 清空历史 | 先有多条记录 | 清空后重启 | 三个数字归零,不回弹旧数据 |
建议给 init 开始/结束、listRecords 开始/结束、records 赋值 临时打印单调序号,而不是只打印“成功”。正确日志顺序应始终是初始化结束在读取开始之前。验证后移除日志,避免把本地存储内容写进正式日志。
故障表:从哪个边界开始排查
| 症状 | 更可能的根因 | 检查点 |
|---|---|---|
| 冷启动永远是 0,做题后正常 | 读取早于初始化且没有二次刷新 | aboutToAppear 的 Promise 顺序 |
| 当前会话有记录,重启后消失 | 写入或 flush() 失败 | putString() 的结果与重启回读 |
| 有记录但统计分类错误 | 旧字段规范化或 passed 值异常 | normalizeRecord() 和原始数据 |
| 切回首页仍是旧统计 | 页面复用后没有刷新 | 路由返回对应的生命周期入口 |
| 初始化失败却显示“暂无记录” | 失败与空数据共用同一兜底 | 增加显式加载/失败状态 |
| 偶发重复初始化 | 多次触发生命周期且首个初始化未完成 | 增加进行中的 Promise 复用或页面级门闩 |
生命周期时机可参考华为官方的 页面和自定义组件生命周期。官方说明用于确认 aboutToAppear 与 build() 的前后关系;本文中的存储名、记录结构和刷新链路均来自当前项目源码。
最终结论:先建立数据入口,再读取,再派生
“先初始化 Preferences 再刷新统计”真正解决的不是一个 API 调用顺序,而是数据可信度问题。存储实例未准备好时,兜底空数组只能代表“暂时读不到”,不能代表“用户没有历史”。
当前应用通过 init().then(refreshData) 保住了因果顺序,再用服务规范化记录、用 @State records 驱动渲染、用 getStats() 从同一快照派生数字。继续加固时,应优先补加载与失败可观测性,仍然不要让页面自己维护一套与持久化脱节的计数。
更多推荐

所有评论(0)