羽毛球教程 HarmonyOS 学习应用(04):学习进度条与完成状态刷新 一、进度不是一根孤立的进度条用户在课程详情停留或播放视频后至少有四处结果会变化课程卡片显示“已学”首页模块完成数增加学习进度页的分子和进度条变化累计时长与学习日历也要更新。若每个页面各存一份计数状态很快就会不一致。应用把课程级进度、已学 id 集合、浏览历史、学习天数和累计秒数放进统一服务。页面通过StorageLink读取共享状态写入后由同一数据源驱动刷新。二、课程进度保留完成状态与播放位置课程级记录使用courseId作为映射键同时保存是否看完、最后播放位置和更新时间。export interface ProgressEntry { watched: boolean lastPos: number updatedAt: number } export type ProgressMap Recordstring, ProgressEntry static getMap(): ProgressMap { return AppStorage.getProgressMap(StorageKeys.LEARN_PROGRESS) ?? {} } static get(courseId: string): ProgressEntry | undefined { return ProgressService.getMap()[courseId] }watched用于完成状态lastPos支持下次继续updatedAt可用于冲突判断或最近学习排序。映射结构让按课程 id 查询保持直接不需要每次扫描整个数组。三、更新映射时创建新对象写入播放位置或完成状态时服务先复制旧映射再替换目标课程条目最后同时写回 AppStorage 和 Preferences。static markWatched(courseId: string, lastPos: number): void { const current: ProgressMap ProgressService.getMap() const next: ProgressMap {} as ProgressMap Object.keys(current).forEach((key: string) { next[key] current[key] }) next[courseId] { watched: true, lastPos, updatedAt: Date.now() } AppStorage.setOrCreateProgressMap(StorageKeys.LEARN_PROGRESS, next) PreferencesHelper.get().persist(StorageKeys.LEARN_PROGRESS, next) ProgressService.markStudied(courseId) }创建新对象是刷新链路的重要部分。页面观察到引用变化后才会重新计算依赖该映射的显示持久化则保证应用重启后仍能恢复。对象引用与直接绑定组件的刷新边界可参照华为官方 State 状态管理说明。对集合做不可变替换也让刷新触发点更容易审查。四、已学集合只追加一次页面卡片和模块完成数只需要知道课程是否学过因此另有轻量的studied_coursesid 集合。写入前先去重再复制追加。static markStudied(courseId: string): void { const current: string[] AppStorage.getstring[](StorageKeys.STUDIED) ?? [] if (current.indexOf(courseId) 0) { return } const next: string[] current.slice() next.push(courseId) AppStorage.setOrCreatestring[](StorageKeys.STUDIED, next) PreferencesHelper.get().persist(StorageKeys.STUDIED, next) }重复进入同一课程不会重复增加完成数。markWatched最终也调用markStudied使视频完成和普通学习入口汇聚到同一个去重规则。五、详情页用停留阈值触发自动完成详情页进入时记录时间并启动计时器。达到 3 秒且课程尚未完成时标记为已学离开时累计本次停留秒数并限制单次最多计入 1800 秒。private markStudiedIfReady(sec: number, showToast: boolean): void { if ( this.course null || sec 3 || ProgressService.isStudied(this.course.id) ) { return } ProgressService.markStudied(this.course.id) if (showToast) { AppToast.show(已自动标记为已学) } }阈值防止用户误触进入又立即返回就被计为完成上限则防止页面长时间挂起把累计学习时长无限放大。自动完成仍是产品规则若课程需要严格考核可改为视频百分比、段落阅读确认或测验通过。六、各页面只做进度投影首页按模块统计已学课程分类页显示单课状态进度页汇总总数、天数、连续学习和模块完成度。它们都不自行写计数。页面读取的数据显示结果首页studied_courses、模块课程数2/6 已学分类页当前课程 id 是否在集合中已学/未学标签进度页已学集合、天数、秒数总览与模块进度条课程详情单课进度映射已完成与播放位置进度条宽度由completed / total计算。分母为零时必须返回 0避免NaN%破坏布局。七、学习日历与连续天数的边界进入学习页时调用touchToday()当天只写入一次日期键。连续天数从今天向前逐日检查若今天尚无记录则从昨天开始避免一天刚开始就把原有连续记录判为中断。static touchToday(): void { const today: string dayKey() const current: string[] AppStorage.getstring[](StorageKeys.LEARN_DAYS) ?? [] if (current.indexOf(today) 0) return const next: string[] current.slice() next.push(today) AppStorage.setOrCreatestring[](StorageKeys.LEARN_DAYS, next) PreferencesHelper.get().persist(StorageKeys.LEARN_DAYS, next) }日期键只表示本地日历日。跨时区、手动改时间或云同步会引入额外冲突当前实现没有把这些场景包装成已解决能力。八、失败路径与刷新验收边界期望行为观察点重复学习同一课完成数不重复增加分子保持不变详情停留不足 3 秒不自动完成卡片仍为未学播放位置更新保留原完成状态继续播放位置正确持久化为空使用空映射和空集合进度页显示 0模块没有课程进度为 0不出现除零错误运行界面已经显示累计时长、已学章节、学习天数、周历和各模块完成条。完整验收还需要完成一门未学课程后返回首页再打开分类页与进度页三处结果都应在不重启应用的情况下同步变化重启后结果仍应保留。还应验证更新时间与播放位置的组合先播放到中段但不完成重进详情应保留位置且卡片仍可显示未完成随后完成课程watched变为 true 但位置不应回退。浏览同一课程多次时历史列表只保留一个 id 并移动到最前连续浏览超过上限后只保留最近 50 条。把设备日期向前或向后调整属于异常环境只记录现象不把本地连续天数当作服务端可信统计。九、进度刷新依赖一个事实源进度服务不保存首页计数或进度条宽度只保存课程级事实和最小聚合集合。页面显示值全部由这些事实重新计算。这样新增模块、调整课程数量或切换设备形态时不需要追着修改多份缓存计数也更容易定位异常数据。如果后续接入账号与云端同步冲突合并必须以课程 id 和更新时间为基础完成状态宜采用“任一端完成即完成”播放位置可选更新时间较新的记录学习秒数不能简单取最大或求和否则离线重复上报会失真。同步完成后仍然一次性替换本地映射和集合让现有页面沿同一观察链路刷新。