前言

「诗境」把全部用户数据压进一张 Preferences 表、八个键,每次写操作把八个键全量刷一遍。这个看起来很粗暴的策略配合一个 readOnly 开关,恰好解决了元服务最麻烦的一类问题:桌面卡片在另一个进程里也要读同一份数据,而它绝不能把旧快照写回去。

alt text

一张表,八个键

键名全部提成常量,集中在文件头:

const STORE_NAME: string = 'cijing_store';
const KEY_GRADE: string = 'selectedGrade';
const KEY_COLLECTED: string = 'collectedIds';
const KEY_MASTERED: string = 'masteredIds';
const KEY_READ_COUNTS: string = 'readCounts';
const KEY_FIRST_USE: string = 'firstUseDate';
const KEY_LAST_ACTIVE: string = 'lastActiveDate';
const KEY_STREAK: string = 'streakDays';
const KEY_LAST_LEVEL: string = 'lastLevelIndex';

八项覆盖了这个应用的全部持久状态,按语义可以分成三组:

分组键值类型默认值含义
用户选择selectedGradenumber0当前年级,0 表示未选
学习记录collectedIdsJSON 数字数组'[]'收藏的诗 id
学习记录masteredIdsJSON 数字数组'[]'已学会的诗 id
学习记录readCountsJSON 对象'{}'每首诗的阅读次数,键是 id
打卡时间firstUseDatestring''首次使用日期 YYYY-MM-DD
打卡时间lastActiveDatestring''最后一次活跃日期
打卡时间streakDaysnumber0连续打卡天数
等级标记lastLevelIndexnumber-1上次已知的等级索引

只有一张表,没有按业务拆成「设置表」「进度表」「打卡表」。理由不是懒,而是跨表就没有原子性:如果收藏和已学会分在两张表,卡片的两个数字分别来自两次读,中间被应用进程写一次,卡片就会显示「收藏 12 / 已学会 3」这种自相矛盾的组合。一张表加一次 flush(),至少保证同一时刻落盘的是一份自洽的快照。

lastLevelIndex 的默认值 -1 值得单独说。等级索引从 0 开始,如果用 0 当默认值,用户第一次学会第一首诗时升级检测会是:

      const lastIdx: number = store.userState.lastLevelIndex;
      if (newIdx > lastIdx) {
        this.newLevel = LevelSystem.getLevel(count);
        await store.markLevel(newIdx);
      }

newIdx 算出来是 0,lastIdx 也是 0,0 > 0 为假,第一级升级弹窗永远不会出现。用 -1 表示「从未记录过」,0 > -1 成立,第一次升级就能正常弹。这个 -1 和「合法值 0」的区别,是 loadAll 里唯一一个不能照抄 UserState 字段默认值的键 —— 不过它恰好也和字段默认值一致,所以两边都没写错。

loadAll:把字符串还原成业务类型

  private async loadAll(): Promise<void> {
    if (this.store === null) {
      return;
    }
    const u: UserState = this.userState;
    u.selectedGrade = await this.store.get(KEY_GRADE, 0) as number;
    const collectedStr: string = await this.store.get(KEY_COLLECTED, '[]') as string;
    u.collectedIds = this.parseNumberArray(collectedStr);
    const masteredStr: string = await this.store.get(KEY_MASTERED, '[]') as string;
    u.masteredIds = this.parseNumberArray(masteredStr);
    u.firstUseDate = await this.store.get(KEY_FIRST_USE, '') as string;
    u.lastActiveDate = await this.store.get(KEY_LAST_ACTIVE, '') as string;
    u.streakDays = await this.store.get(KEY_STREAK, 0) as number;
    u.lastLevelIndex = await this.store.get(KEY_LAST_LEVEL, -1) as number;
    // readCounts 是 JSON: {"1":3,"2":1}
    const rcStr: string = await this.store.get(KEY_READ_COUNTS, '{}') as string;
    try {
      const obj: Record<string, number> = JSON.parse(rcStr) as Record<string, number>;
      const result: Record<number, number> = {};
      const keys: string[] = Object.keys(obj);
      for (let i = 0; i < keys.length; i++) {
        const k: number = parseInt(keys[i], 10);
        result[k] = obj[keys[i]];
      }
      u.readCounts = result;
    } catch (err) {
      u.readCounts = {};
    }
  }

九次 get 串行 await。Preferences 的 get 每次都是异步的,这里没有并发写法(Promise.all),因为九次调用的总耗时在毫秒级,串行写出来是一行一个键、逐行对应,比并发版本更容易核对「哪个键有没有读」。

严格类型语言里 store.get 返回的是联合类型,需要 as 断言成实际类型。断言的正确性完全依赖写入侧一致 —— 写 number 读 number、写 string 读 string。所以这八个键的值类型必须当成一份契约维护,改写入类型就得同步改读取的断言。

readCounts 是唯一需要形状转换的键。内存里它的类型是 Record<number, number>:

  @Trace readCounts: Record<number, number> = {};

但 JSON 的键只能是字符串,JSON.stringify({1: 3}) 得到的是 {"1":3},JSON.parse 回来的键也是字符串 "1"。所以中间过一层 Record<string, number>,再用 parseInt 把键转回数字。这个转换不是多余的:直接把 Record<string, number> 赋给 Record<number, number> 在 ArkTS 的严格检查下过不了,而在运行时用字符串键去索引 Record<number, number> 虽然能取到值,Object.keys 的结果却依然是字符串数组,下游所有 parseInt 都得再写一遍。

parseNumberArray 是数组键的解析入口:

  private parseNumberArray(s: string): number[] {
    try {
      const arr: number[] = JSON.parse(s) as number[];
      return arr;
    } catch (err) {
      return [];
    }
  }

两个数组键共用它。解析失败返回空数组 —— 收藏和已学会都是「丢了就丢了、重来一遍成本很低」的数据,降级成空比让整个 init 抛错好。readCounts 的解析没有抽成方法,因为它比数组多一步键类型转换,硬塞进一个通用方法里反而要多写泛型或联合类型。

persist:八次 put 一次 flush

  async persist(): Promise<void> {
    if (this.store === null) {
      return;
    }
    const u: UserState = this.userState;
    await this.store.put(KEY_GRADE, u.selectedGrade);
    await this.store.put(KEY_COLLECTED, JSON.stringify(u.collectedIds));
    await this.store.put(KEY_MASTERED, JSON.stringify(u.masteredIds));
    await this.store.put(KEY_READ_COUNTS, JSON.stringify(u.readCounts));
    await this.store.put(KEY_FIRST_USE, u.firstUseDate);
    await this.store.put(KEY_LAST_ACTIVE, u.lastActiveDate);
    await this.store.put(KEY_STREAK, u.streakDays);
    await this.store.put(KEY_LAST_LEVEL, u.lastLevelIndex);
    await this.store.flush();
    hilog.info(DOMAIN, TAG, 'persist ok');
  }

这段代码有三个刻意的决定。

第一个是全量写。任何一次写操作 —— 收藏一首诗、切一个年级、记一次阅读 —— 都走这一个方法,八个键一个不漏。为什么要全量?因为 UserState 里八个持久字段和八个键是一一对应的,写「哪些键脏了」需要维护一个脏标记集合,八个字段要在二十多处赋值点手动登记,漏一处就是静默的数据丢失。全量写完全不关心调用方改了哪个字段,只要 userState 是最新的,落盘结果就一定正确。

代价是八次 put 加一次 flush 的固定开销。Preferences 的 put 写的是内存里的实例副本,flush 才真正落盘,所以八次 put 的代价接近八次内存赋值,真正有 I/O 成本的只有最后一次 flush。全量写在 I/O 层面并不比写一个键贵多少。

第二个是先 put 后 flush,且 flush 只调一次。如果每个键后面跟一次 flush,就是八次磁盘写入,而全量写场景下八次中间态没有任何意义 —— 读者要么看到旧快照,要么看到新快照,不存在「只更新了一半」的合法状态。

第三个是没有 try/catch。整个文件里 loadAll、parseNumberArray、getFormInfoMap 这些读路径全都包了 try/catch,唯独 persist 一个都没有。这是有意的分工:读失败可以降级成空数据,界面照样能用;写失败不能假装成功,异常直接抛给调用方。

写路径上的调用序列是固定的四步:

  async toggleCollect(poemId: number): Promise<void> {
    const u: UserState = this.userState;
    const next: number[] = u.collectedIds.slice();
    const idx: number = next.indexOf(poemId);
    if (idx >= 0) {
      next.splice(idx, 1);
    } else {
      next.push(poemId);
    }
    // 重新赋值整个数组,确保 @Trace/@Monitor 稳定监听到引用变化
    u.collectedIds = next;
    await this.persist();
    this.notifyDataChange();
    this.notifyWidgetChange();
  }

改内存态 → 落盘 → 通知页面 → 通知卡片。persist 在通知之前,意味着页面收到通知时数据已经在磁盘上了。如果顺序反过来,通知触发的 refresh() 从单例读数据是没问题的,但另一条链路上的卡片刷新会走到 buildProgressCardData(),它读的也是单例 —— 两种顺序在功能上都对,把落盘放在最前只是让「通知」这个动作的语义变成「数据已经稳定了」。

如果 persist 抛错,notifyDataChange 和 notifyWidgetChange 都不会执行。此时内存态已经改过,但派生统计没重算、回调总线没触发、卡片没刷新。直接绑定在 @Trace 字段上的 UI 仍会跟着变(collectedIds 被整体替换了),而依赖 statsVersion 与回调总线的部分停在上一次的值。这个组合在观感上不是灾难:磁盘是旧的、列表是旧的一路,只有收藏按钮本身可能已经变了色。

alt text

只读模式:卡片进程的第二次读

StorageService 是单例,但单例是进程内的静态变量:

export class StorageService {
  private static instance: StorageService | null = null;

主应用进程里那个实例和卡片进程里那个实例互不相干,各自持有一份 userState,各自 init 一次。卡片进程(FormExtensionAbility 所在处)也要显示收藏数和已学会数,所以它必须读同一张表:

  /** 加载词库 + 学习进度;词库只需加载一次。StorageService 以只读模式跨进程读取最新数据 */
  private prepareData(): Promise<void> {
    const repo: PoemRepository = PoemRepository.getInstance();
    if (repo.isLoaded()) {
      return StorageService.getInstance().init(this.context, true);
    }
    if (this.preparationPromise === null) {
      this.preparationPromise = repo.load(this.context)
        .then(() => StorageService.getInstance().init(this.context, true))
        .then(() => {
          this.preparationPromise = null;
        });
    }
    return this.preparationPromise;
  }

第二个参数 true 就是只读模式:

  async init(context: common.Context, readOnly: boolean = false): Promise<void> {
    this.readOnly = readOnly;
    if (readOnly) {
      try {
        await preferences.removePreferencesFromCache(context, STORE_NAME);
      } catch (err) {
        hilog.warn(DOMAIN, TAG, 'remove cache failed: %{public}s', JSON.stringify(err));
      }
    }
    this.store = await preferences.getPreferences(context, STORE_NAME);
    await this.loadAll();
    if (!readOnly) {
      await this.updateStreak();
    }
    this.notifyDataChange();
  }

只读模式干了两件事,各有各的必要性。

第一件是读之前先清缓存。preferences.getPreferences 返回的是带缓存的实例:同一进程内对同一个 store 名再次调用,拿到的很可能还是上次打开时那份内存副本,看不到另一个进程之后写入磁盘的内容。卡片进程是先启动的一方 —— 用户把卡片加到桌面时它打开过一次表,几分钟后用户在应用里收藏了三首诗,卡片进程的缓存里还是加卡那天的快照。removePreferencesFromCache 把这份缓存实例摘掉,之后 getPreferences 会重新从磁盘加载,拿到应用进程刚写的内容。

这个动作在 WidgetRefreshService 里也出现了一次,方向相反 —— 卡片进程写、应用进程读:

  /**
   * 读取持久化的 formId → 卡片名映射。
   * 先移除缓存实例再重新打开,确保读到卡片进程最新写入的磁盘内容(跨进程缓存隔离)。
   */
  async getFormInfoMap(context: common.Context): Promise<Record<string, string>> {
    try {
      await preferences.removePreferencesFromCache(context, FORMS_STORE_NAME);
    } catch (err) {
      hilog.warn(DOMAIN, TAG, 'remove forms cache failed: %{public}s', JSON.stringify(err));
    }
    const store: preferences.Preferences = await preferences.getPreferences(context, FORMS_STORE_NAME);
    return this.readFormInfos(store);
  }

规律很清楚:跨进程读的一方要清缓存,写的一方不用。saveFormInfo 和 deleteFormInfo 都在卡片进程里执行,读到的就是本进程刚打开的那份实例,读写同一份缓存,不存在看到别人旧数据的问题。只有「我要看别人写的东西」时才需要摘掉缓存。

第二件是跳过 updateStreak(),这一点比清缓存更关键。updateStreak 内部会调用 persist():

  private async updateStreak(): Promise<void> {
    const today: string = this.todayStr();
    const u: UserState = this.userState;
    if (u.firstUseDate === '') {
      u.firstUseDate = today;
      u.streakDays = 1;
      u.lastActiveDate = today;
      await this.persist();
      return;
    }
    if (u.lastActiveDate === today) {
      return;
    }
    // 计算与上次激活的间隔
    const last: Date = new Date(u.lastActiveDate);
    const now: Date = new Date(today);
    const diff: number = Math.round((now.getTime() - last.getTime()) / (1000 * 60 * 60 * 24));
    if (diff === 1) {
      u.streakDays = u.streakDays + 1;
    } else if (diff > 1) {
      u.streakDays = 1;
    }
    u.lastActiveDate = today;
    await this.persist();
  }

persist 是全量写。如果卡片进程也执行 updateStreak,它会以「卡片进程那份快照」为基础,把八个键全部写回磁盘。假设卡片进程在 08:00 系统刷新时读到了「收藏 5 首」,用户在 09:00 收藏了第 6 首(应用进程写入),10:00 卡片又定时刷新一次 —— 卡片进程这次的 updateStreak 会把内存里那份只有 5 首的 collectedIds 全量写回,第 6 首就没了。

这就是「全量写 + 多进程」组合下最危险的一种后果:一次看不见的回写,把另一进程的新数据整片覆盖。readOnly 的全部意义在于从结构上禁止这种回写 —— 不是靠「记得别调 persist」的纪律,而是让卡片进程这条初始化路径上根本不存在写动作。

顺带解释了一个更小的细节:updateStreak 只在 readOnly === false 时执行,所以「连续打卡天数 +1」这个副作用只发生在应用进程启动时,卡片进程只消费 streakDays 的结果。打卡这件事在语义上属于「用户今天打开了应用」,不属于「桌面卡片刷新了」。

init 末尾统一调 notifyDataChange(),两种模式都调。卡片进程里没有任何组件注册监听器,这次调用会走一遍 syncStats() 重算三个计数、statsVersion 加一、然后 notifyChange() 遍历一个空数组。多做了一点点无用功,换来的是「init 之后 userState 的派生统计一定是准的」这条不变式 —— buildProgressCardData() 虽然自己现取 getMasteredPoemIds().length,不依赖 masteredCount 字段,但保持这个一致性可以避免以后有人图省事直接读字段。

另一张表:widget_forms_store

StorageService 之外,项目还有第二张 Preferences 表,专门存卡片 id 与卡片名的映射:

const FORMS_STORE_NAME: string = 'widget_forms_store';
const KEY_FORM_INFOS: string = 'formInfos';

存的内容是一个 JSON 字符串,形如 {"<formId>": "progressWidget", "<formId2>": "dailyPoemWidget"}。为什么必须落盘?因为 formProvider.updateForm 需要一个 formId,而 formId 是系统在卡片被加桌时分配给卡片进程的,应用进程看不到。卡片加桌时 onAddForm 拿到它,顺手写进这张表:

  async saveFormInfo(context: common.Context, formId: string, formName: string): Promise<void> {
    if (formId.length === 0) {
      return;
    }
    try {
      const store: preferences.Preferences = await preferences.getPreferences(context, FORMS_STORE_NAME);
      const map: Record<string, string> = await this.readFormInfos(store);
      map[formId] = formName.length > 0 ? formName : PROGRESS_FORM_NAME;
      await store.put(KEY_FORM_INFOS, JSON.stringify(map));
      await store.flush();
      hilog.info(DOMAIN, TAG, 'saveFormInfo %{public}s -> %{public}s', formId, map[formId]);
    } catch (err) {
      hilog.error(DOMAIN, TAG, 'saveFormInfo failed: %{public}s', JSON.stringify(err));
    }
  }

两个细节。formId.length === 0 时直接返回 —— onAddForm 里的 formId 是从 want.parameters 里按字符串拼出来的,参数缺失时会得到空串,空 id 存进去只会在后面 updateForm 时白失败一次。formName 为空时兜底成 progressWidget,因为 updateForm 要靠卡片名决定推哪套数据,空名字会让它默认走进度卡,那条兜底逻辑和这里的兜底必须指向同一张卡。

删除时重建对象而不是 delete:

  async deleteFormInfo(context: common.Context, formId: string): Promise<void> {
    try {
      const store: preferences.Preferences = await preferences.getPreferences(context, FORMS_STORE_NAME);
      const map: Record<string, string> = await this.readFormInfos(store);
      if (map[formId] !== undefined) {
        const next: Record<string, string> = {};
        const keys: string[] = Object.keys(map);
        for (let i = 0; i < keys.length; i++) {
          if (keys[i] !== formId) {
            next[keys[i]] = map[keys[i]];
          }
        }
        await store.put(KEY_FORM_INFOS, JSON.stringify(next));
        await store.flush();
      }
    } catch (err) {
      hilog.error(DOMAIN, TAG, 'deleteFormInfo failed: %{public}s', JSON.stringify(err));
    }
  }

不用 delete map[formId],是为了和 StorageService 里「整体替换对象」的写法保持一致 —— 这套代码里所有集合类型的状态更新都是「建新对象、赋新值」,从不原地改。习惯统一之后,读代码时不用判断「这里改的是不是响应式对象」,两种场景都能安心。

map[formId] !== undefined 这个前置判断省掉了一次无意义的落盘:删一张不存在的卡片时不需要写磁盘。

readFormInfos 把解析失败降级成空对象:

  private async readFormInfos(store: preferences.Preferences): Promise<Record<string, string>> {
    const s: string = await store.get(KEY_FORM_INFOS, '{}') as string;
    try {
      return JSON.parse(s) as Record<string, string>;
    } catch (err) {
      return {};
    }
  }

空对象的后果是「下次刷新时找不到任何 formId」,卡片停在上次的数据上,等下一次 onAddForm 或系统定时刷新重新建立映射。比抛错好 —— 抛错会让 refreshAllForms 的兜底分支整体跳过。

两张表的对照

放到一起看,两张表的用法差异是设计出来的:

维度cijing_storewidget_forms_store
键数量81
写入方应用进程卡片进程
读取方应用进程 + 卡片进程(只读)应用进程 + 卡片进程
写策略全量写八键单键写整个映射
读前是否清缓存只在 readOnly 模式清跨进程读时清
失败处理读侧 try/catch,写侧上抛全部 try/catch
数据丢失影响用户学习进度归零卡片停止实时刷新,定时刷新可恢复

最后一行是这两张表分开存的真正理由:它们的数据价值差一个量级。学习进度丢了,用户会直接感受到损失;卡片映射丢了,等下一次系统刷新就自愈了。所以 cijing_store 的写路径不吞异常(宁可让调用方感知),widget_forms_store 的写路径全部吞掉(辅助数据,不值得打断主流程)。

同样的道理体现在缓存清理的粒度上:cijing_store 只在只读模式下清缓存,因为应用进程读写自己的缓存从来不会有问题,多清一次就多一次磁盘加载;widget_forms_store 每次跨进程读都清,因为它读的频率低(只在卡片刷新时),清缓存的代价可以忽略。

clearAll 与「没有版本号」

clearAll 是八个键同时归零的唯一入口:

  async clearAll(): Promise<void> {
    const u: UserState = this.userState;
    u.selectedGrade = 0;
    u.collectedIds = [];
    u.masteredIds = [];
    u.readCounts = {};
    u.streakDays = 0;
    u.firstUseDate = '';
    u.lastActiveDate = '';
    u.lastLevelIndex = -1;
    await this.persist();
    this.notifyDataChange();
    this.notifyWidgetChange();
  }

八行赋值逐一对齐 loadAll 的八个默认值 —— 0、[]、[]、{}、0、''、''、-1。这两处必须同步改:loadAll 的默认值决定「首次运行看到什么」,clearAll 决定「清空后看到什么」,两者不一致就会造出一个「清空后状态和首次运行不同」的中间态。比如 lastLevelIndex 如果在这里被写成 0 而不是 -1,清空数据后第一级升级弹窗就再也不会弹了。

清空 firstUseDate 和 lastActiveDate 的后果是打卡重新开始:下次 init 走到 updateStreak 时,firstUseDate === '' 成立,直接把今天写成第一天、streakDays = 1。清空数据等于重新做一次新用户。

这张表上没有版本号,也没有迁移逻辑。loadAll 里没有任何 if (version < 2) 之类的分支,八个键的读取永远走同一套断言。这意味着「字段类型的变更」只能靠两条兜底:

  • 读侧 try/catch:形状不对就把该键降级成空值(parseNumberArray 返回 []、readCounts 返回 {})。
  • 词库校验口径:getCollectedPoemIds 等三个方法会拿 id 去 repo.getPoemById(id) !== null 过滤,历史遗留的无效 id 会被静默剔除。

两条兜底都不需要版本号,因为它们处理的是「值不可信」而不是「格式已升级」。在这个规模下(八个键、一个开发者、没有历史版本要兼容),加版本号只是给未来加一层自己都不会用的机制。

readCounts 的键转换还有一处副作用:parseInt 失败的键会变成 NaN,result[NaN] = ... 会成功写进去。它不影响后续逻辑,因为 getLearnedPoemIds 遍历时会对每个 id 调 repo.getPoemById(NaN),返回 null,该条被过滤掉。这类「脏数据在读取处被过滤」的处理方式,与三个 getXxxPoemIds 的词库校验是同一套思路 —— 存储层不做严格校验,把校验放在口径统一的那一层。

一句话的取舍

八个键挤在一张表、每次全量写加一次 flush(),换来的是「任何时刻磁盘上都是一份自洽的快照」和「不用维护脏标记」两件事;readOnly 开关配合 removePreferencesFromCache,把卡片进程从写者降级成纯读者,全量写的风险才从「跨进程覆盖」收敛成「跨进程只读」。两者是配套的 —— 缺了只读模式,卡片进程会在某次定时刷新时把用户刚产生的数据整片写回旧值;缺了全量写,那张表也就没有「自洽快照」这个性质可用。

Logo

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

更多推荐