学习应用里的“等级进度”经常被写成一个静态展示页:写几个段位名,再放一条进度条。问题是用户完成练习后,等级页必须能从真实练习结果推导状态:累计答了多少题,当前在哪个段位,距离下一段位还差多少,最高段位时进度条如何处理,列表里哪些段位已达成、哪个是当前、哪些尚未解锁。只靠固定文案,页面很快就会和用户数据脱节。

本文基于句匠源码 D:\huawei\one18-11\entry\src\main\ets\pages\LevelProgressPage.ets,同时复核 PracticePage.ets 中写入 bankProgress 的链路,说明 HarmonyOS 5.0+ ArkTS 页面如何把练习结果转成等级展示。源码里真正作为“经验值”的是累计答题数 finished,不是正确率、积分或服务端经验。正文唯一复核标记:com.jiaweikang.one18

等级进度与升级状态封面

这篇文章会按源码边界解决几个问题:

  • 练习完成后 BankProgress.finished 如何累加。
  • 等级页如何汇总所有题库已答题数作为经验值。
  • TIERS 阈值表如何决定当前段位和下一段位。
  • progressRatio() 如何处理普通段位和最高段位。
  • 段位列表如何区分当前、已达成和未达成状态。

等级进度计算流程

等级页职责结构

一、先说明经验值边界:源码用累计答题数,不用正确率加权

句匠的等级页使用 bankProgress,而不是单独的 levelExp 字段。页面里只读取 finished

@StorageLink('bankProgress') progressList: BankProgress[] = []

private totalAnswered(): number {
  let sum = 0
  for (const p of this.progressList) {
    sum += p.finished
  }
  return sum
}

这意味着当前源码的等级经验规则很明确:用户累计答题越多,段位越高。correct 会被进度系统保存,但等级页没有用正确率或答对题数计算段位。

这个边界必须讲清楚。它适合英语练习应用的轻量激励:鼓励持续练习,而不是把等级变成严肃考试评价。如果产品后续要按正确率、难度、题型权重计算经验,需要新增独立经验计算规则,而不是把 finished 描述成已经支持复杂经验系统。

二、练习结果先写入 bankProgress

等级页本身不修改练习结果。进度来源在 PracticePage.ets。练习结束时,页面会统计本次答题记录:

const correctCount = this.records.filter(r => r.correct).length
this.progressList = UserDataManager.updateProgress(
  this.progressList,
  this.bankId,
  this.records.length,
  correctCount,
  this.chapterId
)

考试模式自动提交时也会走同类逻辑:

const correctCount = QuestionUtils.correctCount(this.records)
const score = QuestionUtils.calcScore(this.records, this.questions.length)
this.progressList = UserDataManager.updateProgress(
  this.progressList,
  this.bankId,
  this.records.length,
  correctCount,
  ''
)

这段链路说明等级页的数据不是凭空生成的。records.length 是本次练习完成题数,correctCount 是答对题数,二者被写入 progressList。等级页后续读取的是同一份 @StorageLink('bankProgress')

三、BankProgress 的数据结构决定了等级页能算什么

librarya 中的 BankProgress 结构包含题库 ID、已答题数、答对题数、最后章节和更新时间:

export interface BankProgress {
  bankId: string
  finished: number
  correct: number
  lastChapterId: string
  updatedAt: string
}

更新方法会按题库累加:

static updateProgress(
  records: BankProgress[],
  bankId: string,
  addFinished: number,
  addCorrect: number,
  chapterId: string
): BankProgress[] {
  const idx = records.findIndex(r => r.bankId === bankId)
  let next: BankProgress[]
  if (idx >= 0) {
    const old = records[idx]
    const updated: BankProgress = {
      bankId,
      finished: old.finished + addFinished,
      correct: old.correct + addCorrect,
      lastChapterId: chapterId,
      updatedAt: nowStr(),
    }
    next = [...records]
    next[idx] = updated
  } else {
    next = [{
      bankId,
      finished: addFinished,
      correct: addCorrect,
      lastChapterId: chapterId,
      updatedAt: nowStr()
    }, ...records]
  }
  UserDataManager.persist(UserDataManager.K_PROGRESS, next)
  return next
}

这里有两个重要工程点:

  • 老题库用新对象替换,避免直接改旧对象后 UI 不刷新。
  • 新题库放到数组开头,进度列表能按最近学习的题库靠前展示。

等级页只汇总 finished,所以它能稳定回答“总共练了多少题”。但它不能从当前结构直接判断“哪一天升级”“某次练习贡献了多少经验”“奖励是否领取”。这些都不是现有源码能力。

四、段位阈值表是等级规则的唯一来源

等级规则在 TIERS 常量里:

interface Tier {
  name: string
  enName: string
  threshold: number
  benefit: string
}

const TIERS: Tier[] = [
  { name: '新手', enName: 'Novice', threshold: 0, benefit: '解锁基础题库与每日打卡' },
  { name: '学徒', enName: 'Apprentice', threshold: 50, benefit: '解锁拼写纠错专题 + 错题本回顾' },
  { name: '学者', enName: 'Scholar', threshold: 200, benefit: '解锁时态训练 + 学习统计图表' },
  { name: '专家', enName: 'Expert', threshold: 500, benefit: '解锁介词搭配大全 + 排行榜进入资格' },
  { name: '大师', enName: 'Master', threshold: 1000, benefit: '解锁全部题库 + 英伦大师徽章' },
  { name: '宗师', enName: 'Grandmaster', threshold: 2500, benefit: '解锁专属皮肤 + 终身成就墙' }
]

阈值表的优势是规则透明:总答题数达到 50 进入学徒,达到 200 进入学者。UI、进度条和列表状态都从同一张表推导,不需要在多个地方重复写数字。

但这里也有边界:benefit 当前用于展示“权益”文案,不代表源码已经实现对应功能的开关判断。比如“解锁专属皮肤”只是段位说明文本,不能把它误写成已有皮肤系统。

五、当前段位用最后一个满足阈值的项

当前段位由 currentTierIndex() 计算:

private currentTierIndex(): number {
  const total = this.totalAnswered()
  let idx = 0
  for (let i = 0; i < TIERS.length; i++) {
    if (total >= TIERS[i].threshold) idx = i
  }
  return idx
}

这段逻辑遍历所有段位,只要累计答题数大于等于阈值,就把当前索引更新为该段位。最终返回的是“最后一个满足条件的段位”。

示例规则如下:

累计答题数命中阈值当前段位
00新手
490新手
500、50学徒
2600、50、200学者
2500全部阈值宗师

这种写法对阈值数组顺序有要求:TIERS 必须按 threshold 从小到大排列。如果后续运营调整段位表,应该先检查阈值顺序,否则高段位可能被低阈值项覆盖。

六、下一段位允许不存在

等级进度页必须处理最高段位。源码通过 nextTier() 返回 Tier | undefined

private nextTier(): Tier | undefined {
  const i = this.currentTierIndex()
  return i < TIERS.length - 1 ? TIERS[i + 1] : undefined
}

这比直接访问 TIERS[current + 1] 更稳。最高段位时没有下一段位,进度卡片会显示“已达最高段位”:

if (this.nextTier()) {
  Text(`距 ${this.nextTier()!.name} 还差 ${Math.max(0, this.nextTier()!.threshold - this.totalAnswered())} 题`)
    .fontSize(Sizes.SMALL_FONT)
    .fontColor(Colors.TEXT_HINT)
} else {
  Text('已达最高段位')
    .fontSize(Sizes.SMALL_FONT)
    .fontColor(Colors.ACCENT)
}

这里的 Math.max(0, ...) 也有保护作用。即使数据刚好超过下一段位阈值,显示差值也不会出现负数。正常情况下当前段位已经更新,不会出现“距下一段位 -20 题”,但 UI 层做一次兜底可以避免异常数据直接暴露给用户。

七、进度条比例必须按当前区间计算

进度条不是总答题数除以最高段位阈值,而是按当前段位到下一段位的区间计算:

private progressRatio(): number {
  const total = this.totalAnswered()
  const cur = TIERS[this.currentTierIndex()]
  const nxt = this.nextTier()
  if (!nxt) return 1
  if (nxt.threshold === cur.threshold) return 1
  return Math.min(1, (total - cur.threshold) / (nxt.threshold - cur.threshold))
}

这段计算解决了三个边界:

  • 最高段位没有下一段位,进度固定为 1。
  • 下一阈值等于当前阈值时避免除以 0。
  • 普通段位用当前区间比例,并通过 Math.min(1, ...) 限制最大值。

例如用户答了 120 题,当前段位是学徒,下一段位是学者:

(120 - 50) / (200 - 50) = 70 / 150 ≈ 0.47

这比 120 / 2500 更符合用户感知。用户不是“距离最高段位还有 95%”,而是“当前段位距离下一段位接近一半”。

八、进度条只负责展示,不写回数据

ProgressCard() 里同时展示总答题数、下一段位差值和进度条:

@Builder
ProgressCard() {
  Column({ space: 12 }) {
    Row() {
      Text(`已答 ${this.totalAnswered()} 题`)
        .fontSize(Sizes.BODY_FONT)
        .fontColor(Colors.TEXT_PRIMARY)
        .fontWeight(FontWeight.Medium)
      Blank()
      if (this.nextTier()) {
        Text(`距 ${this.nextTier()!.name} 还差 ${Math.max(0, this.nextTier()!.threshold - this.totalAnswered())} 题`)
          .fontSize(Sizes.SMALL_FONT)
          .fontColor(Colors.TEXT_HINT)
      } else {
        Text('已达最高段位')
          .fontSize(Sizes.SMALL_FONT)
          .fontColor(Colors.ACCENT)
      }
    }

    Stack({ alignContent: Alignment.Start }) {
      Column()
        .width('100%')
        .height(8)
        .borderRadius(4)
        .backgroundColor(Colors.BACKGROUND_ALT)
      Column()
        .width(`${this.progressRatio() * 100}%`)
        .height(8)
        .borderRadius(4)
        .linearGradient({ angle: 90, colors: [[Colors.PRIMARY, 0], [Colors.ACCENT, 1]] })
    }
  }
}

这张卡片没有任何持久化操作。它是纯展示层:读取 progressList,计算 totalAnswered()progressRatio(),渲染。这样能避免等级页误改练习数据。真正的数据写入只在练习完成时发生。

九、HeroCard 展示当前段位,列表展示全局段位关系

顶部 Hero 卡片直接读取当前段位:

Text(TIERS[this.currentTierIndex()].name)
  .fontSize(26)
  .fontWeight(FontWeight.Bold)
  .fontColor(Color.White)

Text(TIERS[this.currentTierIndex()].enName)
  .fontSize(Sizes.CAPTION_FONT)
  .fontColor('#E6FFFFFF')

列表则遍历所有段位:

ForEach(TIERS, (t: Tier, idx: number) => {
  this.TierRow(t, idx)
}, (t: Tier) => t.name)

这个布局分工比较清楚:Hero 告诉用户“我现在是什么”,TierList 告诉用户“全局阶梯是什么”。如果只显示进度条,用户不知道后面有几个等级;如果只显示列表,当前状态不够突出。

十、TierRow 用索引关系区分三种状态

段位行的状态由 idxcurrentTierIndex() 比较得出:

if (idx === this.currentTierIndex()) {
  Text('当前')
    .fontSize(Sizes.SMALL_FONT)
    .fontColor(Color.White)
    .padding({ left: 8, right: 8, top: 3, bottom: 3 })
    .borderRadius(10)
    .backgroundColor(Colors.PRIMARY)
} else if (idx < this.currentTierIndex()) {
  Text('已达成')
    .fontSize(Sizes.SMALL_FONT)
    .fontColor(Colors.SUCCESS)
} else {
  Text(`${t.threshold} 题`)
    .fontSize(Sizes.SMALL_FONT)
    .fontColor(Colors.TEXT_HINT)
}

背景色也使用同样的判断:

.backgroundColor(idx === this.currentTierIndex()
  ? Colors.PRIMARY_LIGHT
  : Colors.BACKGROUND_ALT)

这里没有保存每个段位的状态数组,状态完全从当前索引推导。只要 currentTierIndex() 正确,所有行都会同步变化。对这种等级阶梯页面,这是最简单且可维护的做法。

十一、为什么正确率没有参与等级

源码的 BankProgress 同时保存 finishedcorrect,但等级页只读取 finished。这不是遗漏,而是当前业务规则的选择。句匠作为学习练习应用,用段位鼓励“持续答题”,而不是直接惩罚错误。

如果要把正确率引入等级,可以考虑三种方案:

方案计算方式风险
按答题数finished 累计用户刷题即可升级,质量不区分
按答对数correct 累计初学者挫败感较强
按加权经验难度、题型、正确率综合需要独立经验流水,当前源码不支持

当前源码采用第一种。文章不能把它写成“根据正确率升级”,因为 LevelProgressPage 没有这样的计算。更准确的说法是:练习结果写入了答题数和正确数,等级页把累计答题数作为升级经验。

十二、底部安全区仍然是等级页的一部分

等级页也是 Scroll 长页,底部有段位列表。源码用同样的安全区模式:

@StorageLink('navigationIndicatorHeightPx')
navigationIndicatorHeightPx: number = 0

private bottomSafePadding(): number {
  return Math.max(
    Sizes.BOTTOM_NAV_MIN_PADDING,
    this.getUIContext().px2vp(this.navigationIndicatorHeightPx)
  )
}

页面末尾加空白:

Blank().height(this.bottomSafePadding())

这个细节对 AppGallery 布局检查很实际。段位列表最后一项如果贴到底部手势区,用户在小屏、横屏或 2in1 小窗口中会难以阅读或触达。等级页虽然不包含复杂输入,但仍然要保证底部内容可见。

十三、按源码做验证

可以用下面的表复核等级页:

验证项输入数据预期
空进度progressList = []当前段位为新手,已答 0 题
单题库进度一个题库 finished = 60当前段位为学徒
多题库汇总两个题库 finished = 30 + 180总答题 210,当前段位为学者
下一段位差值总答题 120距学者还差 80 题
区间进度总答题 120学徒到学者区间约 47%
最高段位总答题 2500 以上显示已达最高段位,进度为 100%
已达成行当前段位之后的行只显示阈值,不显示已达成

如果页面显示异常,先不要查 UI 样式,优先打印或查看 progressList。等级页所有核心展示都依赖它。

常见问题与处理

现象优先排查建议处理
完成练习后等级不变PracticePage 是否调用 updateProgress()练习结束入口必须写入 progressList
总答题数不对progressList 中多个题库是否重复累加bankId 更新旧记录,不要每次新增同一题库
进度条超过 100%progressRatio() 是否使用 Math.min(1, ...)比例计算必须限制上限
最高段位仍显示差值nextTier() 是否返回 undefined最高段位显示“已达最高段位”
当前段位行不高亮currentTierIndex() 计算错误检查 TIERS 是否按阈值升序排列
已答正确率影响升级预期业务规则理解偏差当前源码按 finished,不是按 correct
最后一行被挡住底部未加安全区保留 bottomSafePadding() 和末尾 Blank

小结

句匠等级进度页的核心是“展示推导”,不是“数据生产”。练习页通过 UserDataManager.updateProgress() 把本次答题数和答对数写入 bankProgress;等级页只读取 progressList,把所有 finished 汇总成经验值,再通过 TIERS 阈值表推导当前段位、下一段位差值、进度条比例和列表状态。

这条链路的边界很清楚:它没有独立经验流水、没有按正确率加权、没有服务端等级结算,也没有真正的权益解锁开关。对于本地学习应用,这种实现足够轻、可复核、状态一致。后续如果要做更复杂的升级系统,建议先把经验规则抽成独立服务,再把等级页保持为纯展示层。

Logo

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

更多推荐