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

等级是一张表,不是一个状态机
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 – 4 | 5 | 5 | ic_level_01_scholar |
| 小蒙童 | 5 – 9 | 5 | 10 | ic_level_02_books |
| 小学士 | 10 – 19 | 10 | 20 | ic_level_03_bamboo |
| 小秀才 | 20 – 34 | 15 | 35 | ic_level_04_grad |
| 小举人 | 35 – 54 | 20 | 55 | ic_level_05_lantern |
| 小进士 | 55 – 74 | 20 | 75 | ic_level_06_scroll |
| 小翰林 | 75 – 94 | 20 | 95 | ic_level_07_crown |
| 小诗仙 | 95 – 111 | 17 | — | ic_level_08_poet |
前两级各 5 首,第三级 10 首,第四级 15 首,之后固定 20 首一级。这个节奏对应「新手期给反馈、中期靠习惯、后期靠积累」:新用户学会第 5 首就有一次升级,第 10 首又是一次,成就感密集;到 20 首之后每 20 首一次,111 首总量下刚好够分七段。
maxCount 用 -1 表示最高级没有上界。同一个 -1 在 getNextLevelGap 里又有另一层含义 —— 返回值 -1 表示「没有下一级」。两处 -1 都是「负号即无」,但一个修饰字段、一个是返回值,读代码时不能互相套用。

四个方法,一个循环
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 | 进度 | 等级 |
|---|---|---|---|
| 0 | 0 | 0% | 小书童 |
| 1 | 1 | 25% | 小书童 |
| 2 | 2 | 50% | 小书童 |
| 3 | 3 | 75% | 小书童 |
| 4 | 4 | 100% | 小书童 |
| 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.buildLevelCard | this.userState.masteredCount |
| 升级检测 | 详情页 / 刷卡页 | store.getMasteredPoemIds().length |
| 卡片 | buildProgressCardData | store.getMasteredPoemIds().length |
userState.masteredCount 由 syncStats() 用 getMasteredPoemIds().length 写入,所以前两者在数值上永远相同。页面绑字段(能触发刷新),业务逻辑现取方法(拿到调用那一刻的值),两条路通向同一个数字。
LevelSystem 本身是无状态的纯函数集合,四个方法都只吃一个 number。它不缓存、不持有 UserState、不 import 任何 service —— 这让它可以在页面、卡片、WidgetRefreshService 三处任意调用,也让它成为整个项目里最容易单测的一小块。等级体系的可靠性不来自复杂的存储,而来自「只算不存」这四个字:只要 masteredIds 是对的,等级就一定是对的,中间没有任何需要同步的状态。
更多推荐
所有评论(0)