☰
【句匠|04】HarmonyOS ArkTS 等级进度实战:根据练习结果计算经验值和升级状态
2026/10/27 18:36:39 网站建设 项目流程

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

本文基于句匠源码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 用索引关系区分三种状态

段位行的状态由idx和currentTierIndex()比较得出:

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同时保存finished和correct,但等级页只读取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阈值表推导当前段位、下一段位差值、进度条比例和列表状态。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询