首页上的“练习、合格、错题”看起来只是三个数字,实际上它们是持久化初始化时序的最终投影。顺序写反时,应用不会一定崩溃,反而更容易出现迷惑现象:历史页明明有旧记录,冷启动首页却显示三个 0;等用户完成一次新练习后,统计又突然恢复。
灯光模拟应用用一条很短的 Promise 链规避了这个问题:先取得Preferences实例,再读取历史记录,最后用@State records驱动统计重算。本文不把“先后顺序”停留在口号上,而是逐段解释首次构建、异步回调、兜底空数组和派生统计之间的真实关系。
三个 0 可能是正常首帧,也可能是一次被掩盖的读取失败
Index页给记录数组的初始值是空数组:
@Staterecords:PracticeRecord[]=[];privatestore:PracticeStore=newPracticeStore();因此页面第一次执行build()时,统计区允许暂时显示 0。这本身不是错误。真正的验收点是:Preferences准备完成后,持久化记录能否被读出并写回records,从而触发二次渲染。
要区分两种状态:
| 看到 0 的时刻 | 是否合理 | 下一步应发生什么 |
|---|---|---|
| 冷启动刚出现的首帧 | 可以接受 | 初始化完成后刷新成真实数字 |
| 初始化与读取已经结束 | 有历史时不合理 | 检查是否提前读取、解析失败或存储未初始化 |
| 存储确实没有历史 | 合理 | 继续保持 0,并显示对应空态 |
| 存储初始化异常但被兜底 | 不能冒充“没有数据” | 正式产品应增加可观测状态或日志 |
当前源码选择“可用优先”:读取失败时回退空数据,不让首页崩溃。文章后面会说明如何在不改变这一原则的前提下增加错误可见性。
aboutToAppear 建立正确的因果顺序
下面是Index.ets的真实启动代码:
aboutToAppear():void{constcontext=this.getUIContext().getHostContext()ascommon.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:
constSTORE_NAME:string='kemusan_exam_store';exportclassPracticeStore{privatestore?:preferences.Preferences;asyncinit(context:common.UIAbilityContext):Promise<void>{if(this.store){return;}try{this.store=awaitpreferences.getPreferences(context,STORE_NAME);}catch(err){this.store=undefined;}}}这个方法拥有三个清晰边界:
- 页面提供有效的
UIAbilityContext,服务不在内部猜测上下文; - 已经取得实例后再次调用会立即返回;
- 初始化异常不会直接把页面 Promise 变成未处理拒绝。
与此同时,当前实现没有向调用者返回“成功/失败”状态,也没有记录错误详情。这意味着then被调用只代表init()已结束,不等价于一定拿到了存储实例。后续读方法的兜底会继续保证页面可用,但无法区分“真的没有历史”和“初始化失败”。这是当前源码的容错边界,不应被包装成完整的错误提示体系。
为什么不能在 init 之前直接 listRecords
listRecords()最终会调用一个私有读取方法。当前真实逻辑可概括为:
privateasyncgetString(key:string,fallback:string):Promise<string>{if(!this.store){returnfallback;}try{constvalue=awaitthis.store.get(key,fallback);returntypeofvalue==='string'?value:fallback;}catch(err){returnfallback;}}如果页面抢在init()之前调用listRecords(),this.store还是undefined,于是getString()会立刻返回'[]'。JSON 解析成功、页面不报错、统计也能计算——只是计算的是一个“看起来合法”的空数组。
最危险的反例不是崩溃,而是下面这种没有第二次刷新的代码:
// 反例:两个异步操作并行启动,读取可能先拿到兜底空数组aboutToAppear():void{constcontext=this.getUIContext().getHostContext()ascommon.UIAbilityContext;this.store.init(context);this.refreshData();}一旦refreshData()先走到getString(),页面就把空数组写入records;随后init()虽然成功,也没有任何动作重新读取历史。这个故障很难从异常堆栈发现,因为每一步都“成功返回”了。
listRecords 是原始记录到页面状态的唯一读路径
初始化完成后,refreshData()不自行读取键值或解析 JSON,而是调用服务层:
privaterefreshData():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{letpassed=0;letwrong=0;for(letindex=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>初始化接口需要一起迁移:
@Staterecords:PracticeRecord[]=[];@StatehistoryLoadState:string='loading';asyncaboutToAppear():Promise<void>{constcontext=this.getUIContext().getHostContext()ascommon.UIAbilityContext;this.historyLoadState='loading';try{constinitialized=awaitthis.store.init(context);if(!initialized){this.historyLoadState='failed';return;}this.records=awaitthis.store.listRecords();this.historyLoadState='ready';}catch(err){this.historyLoadState='failed';}}对应的PracticeStore.init()显式返回boolean,页面必须检查false分支,不能只依靠catch。否则初始化内部捕获异常后,页面仍会把失败误记为ready:
asyncinit(context:common.UIAbilityContext):Promise<boolean>{if(this.store){returntrue;}try{this.store=awaitpreferences.getPreferences(context,STORE_NAME);returntrue;}catch(err){this.store=undefined;returnfalse;}}这套布尔方案能把“初始化失败,可重试”与加载中、初始化成功后的空记录区分开,但还不是所有读取错误的完整可观测方案。当前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()从同一快照派生数字。继续加固时,应优先补加载与失败可观测性,仍然不要让页面自己维护一套与持久化脱节的计数。