篮球口袋教练 HarmonyOS 学习应用(02):播放器页面与课程详情解耦

一节篮球课程从“详情”进入“播放”,最容易出现的问题不是跳转本身,而是返回后课程上下文被清空:标题变了、学习进度回到零,或者上一节和下一节指向了别的课程。篮球口袋教练把课程 id 作为两页之间唯一需要传递的业务标识,详情页和播放器各自重新查询课程数据,避免把整张页面状态塞进路由参数。

一、用课程 id 保持上下文,而不是传递页面对象

详情页在用户点击播放时只传入courseId;播放器读取这个 id 后查询课程,并接收可选的startSec作为起播位置。这样播放器的暂停状态、倍速和控制层显示都属于播放器,收藏、简介和章节信息仍属于详情页。

const params: PlayerParams = { courseId: course.id, startSec: 0 } router.pushUrl({ url: Routes.PLAYER, params }) const p = router.getParams() as PlayerParams const course = findCourse(p.courseId)

这种边界有两个好处:页面重建时可以重新得到课程,播放器也不会持有详情页的组件引用。若courseId无法查到课程,页面应显示不可用状态或返回上一级,而不是继续用空对象创建播放器。

状态所属页面为什么这样划分
课程标题、简介、收藏入口课程详情与课程信息和学习入口有关
播放/暂停、倍速、控制层全屏播放器与媒体会话生命周期一致
课程 id、起播秒数路由参数能在页面重建后恢复上下文

二、上一节和下一节为何使用替换式跳转

播放器中的上一节、下一节不是在当前组件里手动改标题,而是先根据当前课程 id 找相邻课程,再替换当前播放器路由。这样新的课程会重新完成资源解析和进度初始化,旧视频的控制状态不会残留到下一节。

const next = nextCourse(course.id) if (next !== undefined) { router.replaceUrl({ url: Routes.PLAYER, params: { courseId: next.id, startSec: 0 } }) } else { AppToast.show('已经是最后一节') }

边界提示也属于交互的一部分:没有上一节或下一节时只给出提示,不构造无效跳转。播放器点击返回时使用router.back(),因此用户会回到原来的课程详情,而不是回到一个脱离上下文的列表页。

三、真实运行回读

本次模拟器验证从课程详情进入独立全屏播放器,界面回读到返回、倍速、进度、上一节、暂停/播放和下一节控件;返回后仍是同一课程详情。这说明“详情—播放器—详情”的动作链保持了课程上下文。

四、实现时要避免的三个误区

第一,不把Course整个对象直接放进路由参数;对象结构变化和页面重建都会放大耦合。第二,不让详情页直接控制播放器内部计时器;媒体状态应在播放器退出时统一释放。第三,不把“返回详情”理解为“恢复所有临时控件状态”:详情页需要恢复的是课程语义和持久化学习状态,不是全屏控制层。

这套拆分让课程详情继续负责“学什么”,播放器专注“怎么播放”。后续增加断点续播或手势控制时,也能各自扩展,而不会让一页成为同时维护课程、导航和媒体资源的巨型状态容器。

为什么路由只传 courseId

详情页需要展示简介、收藏和下载入口,播放器需要处理暂停、倍速和控制层。两页都需要课程信息,但它们不应共享同一个组件对象。使用 courseId 作为稳定边界后,页面重建只会重新查询模型,不会把已经失效的视图字段带到下一次播放。

上一节和下一节的跳转更适合替换播放器路由,而不是在正在运行的播放器里修改标题。替换后,新课程会重新解析本地视频地址、初始化进度读写和媒体控制层;旧课程的计时器、控制层显示不会借尸还魂。

return TXT_PLAY_LOCAL; } return TXT_NO_VIDEO_PLACEHOLDER; } private openPreferredVideo(c: Course): void { if (this.hasPlayableVideo(c)) { const params: PlayerParams = { courseId: c.id, startSec: 0 }; router.pushUrl({ url: Routes.PLAYER, params }); return; } AppToast.show(TXT_NO_VIDEO); } private downloadActionText(): string { if (this.savingVideo) { return TXT_DOWNLOADING_LOCAL; } return this.downloadedVideo ? TXT_DOWNLOADED_LOCAL : TXT_DOWNLOAD_LOCAL; } private refreshDownloadState(c: Course): void { const ctx: common.UIAbilityContext = getContext(this) as common.UIAbilityContext; this.downloadedVideo = CourseVideoService.hasDownloadedVideo(ctx, c.id);

详情页与播放器各自负责什么

异常分支也应停留在路由边界:若参数为空或课程查找失败,播放器显示不可用结果并允许返回,而不是以默认课程继续播放。默认课程看似让页面“有内容”,实际会把错误学习记录写到另一门课。

if (c !== undefined) { this.course = c; this.currentSec = p.startSec ?? 0; this.resolvedVideoSrc = this.resolveVideoSrc(c); this.hasResolvedVideo = this.resolveHasVideo(c); } } this.startTick(); } aboutToDisappear(): void { this.stopTick(); if (this.course !== null) { ProgressService.updatePos(this.course.id, this.currentSec); ProgressService.addSeconds(Math.min(this.currentSec, 1800)); } } build() { Stack({ alignContent: Alignment.Center }) { if (this.course !== null && this.hasVideo() && !this.videoError) { Video({ src: this.resolvedVideoSrc, currentProgressRate: SPEEDS[this.speedIdx], controller: this.videoController }) .width('100%').height('100%') .objectFit(ImageFit.Contain) .autoPlay(true) .controls(false) .loop(false) .onPrepared((e) => { this.videoReady = true; if (e && e.duration !== undefined && e.duration > 0) { this.realDuration = Math.floor(e.duration); } this.stopTick(); this.scheduleAutoHide(); }) .onUpdate((e) => { if (e && e.time !== undefined) { this.currentSec = Math.floor(e.time);
检查对象事实来源页面应表现
正常路径路由只传 courseId 与 startSec;详情和播放器分别按 id 查询课程,媒体控制状态留在播放器页面。显示与真实数据一致的结果
边界条件courseId 无法解析、下一节不存在、旧播放器尚未释放时,不能沿用上一节的页面数据。不给出假成功,保留可恢复入口
回读验证从详情启动播放、切换下一节、返回详情并再次进入,逐次核对课程标题、视频来源和起播位置。跨页面或重进后结果一致

切换上下节怎样避免旧状态残留

验收不是看一次播放按钮亮起即可。需要从不同课程详情进入、连续切换上下节、返回再进,并核对标题、课程 id 和资源解析结果始终一致。这条链路证明的是页面职责分离,而不是某次导航碰巧成功。

static readonly PLAYER: string = 'pages/Player'; static readonly SETTINGS: string = 'pages/Settings'; static readonly ABOUT: string = 'pages/About'; static readonly HISTORY: string = 'pages/History'; static readonly PROGRESS: string = 'pages/Progress'; static readonly DOWNLOADS: string = 'pages/Downloads'; static readonly COMING_SOON: string = 'pages/ComingSoon'; static readonly PRACTICE: string = 'pages/Practice'; static readonly PRACTICE_EDIT: string = 'pages/PracticeEdit'; static readonly ACHIEVEMENT: string = 'pages/Achievement'; static readonly QUIZ: string = 'pages/Quiz'; static readonly QUIZ_RESULT: string = 'pages/QuizResult'; }

上述代码片段来自当前实现的连续调用点:它们分别回答“谁提供事实”“谁消费结果”“异常时在哪里停止”。读者排查同类问题时,应先验证这三个边界,而不是只根据按钮颜色判断业务是否完成。

用返回和切课检验解耦边界

从详情启动播放、切换下一节、返回详情并再次进入,逐次核对课程标题、视频来源和起播位置。

定位这类问题时,第一步应回到实际风险:把详情页对象和播放器控制状态混在路由参数中,会在返回、横竖屏切换或连续切课后出现标题与播放进度错配。。先确认输入是否已经被模型或服务拒绝,再检查页面是否把该结果展示出来;如果先从视觉现象倒推,往往会把偶然残留的控件状态误判成业务完成。

当前实现选择的是:路由只传 courseId 与 startSec;详情和播放器分别按 id 查询课程,媒体控制状态留在播放器页面。。这意味着每个页面不必重复实现同一份判断,却也要求任何新增入口都调用相同的服务或模型;绕过该入口虽然能暂时缩短页面代码,却会让后续回读失去一致性。

需要单独保住的边界是:courseId 无法解析、下一节不存在、旧播放器尚未释放时,不能沿用上一节的页面数据。。边界出现时,页面应当保留原因和下一步操作,而不是把错误状态压成空白或成功提示。读者据此可以区分“没有数据”“资源不可用”和“动作尚未完成”。

把这条边界写进文章还有一个维护价值:当课程内容、页面布局或资源形式变化时,验收仍可以围绕同一个事实来源进行,而不必依赖某张旧截图或某个控件曾经显示过的文字。这样得到的结论能被下一次修改复查。

排查顺序应先确认的事实不应采用的替代做法
输入课程、记录或题目是否有稳定标识从页面文本推断业务对象
服务判断或写入是否经过唯一入口在多个页面复制同一段临时逻辑
回读重进后是否从持久化或模型得到同一结果只凭一次按钮变化判定成功

ArkTS 响应式状态的基础机制可参考 HarmonyOS ArkTS 状态管理文档。这里的重点不是堆叠状态字段,而是让页面在每次进入时都从同一业务事实重新得到可见结果。

当需求继续扩展时,应把新增条件加入现有服务或模型的明确入口,并为该条件补充可观察的回读动作。这样课程内容、页面交互和持久化数据仍可沿同一条链路解释,而不会在不同入口形成互相矛盾的结论。