前言

「诗境」的等级没有一个字段存在磁盘上 —— 它由「已学会多少首」实时算出来,只在升级发生的那一刻往磁盘写一个「上次已知的等级索引」用于比较。这套设计让等级永远不可能与学习记录脱节,代价是每次都要重新扫一遍区间表。

alt text

等级是一张表,不是一个状态机

export interface GradeLevel {
  name: string;
  minCount: number;
  maxCount: number;
  badge: string;
  badgeIcon: Resource;
  desc: string;
}

六个字段里,minCount 和 maxCount 定义区间,name 与 desc 是文案,badge 是 emoji(代码里保留但界面未使用),badgeIcon 是 SVG 徽章资源。八条记录写成一个只读数组:

  static readonly LEVELS: GradeLevel[] = [
    { name: '小书童',   minCount: 0,  maxCount: 4,  badge: '🎒', badgeIcon: $r('app.media.ic_level_01_scholar'),      desc: '刚入学堂,开始背诵第一首' },
    { name: '小蒙童',   minCount: 5,  maxCount: 9,  badge: '📚', badgeIcon: $r('app.media.ic_level_02_books'),       desc: '已会五首诗,韵律渐熟' },
    { name: '小学士',   minCount: 10, maxCount: 19, badge: '🎋', badgeIcon: $r('app.media.ic_level_03_bamboo'),       desc: '渐入佳境,能随口成诵' },
    { name: '小秀才',   minCount: 20, maxCount: 34, badge: '🎓', badgeIcon: $r('app.media.ic_level_04_grad'),         desc: '二十首在胸,功底初成' },
    { name: '小举人',   minCount: 35, maxCount: 54, badge: '🏮', badgeIcon: $r('app.media.ic_level_05_lantern'),      desc: '能举一反三,触类旁通' },
    { name: '小进士',   minCount: 55, maxCount: 74, badge: '📜', badgeIcon: $r('app.media.ic_level_06_scroll'),       desc: '诗词功底扎实,出口成章' },
    { name: '小翰林',   minCount: 75, maxCount: 94, badge: '👑', badgeIcon: $r('app.media.ic_level_07_crown'),        desc: '课本必背大部已掌握' },
    { name: '小诗仙',   minCount: 95, maxCount: -1, badge: '✨', badgeIcon: $r('app.media.ic_level_08_poet'),        desc: '111 首必背诗近于全通,称诗仙' }
  ];

maxCount 只出现在这张表里,四个查询方法没有一个读它 —— 除了 getProgress。判断等级只看 minCount,区间上界是给人看的说明。这是刻意的:上界和下界如果都参与判定,两条相邻记录写错一个数字就会出现「5 首落在两个等级里」或「没有任何等级覆盖 12 首」的空洞。只认下界,八条记录的下界严格递增,任何数量都能落到唯一一个等级上。

区间的宽度是递增的:

等级区间本级首数升级门槛(累计)徽章资源
小书童0 – 455ic_level_01_scholar
小蒙童5 – 9510ic_level_02_books
小学士10 – 191020ic_level_03_bamboo
小秀才20 – 341535ic_level_04_grad
小举人35 – 542055ic_level_05_lantern
小进士55 – 742075ic_level_06_scroll
小翰林75 – 942095ic_level_07_crown
小诗仙95 – 11117—ic_level_08_poet

前两级各 5 首,第三级 10 首,第四级 15 首,之后固定 20 首一级。这个节奏对应「新手期给反馈、中期靠习惯、后期靠积累」:新用户学会第 5 首就有一次升级,第 10 首又是一次,成就感密集;到 20 首之后每 20 首一次,111 首总量下刚好够分七段。

maxCount 用 -1 表示最高级没有上界。同一个 -1 在 getNextLevelGap 里又有另一层含义 —— 返回值 -1 表示「没有下一级」。两处 -1 都是「负号即无」,但一个修饰字段、一个是返回值,读代码时不能互相套用。

alt text

四个方法,一个循环

getLevelIndex 是唯一的判定入口:

  static getLevelIndex(masteredCount: number): number {
    let idx: number = 0;
    for (let i = 0; i < LevelSystem.LEVELS.length; i++) {
      if (masteredCount >= LevelSystem.LEVELS[i].minCount) {
        idx = i;
      }
    }
    return idx;
  }

循环里不 break,靠覆盖式赋值。八条记录的下界递增,最后一个满足 count >= minCount 的就是正确等级。用 break 的唯一区别是少走几次循环 —— 八次比较的开销可以忽略,而不 break 让这段代码对「下界没排序」这种错误有容错:顺序乱了它仍会返回「最后一个满足条件的下标」,虽然结果未必正确,但至少不会停在第一个满足的(那才是真正的错,因为第一级下界是 0,永远满足)。

idx 初值 0 而不是 -1,因为 LEVELS[0].minCount 是 0,masteredCount 不可能是负数,第一轮一定赋值。

getLevel 是它的一层包装:

  static getLevel(masteredCount: number): GradeLevel {
    return LevelSystem.LEVELS[LevelSystem.getLevelIndex(masteredCount)];
  }

getNextLevelGap 与 getNextLevelName 用的是同一个循环,只是判定条件反过来找「第一个下界大于当前数量的记录」:

  /** 距离下一等级还差多少首;已是最高等级返回 -1 */
  static getNextLevelGap(masteredCount: number): number {
    for (let i = 0; i < LevelSystem.LEVELS.length; i++) {
      if (masteredCount < LevelSystem.LEVELS[i].minCount) {
        return LevelSystem.LEVELS[i].minCount - masteredCount;
      }
    }
    return -1;
  }
  static getNextLevelName(masteredCount: number): string {
    for (let i = 0; i < LevelSystem.LEVELS.length; i++) {
      if (masteredCount < LevelSystem.LEVELS[i].minCount) {
        return LevelSystem.LEVELS[i].name;
      }
    }
    return '(已达最高)';
  }

这两个方法可以合并成一个返回 { gap, name } 的调用,但拆开的版本更适合调用点 —— 「我的」页和卡片都要同时用,拼接成一句提示:

        Text(LevelSystem.getNextLevelGap(this.userState.masteredCount) >= 0
          ? '再学会 ' + LevelSystem.getNextLevelGap(this.userState.masteredCount) + ' 首升为「' + LevelSystem.getNextLevelName(this.userState.masteredCount) + '」'
          : '已达最高等级「小翰林」')

一行里调了三次 getNextLevelGap + 一次 getNextLevelName,每次都是一趟八轮的循环。四次调用、三十二次比较、两字节字符串拼接 —— 这个量级不值得缓存,但值得知道它的成本在哪。

这行还有个细节:兜底文案硬编码成「小翰林」,而八级表的最高级是「小诗仙」。卡片侧 buildProgressCardData 里对应的兜底写的是「诗仙本仙」:

    const levelTip: string = gap > 0
      ? `再学会 ${gap} 首升级「${LevelSystem.getNextLevelName(masteredCount)}」`
      : '已达最高等级 · 诗仙本仙';

两处兜底都是字符串字面量,不在等级表里,改等级名时它们不会跟着变。表驱动的部分是「升级中」的文案,兜底的部分是散在外面的。这类不一致不会影响功能(最高级的用户看不到「再学会 X 首」,只会看到两处不同的庆祝语),但它是「文案分散」的典型代价:能进表的文案都进了表,兜底那句因为要长成另一个句式,就留在了调用点。

进度:分段线性

  static getProgress(masteredCount: number): number {
    const lv: GradeLevel = LevelSystem.getLevel(masteredCount);
    if (lv.maxCount < 0) {
      return 1;
    }
    const span: number = lv.maxCount - lv.minCount;
    if (span <= 0) {
      return 1;
    }
    const offset: number = masteredCount - lv.minCount;
    const p: number = offset / span;
    return p < 0 ? 0 : (p > 1 ? 1 : p);
  }

三个提前返回覆盖了三种边界:最高级 maxCount < 0 直接满格;span <= 0(万一某级 min 等于 max,单首一级)避免除零;最后用手写 clamp 代替 Math.min/Math.max,p < 0 ? 0 : (p > 1 ? 1 : p) 一次算完。

按第一级算一遍:minCount = 0、maxCount = 4、span = 4。

已学会offset进度等级
000%小书童
1125%小书童
2250%小书童
3375%小书童
44100%小书童
5—0%小蒙童

第 4 首时进度条已经满格,等级名还是「小书童」,第 5 首才跳级并把进度条归零。这是「以本级最后一首为终点」的口径:maxCount 是包含在内的上界,所以 span = max - min 恰好等于「从第一首走到最后一首要走几步」。

另一种口径是「以下一级门槛为终点」,即 offset / (nextMin - min),那样第 4 首是 80%,满格与升级严格同步。两种都在用,项目选了前一种 —— 好处是「本级最后一首」这个状态有明确的视觉表达(满格),坏处是满格之后还要再学一首才升级。对一个正反馈驱动的学习应用,先看到满格、再学一首触发升级弹窗,比「80% 直接跳到新等级」更有节奏感。

进度条本身是两段式拼接,不依赖 Progress 组件:

        Row() {
          Column()
            .width((LevelSystem.getProgress(this.userState.masteredCount) * 100).toFixed(1) + '%')
            .height(8)
            .borderRadius(4)
            .linearGradient({ angle: 90, colors: [[AppColors.GRADIENT_START, 0], [AppColors.PRIMARY, 1]] })
          Column()
            .layoutWeight(1)
            .height(8)
        }
        .width('100%')
        .height(8)
        .backgroundColor('#FFE0B2')
        .borderRadius(4)

外层 Row 铺底色,内层实心段按百分比占宽,剩余空间由第二个 Column 的 layoutWeight(1) 吃掉。百分比要 toFixed(1),因为 width 接受字符串但浮点数直接拼会得到 33.33333333333333% 这种长度不定的值。卡片侧同样的进度用 Math.round(this.levelProgress * 100) 取整 —— 卡片空间小,百分比只显示整数。

升级检测:只在「未学会 → 已学会」那一次

等级虽然随时可算,但「弹窗」是一次性事件,需要知道「刚才发生了什么变化」。判断逻辑写在两个地方,内容完全相同:

  private async toggleMastered(): Promise<void> {
    if (this.currentIndex < 0 || this.currentIndex >= this.poems.length) {
      return;
    }
    const poem: Poem = this.poems[this.currentIndex];
    const store: StorageService = StorageService.getInstance();
    const wasMastered: boolean = store.userState.isMastered(poem.id);
    await store.toggleMastered(poem.id);
    this.syncCardFlags();

    // 升级检测:仅在「未学会 → 已学会」时判断(使用词库校验后的口径,与各页面等级显示一致)
    if (!wasMastered && this.isMastered) {
      const count: number = store.getMasteredPoemIds().length;
      const newIdx: number = LevelSystem.getLevelIndex(count);
      const lastIdx: number = store.userState.lastLevelIndex;
      if (newIdx > lastIdx) {
        this.newLevel = LevelSystem.getLevel(count);
        await store.markLevel(newIdx);
      }
    }
  }

详情页那份只差两个变量名(this.mastered 而不是 this.isMastered,this.poemId 而不是 poem.id):

  async toggleMastered(): Promise<void> {
    const store: StorageService = StorageService.getInstance();
    const wasMastered: boolean = store.userState.isMastered(this.poemId);
    await store.toggleMastered(this.poemId);
    this.syncState();

    // 升级检测:仅在「从未学会 → 已学会」时判断(使用词库校验后的口径,与各页面等级显示一致)
    if (!wasMastered && this.mastered) {
      const count: number = store.getMasteredPoemIds().length;
      const newIdx: number = LevelSystem.getLevelIndex(count);
      const lastIdx: number = store.userState.lastLevelIndex;
      if (newIdx > lastIdx) {
        this.newLevel = LevelSystem.getLevel(count);
        await store.markLevel(newIdx);
      }
    }
  }

三个动作的顺序是有讲究的。

先取旧值,再切换,再判新值。wasMastered 必须在 toggleMastered 之前读,因为它是个不入库的瞬时状态;this.isMastered 在 syncCardFlags() 之后才更新。两次读取夹着一次写操作,中间隔着一次 persist()。这个「前后对比」是本项目里唯一需要知道「操作前状态」的地方 —— 其他所有地方都是「读当前状态、渲染」,只有升级检测关心变化方向。

用 getMasteredPoemIds().length 而不是 masteredIds.length。前者经过词库校验,后者是原始存储。111 首诗全部来自内置词库,两者在正常情况下相等;但如果哪天词库删了某首诗、而用户之前收藏过它并标了已学会,masteredIds 会比 masteredPoemIds 多一个,等级就会因为一首不存在的诗而虚高。升级检测用的是「页面和卡片都在用的那个口径」,所以弹窗里的等级名与随后「我的」页显示的等级一定一致。

用 newIdx > lastIdx 而不是 newIdx === lastIdx + 1。一次 toggleMastered 只会让数量 +1,跨两级需要一次跳 20 首,不可能发生。用 > 是为了容纳「lastLevelIndex 落后于真实等级」的情况:清空数据后 lastLevelIndex 被重置成 -1,用户学会第一首时 count = 1、newIdx = 0,0 > -1 成立,会再弹一次「小书童」升级。用 === 也能走到这里(0 === -1 + 1),但假如某次数据修复把 lastLevelIndex 留在 3、真实等级已经是 6,=== 就永远不成立,用户再也看不到升级弹窗;> 会在下次数量增长时把等级补上。

newLevel 是页面级的 @Local,类型是 GradeLevel | null:

  @Local newLevel: GradeLevel | null = null;

写入的是 LevelSystem.getLevel(count) 返回的整条记录,不是索引也不是名字。弹窗需要 badgeIcon、name、desc 三样东西,索引还要再查一次表,直接传记录最省事 —— LevelUpDialog 的 @Param 就是按这个类型声明的:

@ComponentV2
export struct LevelUpDialog {
  @Param level: GradeLevel | null = null;
  @Event onClose: (() => void) = () => {
  };

  build() {
    if (this.level !== null) {

null 即关闭。整个弹窗没有 visible 之类的布尔开关,靠 if 决定是否挂载子树 —— null 时连蒙层都不存在,不占布局、不参与命中测试。关闭走 @Event onClose,父组件把 newLevel 置回 null:

      LevelUpDialog({
        level: this.newLevel,
        onClose: () => {
          this.newLevel = null;
        }
      })

弹窗内部两个关闭入口(点蒙层、点按钮)都调 onClose(),父组件只认一个信号。这种「子组件不持有可见状态」的写法让弹窗成为一个纯函数式的展示组件:给数据就显、给 null 就消失。

markLevel 是升级链路的最后一环:

  /** 记录当前等级(在检测到升级后调用) */
  async markLevel(levelIdx: number): Promise<void> {
    this.userState.lastLevelIndex = levelIdx;
    await this.persist();
    this.notifyDataChange();
    this.notifyWidgetChange();
  }

它只做一件事:把「已经弹过哪一级」写进磁盘。persist 是全量写,八键一起刷 —— 所以等级标记虽然只是一个数字,落盘成本和一次收藏操作相同。notifyWidgetChange 也要调,因为进度卡上显示的等级名、徽章、进度条都依赖这个状态。

这条链路里唯一可能出错的地方是「弹了但没写成功」和「写了但没弹」。markLevel 在 this.newLevel = ... 之后 await,如果 persist 抛错,弹窗已经挂上了(newLevel 已赋值),但磁盘没写 —— 下次再学会一首还会弹同一级。这个顺序是刻意的:宁可多弹一次,不要漏弹。反过来如果先 markLevel 再赋值 newLevel,persist 失败就变成「没弹窗也没记录」,用户永远错过这次升级。

徽章为什么在卡片里又写了一遍

页面侧的徽章直接从表里取 Resource:

        Image(LevelSystem.getLevel(this.userState.masteredCount).badgeIcon)
          .width(40)
          .height(40)

卡片侧不行。卡片的数据来自 FormBindingData,它只接受可序列化的基本类型 —— Resource 是资源引用对象,跨进程传不过去。所以 buildProgressCardData 只传一个数字:

    const data: ProgressCardData = {
      levelIdx: levelIdx,
      levelName: LevelSystem.getLevel(masteredCount).name,
      ...

卡片收到 levelIdx 之后自己查本地数组:

const PROGRESS_BADGE_ICONS: Resource[] = [
  $r('app.media.ic_level_01_scholar'),
  $r('app.media.ic_level_02_books'),
  $r('app.media.ic_level_03_bamboo'),
  $r('app.media.ic_level_04_grad'),
  $r('app.media.ic_level_05_lantern'),
  $r('app.media.ic_level_06_scroll'),
  $r('app.media.ic_level_07_crown'),
  $r('app.media.ic_level_08_poet')
];
  private badgeIcon(): Resource {
    if (this.levelIdx >= 0 && this.levelIdx < PROGRESS_BADGE_ICONS.length) {
      return PROGRESS_BADGE_ICONS[this.levelIdx];
    }
    return PROGRESS_BADGE_ICONS[0];
  }

八个资源在两个文件里各写一遍,靠「数组下标 == 等级索引」这条隐式约定对齐。约定没有类型系统保护 —— 如果以后在 LEVELS 中间插一级,卡片数组不同步就会让所有更高等级错配一个徽章。越界检查只挡住「数组比等级表短」这一种情况,挡不住「顺序错」。

levelName 倒是跟着数据传过来了,所以卡片上的文字一定正确,只有图标可能错。这个分布挺有意思:能传的传值,不能传的传索引 + 本地映射。索引方案的容错上限就是「序号对齐」,而这在单次版本内是稳定的。

等级与统计的同一口径

等级的计算输入只有一处:getMasteredPoemIds().length,三个地方都这么写:

调用点位置取数方式
等级卡 / 进度提示MineTabPage.buildLevelCardthis.userState.masteredCount
升级检测详情页 / 刷卡页store.getMasteredPoemIds().length
卡片buildProgressCardDatastore.getMasteredPoemIds().length

userState.masteredCount 由 syncStats() 用 getMasteredPoemIds().length 写入,所以前两者在数值上永远相同。页面绑字段(能触发刷新),业务逻辑现取方法(拿到调用那一刻的值),两条路通向同一个数字。

LevelSystem 本身是无状态的纯函数集合,四个方法都只吃一个 number。它不缓存、不持有 UserState、不 import 任何 service —— 这让它可以在页面、卡片、WidgetRefreshService 三处任意调用,也让它成为整个项目里最容易单测的一小块。等级体系的可靠性不来自复杂的存储,而来自「只算不存」这四个字:只要 masteredIds 是对的,等级就一定是对的,中间没有任何需要同步的状态。

Logo

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

更多推荐