【句匠|04】HarmonyOS ArkTS 等级进度实战:根据练习结果计算经验值和升级状态
学习应用里的“等级进度”经常被写成一个静态展示页:写几个段位名,再放一条进度条。问题是用户完成练习后,等级页必须能从真实练习结果推导状态:累计答了多少题,当前在哪个段位,距离下一段位还差多少,最高段位时进度条如何处理,列表里哪些段位已达成、哪个是当前、哪些尚未解锁。只靠固定文案,页面很快就会和用户数据脱节。
本文基于句匠源码 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
}
这段逻辑遍历所有段位,只要累计答题数大于等于阈值,就把当前索引更新为该段位。最终返回的是“最后一个满足条件的段位”。
示例规则如下:
| 累计答题数 | 命中阈值 | 当前段位 |
|---|---|---|
| 0 | 0 | 新手 |
| 49 | 0 | 新手 |
| 50 | 0、50 | 学徒 |
| 260 | 0、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 阈值表推导当前段位、下一段位差值、进度条比例和列表状态。
这条链路的边界很清楚:它没有独立经验流水、没有按正确率加权、没有服务端等级结算,也没有真正的权益解锁开关。对于本地学习应用,这种实现足够轻、可复核、状态一致。后续如果要做更复杂的升级系统,建议先把经验规则抽成独立服务,再把等级页保持为纯展示层。
更多推荐



所有评论(0)