首页上的“练习、合格、错题”看起来只是三个数字,实际上它们是持久化初始化时序的最终投影。顺序写反时,应用不会一定崩溃,反而更容易出现迷惑现象:历史页明明有旧记录,冷启动首页却显示三个 0;等用户完成一次新练习后,统计又突然恢复。

灯光模拟应用用一条很短的 Promise 链规避了这个问题:先取得 Preferences 实例,再读取历史记录,最后用 @State records 驱动统计重算。本文不把“先后顺序”停留在口号上,而是逐段解释首次构建、异步回调、兜底空数组和派生统计之间的真实关系。

Preferences 初始化与首页统计封面

三个 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 不会阻塞首帧构建。

所以准确时序是:

  1. Index 创建,records 采用空数组默认值;
  2. aboutToAppear() 获取 UIAbilityContext 并启动初始化;
  3. 页面可以先用默认状态完成构建;
  4. preferences.getPreferences() 完成;
  5. Promise 回调执行 refreshData()
  6. records 被替换,依赖它的统计组件更新。

首次加载从 aboutToAppear 到统计重算的时序

这里最关键的是第 4、5 步不能倒置。首帧是否显示加载态属于产品选择,但真实读取必须发生在存储句柄可用之后。

PracticeStore.init 只负责拿到持久化入口

PracticeStorePreferences 声明为可选成员,并在初始化失败时保持 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 损坏时由服务统一回退空数组;
  • 旧记录缺少 subjectmode 等字段时由 normalizeRecord() 补默认值;
  • 首页、历史页和筛选逻辑共同消费同一份 records,不会各自解析出不同结果。

页面、PracticeStore、Preferences 与派生统计的职责结构

图中 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 项目时,可按以下顺序执行:

  1. 在服务中统一保存 Preferences 实例,页面不直接散落键名。
  2. UIContext 获取宿主 UIAbilityContext,传入服务初始化。
  3. await init() 或在 .then() 内开始读取,禁止并行抢跑。
  4. 服务返回规范化记录,页面一次性替换 @State 数组。
  5. 统计从记录重算,不维护独立持久化计数。
  6. 写入后关闭并重开页面,必要时重启应用,验证 flush() 后的数据确实存在。

如果一个页面会在导航返回或应用回前台时继续复用,还要根据页面架构评估 onPageShowNavDestination 生命周期刷新。aboutToAppear 适合组件实例创建前后的初始化,但不会因为应用每次从后台回前台就必然重新创建组件。不要把“首次初始化”和“每次重新可见”混为一谈。

验证矩阵:用时序而不是肉眼猜测

用例准备数据操作预期
首次安装无历史启动应用初始化结束后总数仍为 0,无异常
冷启动回读预置 3 条通过、2 条失败结束进程再启动总数 5、合格 3、错题 2
慢初始化在调试环境记录时间戳启动并观察首帧与回调可先显示默认态,随后只刷新到真实快照
提前读取反例临时把刷新移到 init冷启动可复现先读到空数组,用于证明顺序问题;验证后还原
JSON 异常调试构造非法历史字符串启动页面不崩溃,记录回退空数组
旧记录兼容缺少新增字段的历史启动normalizeRecord() 补默认字段,统计仍正确
写入持久化完成一次练习重启应用新纪录仍存在,统计一致
清空历史先有多条记录清空后重启三个数字归零,不回弹旧数据

建议给 init 开始/结束listRecords 开始/结束records 赋值 临时打印单调序号,而不是只打印“成功”。正确日志顺序应始终是初始化结束在读取开始之前。验证后移除日志,避免把本地存储内容写进正式日志。

故障表:从哪个边界开始排查

症状更可能的根因检查点
冷启动永远是 0,做题后正常读取早于初始化且没有二次刷新aboutToAppear 的 Promise 顺序
当前会话有记录,重启后消失写入或 flush() 失败putString() 的结果与重启回读
有记录但统计分类错误旧字段规范化或 passed 值异常normalizeRecord() 和原始数据
切回首页仍是旧统计页面复用后没有刷新路由返回对应的生命周期入口
初始化失败却显示“暂无记录”失败与空数据共用同一兜底增加显式加载/失败状态
偶发重复初始化多次触发生命周期且首个初始化未完成增加进行中的 Promise 复用或页面级门闩

生命周期时机可参考华为官方的 页面和自定义组件生命周期。官方说明用于确认 aboutToAppearbuild() 的前后关系;本文中的存储名、记录结构和刷新链路均来自当前项目源码。

最终结论:先建立数据入口,再读取,再派生

“先初始化 Preferences 再刷新统计”真正解决的不是一个 API 调用顺序,而是数据可信度问题。存储实例未准备好时,兜底空数组只能代表“暂时读不到”,不能代表“用户没有历史”。

当前应用通过 init().then(refreshData) 保住了因果顺序,再用服务规范化记录、用 @State records 驱动渲染、用 getStats() 从同一快照派生数字。继续加固时,应优先补加载与失败可观测性,仍然不要让页面自己维护一套与持久化脱节的计数。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐