一节篮球课程从“详情”进入“播放”最容易出现的问题不是跳转本身而是返回后课程上下文被清空标题变了、学习进度回到零或者上一节和下一节指向了别的课程。篮球口袋教练把课程 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 状态管理文档。这里的重点不是堆叠状态字段而是让页面在每次进入时都从同一业务事实重新得到可见结果。当需求继续扩展时应把新增条件加入现有服务或模型的明确入口并为该条件补充可观察的回读动作。这样课程内容、页面交互和持久化数据仍可沿同一条链路解释而不会在不同入口形成互相矛盾的结论。