一、成就不应成为另一份可编辑的用户数据

学习应用里的“已解锁”必须能追溯到具体行为:完成课程、累计学习秒数、连续天数、练习日志或测验结果。若只在成就卡片上把某个布尔值设为 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 即可得到新结果。规则本身和原始学习事实分离,历史数据不用迁移卡片状态。测试应同时覆盖恰好达到阈值、低于阈值和超过阈值三种情况,避免边界比较符号写反。

学习动作后的个人页与成就回读

Logo

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

更多推荐