乒乓球口袋教练 HarmonyOS 学习应用(07):学习进度与成就系统
一、成就不应成为另一份可编辑的用户数据
学习应用里的“已解锁”必须能追溯到具体行为:完成课程、累计学习秒数、连续天数、练习日志或测验结果。若只在成就卡片上把某个布尔值设为 true,用户重启后就无法解释徽章从哪里来,也不能处理编辑日志、删除记录或重复提交。本项目把成就视作对既有事实的计算结果,而不是需要单独保存的页面状态。
const HISTORY_LIMIT: number = 50;
export class ProgressService {
static getMap(): ProgressMap {
return AppStorage.get<ProgressMap>(StorageKeys.LEARN_PROGRESS) ?? {} as ProgressMap;
}
static get(courseId: string): ProgressEntry | undefined {
return ProgressService.getMap()[courseId];
}
static markWatched(courseId: string, lastPos: number): void {
const cur: ProgressMap = ProgressService.getMap();
const next: ProgressMap = {} as ProgressMap;
Object.keys(cur).forEach((k: string) => {
next[k] = cur[k];
});
next[courseId] = { watched: true, lastPos: lastPos, updatedAt: Date.now() } as ProgressEntry;
AppStorage.setOrCreate<ProgressMap>(StorageKeys.LEARN_PROGRESS, next);
PreferencesHelper.get().persist(StorageKeys.LEARN_PROGRESS, next);
ProgressService.markStudied(courseId);
}
二、ProgressService 记录哪些学习事实
ProgressService 管理课程进度图、学习历史和连续学习日期。markStudied 以 courseId 为键更新完成事实,addSeconds 聚合累计时长,touchToday 负责连续日期。每项数据都经由 StorageKeys 进入 AppStorage,页面离开后再进入仍通过服务查询,而不是依赖上一页内存里的圆形进度条。
| 参与者 | 输入 | 输出或约束 |
|---|---|---|
| 模型或配置 | 稳定标识、模式或模块字段 | 给出可追溯的工程事实 |
| 服务或系统能力 | 经过归一化的请求 | 返回明确结果或失败原因 |
| 页面 | 回读后的结果 | 只渲染,不保存第二份事实 |
export class AchievementService {
static checkAll(): string[] {
const unlocked: string[] = [];
// --- streak ---
const streak: number = ProgressService.streak();
if (streak >= 3) unlocked.push('streak_3');
if (streak >= 7) unlocked.push('streak_7');
if (streak >= 14) unlocked.push('streak_14');
if (streak >= 30) unlocked.push('streak_30');
// --- study ---
const studied: string[] = AppStorage.get<string[]>(StorageKeys.STUDIED) ?? [];
if (studied.length >= 1) unlocked.push('study_1');
if (studied.length >= 5) unlocked.push('study_5');
if (studied.length >= 10) unlocked.push('study_10');
const seconds: number = AppStorage.get<number>(StorageKeys.LEARN_SECONDS) ?? 0;
if (seconds >= 3600) unlocked.push('time_1h');
if (seconds >= 18000) unlocked.push('time_5h');
// --- practice ---
三、AchievementService 怎样组合跨模块条件
AchievementService.checkAll 同时读取 ProgressService、PracticeLogService 和 QuizService 的结果,并用成就定义里的条件决定返回哪些 id。连续三天、完成课程和完成测验是不同条件,不能用总点击次数替代。这个汇总层让规则集中,但没有把日志或测验复制一份到成就页。
@State unlocked: string[] = [];
aboutToAppear(): void {
this.unlocked = AchievementService.checkAll();
}
build() {
Column() {
TopBar({ title: '我的成就' });
Scroll() {
Column({ space: AppSizes.s4 }) {
this.summaryCard();
ForEach(CATEGORIES, (cat: AchievementCategory) => {
this.categorySection(cat);
}, (cat: AchievementCategory) => cat);
Column().height(AppSizes.s8);
}
.width('100%')
.padding({ left: AppSizes.s4, right: AppSizes.s4, top: AppSizes.s2 });
}
.layoutWeight(1)
.scrollBar(BarState.Off);
四、个人页与成就页为何都要重新查询
个人页展示已解锁数量,成就页展示按类别分组的卡片;两个页面都在显示时调用 checkAll。这样在课程详情完成一课、在练习页新增日志后,用户从任一入口回到个人页都能看到同一数字。若写入失败,卡片应保持未解锁而不是预先点亮,避免产生无法重现的成功。
| 情况 | 容易出现的错误 | 本文采用的处理 |
|---|---|---|
| 数据或配置缺项 | 伪造默认成功状态 | 停在可解释的失败或空态 |
| 页面重进 | 使用上一页残留对象 | 从模型、服务或系统重新回读 |
| 重复动作 | 再写一遍相同业务事实 | 由稳定入口或回调收敛 |
aboutToAppear(): void {
this.achieveCount = AchievementService.checkAll().length;
}
build() {
Column() {
Row() {
Text('我的')
.fontSize(AppText.fsTitle)
.fontWeight(AppText.fwSemi)
.fontColor(this.palette.textPrimary)
.layoutWeight(1);
}
.width('100%').height(AppSizes.topBar)
.padding({ left: AppSizes.s4, right: AppSizes.s4 });
Scroll() {
Column({ space: AppSizes.s4 }) {
this.userCard();
this.statsCard();
this.menuList();
Column().height(AppSizes.s8);
五、解锁与重进的验证顺序
验收可先完成一门课程并重新进入个人页,观察课程完成数变化;再写入一条练习日志、完成一次测验,打开成就页检查对应类别;最后强制停止并再次启动,比较连续天数、累计时长和解锁数量。配图用于回读成就界面,真正的结论仍需由主题动作后的服务结果支持。
| 验收阶段 | 实际动作 | 回读重点 |
|---|---|---|
| 前置确认 | 启动正确 bundle 或打开目标页 | 标题、入口与模块身份 |
| 主题操作 | 执行搜索、切换、完成或跳转 | 服务/系统返回的结果 |
| 重进检查 | 返回、重启或切换范围后再进入 | 事实没有依赖旧页面残留 |
六、实现边界与维护顺序
课程完成、累计时长、连续学习、练习日志和测验结果各自保留事实来源,成就页只汇总条件,不制造第二本统计账。 新增需求时应先补齐模型、配置或服务合同,再调整页面入口;把同一个判断复制到多个组件,短期看似方便,后续会使结果无法回读。ArkTS 状态管理的基础机制可参考 HarmonyOS 官方文档。
七、继续扩展时的约束
课程完成与学习时长并不是同一维度。一门课程可以被标记完成却只有很短的停留时间,播放器也可能累计时长但用户尚未完成关键动作。成就规则读取不同事实时,必须明确各自阈值,避免把看过页面误当成掌握动作。
统计显示的数量应由服务计算,页面不对数组长度做额外加减。特别是在练习日志编辑、测验重复进入和课程重新学习后,手工维护显示值最容易把历史与本次操作混在一起。服务返回的结果是唯一输入,个人页、进度页和成就页就能共享相同结论。
若未来增加云同步,应同步原始进度、日志和测验结果,再在本地或服务端重新计算成就。直接同步解锁卡片虽然实现快,却无法处理规则升级、重复数据和跨端冲突,也失去解释每一枚徽章来源的能力。
进度服务需要防止哪些重复写入
课程详情、播放器和首页都可能触发学习动作,所以服务必须让相同 courseId 的完成状态保持幂等。重复点击完成不应把已学课程数加两次;相同日期的 touchToday 也不应把连续天数加两次。把防重复放在服务内,比要求每个页面都记住上次点击更可靠。
成就规则升级后的读取方式
当“完成三门课程”改为“完成五门课程”时,页面只需再次调用 checkAll 即可得到新结果。规则本身和原始学习事实分离,历史数据不用迁移卡片状态。测试应同时覆盖恰好达到阈值、低于阈值和超过阈值三种情况,避免边界比较符号写反。

更多推荐

所有评论(0)