前言

词库是一份 59518 字节的 poems.json,111 首课本必背诗,按六个年级、上下两册组织。PoemRepository 在启动时把它解成内存里的三份结构:保留原始层级的 gradeGroups、扁平化的 allPoems、按 id 查的 poemMap。三份结构指向同一批数据但对象来源不同,这个差别直接决定了「哪些方法能用在哪些对象上」。

alt text

一次解码与两份产物

load 的方法是十二行,做完的事不少:

async load(context: common.Context): Promise<void> {
  if (this.loaded) {
    return;
  }
  try {
    const buffer: Uint8Array = await context.resourceManager.getRawFileContent('poems.json') as Uint8Array;
    const decoder: util.TextDecoder = util.TextDecoder.create('utf-8');
    const content: string = decoder.decodeToString(buffer);
    const root: PoemsJsonRoot = JSON.parse(content) as PoemsJsonRoot;
    this.gradeGroups = root.grades;
    const flat: Poem[] = [];
    for (let i = 0; i < root.grades.length; i++) {
      const g: PoemGrade = root.grades[i];
      const volumes: Record<string, PoemVolume> = g.volumes;
      const keys: string[] = Object.keys(volumes);
      for (let j = 0; j < keys.length; j++) {
        const vol: PoemVolume = volumes[keys[j]];
        for (let k = 0; k < vol.poems.length; k++) {
          // 把 JSON 原始条目(含可选 translation)规范化为 Poem 对象
          const raw: PoemJsonEntry = vol.poems[k] as PoemJsonEntry;
          const poem: Poem = new Poem(raw.id, raw.grade, raw.title, raw.author, raw.dynasty, raw.content);
          if (raw.translation !== undefined) {
            poem.translation = raw.translation;
          }
          flat.push(poem);
        }
      }
    }
    this.allPoems = flat;
    for (let i = 0; i < flat.length; i++) {
      this.poemMap.set(flat[i].id, flat[i]);
    }
    this.loaded = true;
    hilog.info(DOMAIN, TAG, 'loaded poems count: %{public}d', flat.length);
  } catch (err) {
    hilog.error(DOMAIN, TAG, 'load failed: %{public}s', JSON.stringify(err));
  }
}

getRawFileContent 返回 Uint8Array,中间必须过一次 TextDecoder。59518 字节在这个体量上不需要分片,一次 decodeToString 加一次 JSON.parse 就够了。

try/catch 罩住整个流程,失败时只打日志并把 loaded 留在 false。这比抛出去好 —— 启动页的 await PoemRepository.load(ctx) 会正常 resolve,后续页面拿到空列表,走各自的空状态分支,而不是整个启动链断在第一步。

关键的一行是 this.gradeGroups = root.grades;。它把 JSON.parse 的结果直接挂到实例上,而 root.grades 里的 poems 是普通对象,不是 Poem 实例。后面的三层循环重新 new Poem(...) 出的是另一批对象,放进 allPoems。

所以内存里同时存在两份词对象:

结构内容来源类型带原型方法
gradeGroupsJSON.parse 原样普通对象(类型标注为 PoemGrade[])否
allPoemsnew Poem(...)Poem 实例是
poemMapallPoems 的引用Poem 实例是

这不是笔误,而是「保留原始层级」与「规范化实例」两个目标同时要的代价。gradeGroups 保留了 volumes 这个 Record<string, PoemVolume> 的键序和册名(上册 / 下册),allPoems 提供了扁平遍历和 id 索引。

词库的真实结构

poems.json 顶层有四个字段,total 是 111,meta 与 summary 是元信息,grades 是主体:

年级上册下册合计
一年级6713
二年级7714
三年级9918
四年级9918
五年级11920
六年级111728
合计5358111

册的键是 shang 与 xia(拼音),册名是 上册 与 下册。用拼音做键而不是中文,好处是键可以直接出现在对象字面量里、不必担心编码问题;坏处是「册的显示名」得单独存在 name 字段里,代码里读 vol.name 而不是 key。

单册最多 17 首(六年级下册),最少 6 首(一年级上册)。这个数字决定了刷卡页一次要渲染多少张卡片 —— 最坏情况 17 张。

一首诗的字段:

{"id": 1, "grade": "一年级上册", "title": "咏鹅", "author": "骆宾王", "dynasty": "唐",
 "content": "鹅,鹅,鹅,曲项向天歌。白毛浮绿水,红掌拨清波。",
 "translation": "鹅,鹅,鹅,弯着脖子朝天欢叫。洁白的羽毛浮在碧绿的水面上,红红的脚掌拨动着清澈的水波。"}

id 是 1 到 111 的连续整数,全库唯一。这一点让 poemMap 用 number 做键很自然,也让持久化里存 collectedIds: number[] 比存字符串省一半空间。

grade 字段是「一年级上册」这种整串,包含了年级和册次两部分信息,但不是 grade 数字与 volumeKey 的组合键。这个设计留下的问题在后面的查询函数里会出现。

translation 在 111 条里全部存在,所以「译文缺失」的分支在当前数据上一次都不会触发 —— 那些兜底文案是为了数据裁剪或新增诗集时留的。

Poem 实例比原始对象多了什么

Poem 是这批数据唯一的类:

@ObservedV2
export class Poem {
  id: number = 0;
  grade: string = '';
  title: string = '';
  author: string = '';
  dynasty: string = '';
  content: string = '';
  /** 白话译文(JSON 中可选字段,为空时由 UI 回退到分句提示) */
  translation: string = '';
  constructor(id: number, grade: string, title: string, author: string, dynasty: string, content: string) {
    this.id = id;
    this.grade = grade;
    this.title = title;
    this.author = author;
    this.dynasty = dynasty;
    this.content = content;
  }

七个字段,构造函数只接六个 —— translation 不在参数里。这就是 load 里为什么要分两步构造:

          const poem: Poem = new Poem(raw.id, raw.grade, raw.title, raw.author, raw.dynasty, raw.content);
          if (raw.translation !== undefined) {
            poem.translation = raw.translation;
          }

如果把它并进构造函数,第七个参数只能是可选参数 translation?: string,而可选参数必须排在最后 —— 所有 new Poem(...) 的调用点都会多一个位置的负担,包括 PoemCard 里那个当默认值用的 new Poem(0, '', '', '', '', '')。分成两步之后,构造函数表达的是「六件必填的事」,可选字段由构造后赋值补齐,两种语义各归其位。

类上标了 @ObservedV2,但整个类没有一个 @Trace 字段。V2 的依赖追踪单元是 @Trace 字段,零个 @Trace 意味着这个装饰器在当前代码里不产生任何追踪行为。它会出现在这里,是因为 Poem 是会被放进响应式容器的实体类型;真正驱动界面刷新的是容器整体的替换 —— this.poems = repo.getPoemsByGrade(...)、this.collected = list,数组引用一变,读它的组件就重渲染。

类里唯一的方法是分句:

  /** 按句号/问号/感叹号分句 */
  getSentences(): string[] {
    const result: string[] = [];
    const raw: string = this.content;
    let buffer: string = '';
    for (let i = 0; i < raw.length; i++) {
      const ch: string = raw.charAt(i);
      buffer += ch;
      if (ch === '。' || ch === '?' || ch === '!' || ch === ',') {
        // 以逗号结尾的短句并入下一句更适合朗读;这里保留逗号作为分句单位
        if (ch === ',') {
          continue;
        }
        result.push(buffer);
        buffer = '';
      }
    }
    if (buffer.length > 0) {
      result.push(buffer);
    }
    return result;
  }

外层条件把四个标点都算进来了,但逗号进分支后立刻 continue —— 而 buffer += ch 在判断之前已经执行,所以逗号既留在了 buffer 里,又没有触发切分。净效果是只按 。 ? ! 三种终止标点分句。

这个「看起来多写了一个分支」的写法,落点是「哪些标点算句尾」这件事的显式声明:逗号被列进判断里,说明作者考虑过它;continue 说明考虑了之后决定不切。如果只写三个标点,读者无法判断逗号是被忽略了还是被遗漏了。

同样的扫字符逻辑在 PoemCard.buildSentenceLines 里又出现了一遍,那一份没有逗号分支,只认三个终止标点。两处的产出完全一致 —— 一个有冗余判断、一个没有,最终都是按句号问号感叹号切。这种重复在这个项目里只此一处,原因是卡片需要在「没有译文时」把正文拆成多行,而它拿到的只是一个 Poem,方法本身就在模型上,其实可以直接调 getSentences()。

四个 interface 各管一段数据形状:

interface用在哪特征
PoemsJsonRootload 的解析目标只有 total 与 grades
PoemGradegradeGroups 的元素带 grade、gradeName、volumes
PoemVolume每册name + poems
PoemJsonEntry原始条目与 Poem 同字段,但 translation 可选
VolumeRowInfo首页册列表行扁平化,带 volumeKey 与 poemCount

PoemGrade.gradeName 存了「一年级」这样的显示名,但 getVolumesByGrade 只往外抛每册的 name,不抛 gradeName。首页要显示当前年级名时,用的是组件里自己的一份硬编码数组:

  private gradeNames: string[] = ['一年级', '二年级', '三年级', '四年级', '五年级', '六年级'];

数据里有、UI 里又存一份。原因是 gradeName 只在「遍历年级分组」时才有意义,而首页拿到的是 selectedGrade 这个数字,要从数字拿名字,硬编码数组反而比「遍历 gradeGroups 找 g.grade === n 再取 gradeName」更直接 —— 六个年级是固定的,不会因为词库更新而变。

alt text

三条查询路径

PoemRepository 对外暴露的查询分三组,各自走不同的数据结构。

按册取诗走 gradeGroups:

getVolumesByGrade(grade: number): VolumeRowInfo[] {
  const result: VolumeRowInfo[] = [];
  for (let i = 0; i < this.gradeGroups.length; i++) {
    const g: PoemGrade = this.gradeGroups[i];
    if (g.grade === grade) {
      const keys: string[] = Object.keys(g.volumes);
      for (let j = 0; j < keys.length; j++) {
        const key: string = keys[j];
        const vol: PoemVolume = g.volumes[key];
        result.push({
          volumeKey: key,
          volumeName: vol.name,
          poems: vol.poems,
          poemCount: vol.poems.length
        });
      }
      break;
    }
  }
  return result;
}

g.grade === grade 是数字比较,能精确命中。Object.keys(g.volumes) 返回插入顺序 —— JSON.parse 保留对象的键序,所以 ['shang', 'xia'] 的顺序就是上下册的顺序,不会出现「下册排在上册前面」。

break 在命中后立刻跳出外层循环。六个年级里只有一个匹配,没有它就会白跑剩下几个。

VolumeRowInfo 是给 UI 用的扁平结构:

export interface VolumeRowInfo {
  volumeKey: string;      // 册的 key,例如 "上册" / "下册"
  volumeName: string;     // 册的显示名
  poems: Poem[];          // 该册所有诗
  poemCount: number;      // 诗数
}

poems 直接引用 vol.poems,是原始对象那批。poemCount 单独存一份,是为了 UI 不必写 poems.length —— 这个字段看着冗余,但在 ForEach 的 key 与进度条 total 里被反复读,省掉的是链式属性访问。

按册 + 年级精确取诗是同一套逻辑的简化版:

getPoemsByGradeAndVolume(grade: number, volumeKey: string): Poem[] {
  for (let i = 0; i < this.gradeGroups.length; i++) {
    const g: PoemGrade = this.gradeGroups[i];
    if (g.grade === grade) {
      const vol: PoemVolume | undefined = g.volumes[volumeKey];
      if (vol !== undefined) {
        return vol.poems;
      }
      break;
    }
  }
  return [];
}

同样的数字匹配,同样的命中后 break。找不到册时 vol 是 undefined,返回空数组。

第三条路径是按年级整体取诗:

getPoemsByGrade(grade: number): Poem[] {
  const result: Poem[] = [];
  for (let i = 0; i < this.allPoems.length; i++) {
    const p: Poem = this.allPoems[i];
    if (p.grade.indexOf(grade + '年级') >= 0) {
      result.push(p);
    }
  }
  return result;
}

这里用的是字符串子串匹配:grade + '年级'。grade 是数字,拼出来是 "1年级"、"3年级",而数据里的 p.grade 是 "一年级上册"、"三年级下册" —— 中文数字与阿拉伯数字对不上,indexOf 全部返回 -1。

也就是说,这个函数在现有词库上返回空数组。它不会让程序出错,因为调用方准备了兜底:

    // 如果指定了册 key,只加载该册的诗;否则加载整个年级
    if (volumeKey.length > 0) {
      this.poems = repo.getPoemsByGradeAndVolume(startGrade, volumeKey);
    } else {
      this.poems = repo.getPoemsByGrade(startGrade);
    }
    if (this.poems.length === 0) {
      // 兜底:如果该年级/册没有诗,取全部诗
      this.poems = repo.getAllPoems();
    }

兜底把「空结果」换成了全部 111 首。于是从首页「整本刷」按钮进入刷卡页时,实际看到的是全库 111 首,而不是当前年级的十几首;从册列表点进去时,看到的是该册的 6 到 17 首。两条入口的范围不同,但都能正常翻卡。

要修的话,匹配条件应该改成数字对数字:

const gradeNum: number = parseInt(p.grade, 10);   // 中文数字仍需映射

或者更稳妥 —— 让 getPoemsByGrade 也走 gradeGroups,从 volumes 里直接拼,避开字符串匹配:

const vols: VolumeRowInfo[] = this.getVolumesByGrade(grade);
const result: Poem[] = [];
for (const v of vols) {
  for (const p of v.poems) {
    result.push(p);
  }
}

这条路用的是已经能精确命中的 g.grade === grade,不需要再解析年级名。代价是返回的是原始对象那批,和 getPoemsByGradeAndVolume 一致 —— 反而更统一。

每日一诗的下标算法

getDailyPoem 只用了五行算出「今天是第几首诗」:

getDailyPoem(): Poem | null {
  if (this.allPoems.length === 0) {
    return null;
  }
  const now: Date = new Date();
  const start: Date = new Date(now.getFullYear(), 0, 0);
  const diff: number = now.getTime() - start.getTime();
  const dayOfYear: number = Math.floor(diff / (1000 * 60 * 60 * 24));
  const idx: number = dayOfYear % this.allPoems.length;
  return this.allPoems[idx];
}

new Date(year, 0, 0) 是当年的「第 0 天」,也就是上一年 12 月 31 日的零点。用它做起点,1 月 1 日算出来的 dayOfYear 正好是 1,而不是 0。

Math.floor(diff / 86400000) 把毫秒差折成天数,向下取整保证同一天内返回值不变 —— 从凌晨到深夜,dayOfYear 一直是同一个数字,idx 也就固定。跨过午夜零点,diff 越过下一个 86400000 的整数倍,idx 才推进一格。

% this.allPoems.length 是取模循环。111 首,第 111 天回到第 0 首。1 月 1 日取到 idx = 1(第二首),不是第一首 —— 因为 dayOfYear 从 1 起算。第一首(咏鹅)只在取模结果恰好为 0 的那一天出现。

这个算法依赖本地时间:new Date() 与 new Date(year, 0, 0) 都用设备时区,所以「同一天」是本地自然日。跨时区旅行的设备会在当地午夜换诗,这是符合直觉的行为。

getAllPoems() 返回的是内部数组引用:

getAllPoems(): Poem[] {
  return this.allPoems;
}

调用方拿到后既可以遍历也可以改。目前两处调用(MineTabPage 取总数、刷卡页兜底)都只读,但类型上没拦住。要防住得返回 this.allPoems.slice(),代价是每次复制 111 个引用 —— 对这个体量是可忽略的开销,只是当前没做。

三层结构的内存账

111 首诗,每首 6 个字符串字段加一个数字 id,两个对象副本(原始 + 实例),加上 poemMap 的哈希表。粗算:

部分数量说明
gradeGroups 里的原始诗对象111JSON.parse 产物,带 translation
allPoems 里的 Poem 实例111新增 6 个字段拷贝 + 原型指针
poemMap 条目111键是数字,值是实例引用

字符串本身在两份对象之间是共享引用(new Poem(raw.id, raw.grade, ...) 传的是同一个字符串引用),所以真正翻倍的是对象外壳,不是文本内容。59518 字节的 JSON 文本在解析后大约占 200 到 300 KB,两份对象再加 100 KB 左右。整个词库常驻内存在半兆以内。

这个量级不需要做任何优化。真要说有什么可省的,是 gradeGroups 里那批原始对象 —— 如果不保留原始层级,直接从扁平数组按册过滤,能省掉一半对象。但那样 getVolumesByGrade 就得自己维护册的顺序和册名,失去了「JSON 里怎么写就怎么读」的简单性。

三层结构的价值在于各取所需:gradeGroups 服务「按册分组的 UI」,allPoems 服务「扁平遍历与每日一诗」,poemMap 服务「按 id 反查」。三者共享同一批 Poem 引用(除 gradeGroups 是原始对象),改任何一个实例的字段,另外两处都会看到。

词库这一层到这里就齐了:一次解码、两份产物、三条查询路径。上层拿到的都是现成数组或对象引用,没有任何查询是异步的 —— 这是「启动时一次性加载」换来的最大便利,页面里不需要为「数据还没到」写任何状态。

Logo

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

更多推荐