篮球口袋教练 HarmonyOS 学习应用(03):离线下载入口与课程缓存状态
学习应用的“下载”不能只是一枚按钮。用户真正关心的是这节课能不能保存、保存到了哪里、重启后还能不能找到。篮球口袋教练将可下载条件、文件写入和下载状态回读收敛到课程视频服务详情页只根据服务结果刷新文案与操作入口。一、下载资格先由资源条件决定课程能否保存不由页面猜测而是同时检查课程是否存在、是否有视频资源以及对应资源是否已随应用打包。只有这三个条件成立详情页才展示可执行的保存入口。static canDownload(context: common.UIAbilityContext, courseId: string): boolean { const asset findCourseVideoAsset(courseId) return asset ! undefined CourseVideoService.hasPackagedVideo(context, courseId) asset.url.length 0 }这一步先拦住“课程存在但没有视频”的情形。对于不支持下载的条目页面应该保留明确说明而不是让用户点击后才遇到无意义失败。二、状态来自文件存在性而不是按钮文字保存后的状态判断依赖本地目标文件是否真实存在。服务先按课程 id 得到稳定文件名和目标位置再通过文件存在性生成下载管理条目列表、详情和下载管理页共享这份判断结果。static hasDownloadedVideo(context: common.UIAbilityContext, courseId: string): boolean { const path CourseVideoService.getDownloadedVideoPath(context, courseId) return path.length 0 CourseVideoService.exists(path) }场景服务端判断页面行为课程没有可用视频不允许保存不显示可执行下载操作文件尚未写入未下载保留保存入口文件已存在已下载显示本地可用状态文件被删除未下载回到可重新保存状态三、运行验证覆盖了什么模拟器中用户从课程详情的保存入口进入“仅保存本地/保存到图库”选择选择“仅保存本地”后详情页回读为“已下载到本地”。强制停止并重新启动应用后下载管理仍回读到“三威胁与持球姿势”、文件名skill_triple_threat.mp4和“已下载”。因此本篇只声明已验证的本地保存和跨重启状态恢复不把图库实际落盘写成已验证结果。四、下载与图库导出是两条不同路径本地保存完成后才允许走图库导出。导出前需要先取得已下载文件的 URI如果文件不存在应直接返回失败不能创建空的图库记录。相册授权也不应被当作下载成功它只是用户允许写入图库的前置条件。const uri CourseVideoService.getDownloadedVideoUri(context, courseId) if (uri.length 0) { return Promise.reject(new Error(downloaded video not found)) }把“下载完成”和“已导出图库”分开记录能让页面反馈更诚实用户知道视频已可离线观看也知道图库副本是否真的建立。后续若加入删除下载内容只需删除本地文件并重新计算状态不需要在多个页面维护相互矛盾的布尔值。下载资格不是按钮文案离线入口的本质不是保存一个“已下载”布尔值而是让用户稍后仍能打开真实文件。因此服务先由 courseId 找到资源再计算目标文件名和目录成功条件是目标路径真实存在不是点击事件已经返回。详情页只负责把服务返回的状态翻译成“下载到本地”“已下载”或不可用说明。下载列表同样从服务读取已存在文件避免两个页面各自维护一份 downloadedIds进程重启后也不会出现一边显示已下载、另一边没有文件。} return file:// path; } static hasDownloadedVideo(context: common.UIAbilityContext, courseId: string): boolean { const path: string CourseVideoService.getDownloadedVideoPath(context, courseId); return path.length 0 CourseVideoService.exists(path); } static managedDownloads(context: common.UIAbilityContext): DownloadVideoItem[] { const result: DownloadVideoItem[] []; for (const course of COURSES) { const courseId: string course.id; const asset: CourseVideoAsset | undefined findCourseVideoAsset(courseId); if (asset ! undefined) { const path: string CourseVideoService.getDownloadedVideoPath(context, courseId); const item: DownloadVideoItem new DownloadVideoItem(); item.courseId courseId; item.title course.title; item.fileName CourseVideoService.localVideoFileName(courseId); item.path path; item.downloaded path.length 0 CourseVideoService.exists(path); item.galleryUri CourseVideoService.getGalleryUri(courseId); result.push(item); } } return result; } static downloadedItems(context: common.UIAbilityContext): DownloadVideoItem[] { return CourseVideoService.managedDownloads(context).filter((item: DownloadVideoItem) item.downloaded); } static canSavePackagedVideo(context: common.UIAbilityContext, courseId: string): boolean { return PACKAGED_COURSE_VIDEOS_ENABLED findCourse(courseId) ! undefined 文件存在性怎样成为唯一状态来源重复点击需要有确定行为若目标文件已经存在直接复用而非再复制若写入未完成页面保持进行中并阻止第二个任务。删除操作完成后必须重新从磁盘读取不能只把按钮文字改回去。findCourse(courseId) ! undefined CourseVideoService.getDownloadFileName(context, courseId).length 0; } static savePackagedVideo(context: common.UIAbilityContext, courseId: string): Promisestring { const fileName: string CourseVideoService.getDownloadFileName(context, courseId); if (fileName.length 0) { return Promise.reject(new Error(download item not supported)); } const targetPath: string CourseVideoService.getDownloadedVideoPath(context, courseId); if (targetPath.length 0) { return Promise.reject(new Error(invalid target path)); } if (CourseVideoService.exists(targetPath)) { return Promise.resolve(targetPath); } try { CourseVideoService.ensureDir(CourseVideoService.videoDir(context)); const data: Uint8Array context.resourceManager.getRawFileContentSync(fileName); const file fs.openSync(targetPath, fs.OpenMode.READ_WRITE | fs.OpenMode.CREATE); fs.writeSync(file.fd, data.buffer); fs.closeSync(file); return Promise.resolve(targetPath); } catch (err) { CourseVideoService.removeFileIfExists(targetPath); return Promise.reject(err); } } static deleteDownloadedVideo(context: common.UIAbilityContext, courseId: string): boolean { const path: string CourseVideoService.getDownloadedVideoPath(context, courseId); return CourseVideoService.removeFileIfExists(path); } static saveDownloadedVideoToGallery(context: common.UIAbilityContext, courseId: string): Promisestring { const fileUri: string CourseVideoService.getDownloadedVideoUri(context, courseId); if (fileUri.length 0) { return Promise.reject(new Error(downloaded video not found)); }检查对象事实来源页面应表现正常路径CourseVideoService 先确认资源条件再复制到课程专属目标路径详情、播放器和下载页都以文件存在性为准。显示与真实数据一致的结果边界条件不支持资源、目标文件已存在、写入中断和删除后重进页面都必须返回可解释状态。不给出假成功保留可恢复入口回读验证保存支持下载的视频、重开详情与下载列表、删除文件后再回读确认按钮与文件存在性同步变化。跨页面或重进后结果一致保存失败与重复点击如何处理这种实现把文件系统成本集中到服务层页面代码更短也使后续加入下载进度或存储空间检查时有唯一入口。代价是所有入口都必须尊重服务返回值不能为了视觉流畅预先宣称成功。}); } private refresh(): void { const ctx: common.UIAbilityContext getContext(this) as common.UIAbilityContext; this.items CourseVideoService.downloadedItems(ctx); } private play(item: DownloadVideoItem): void { const params: PlayerParams { courseId: item.courseId, startSec: 0 }; router.pushUrl({ url: Routes.PLAYER, params }); } private delete(item: DownloadVideoItem): void { if (item.galleryUri.length 0) { this.confirmDeleteWithGallery(item); return; } promptAction.showDialog({ title: TXT_DELETE_TITLE, message: TXT_DELETE_MSG_LOCAL, buttons: [ { text: TXT_CANCEL, color: this.palette.textSecondary }, { text: TXT_DELETE, color: #D32F2F } ] }).then((result: promptAction.ShowDialogSuccessResponse) {上述代码片段来自当前实现的连续调用点它们分别回答“谁提供事实”“谁消费结果”“异常时在哪里停止”。读者排查同类问题时应先验证这三个边界而不是只根据按钮颜色判断业务是否完成。下载管理页的回读路径保存支持下载的视频、重开详情与下载列表、删除文件后再回读确认按钮与文件存在性同步变化。定位这类问题时第一步应回到实际风险用户点击下载后按钮先显示成功但文件没有真正落地重启后列表又消失会造成“缓存状态”与真实存储分叉。。先确认输入是否已经被模型或服务拒绝再检查页面是否把该结果展示出来如果先从视觉现象倒推往往会把偶然残留的控件状态误判成业务完成。当前实现选择的是CourseVideoService 先确认资源条件再复制到课程专属目标路径详情、播放器和下载页都以文件存在性为准。。这意味着每个页面不必重复实现同一份判断却也要求任何新增入口都调用相同的服务或模型绕过该入口虽然能暂时缩短页面代码却会让后续回读失去一致性。需要单独保住的边界是不支持资源、目标文件已存在、写入中断和删除后重进页面都必须返回可解释状态。。边界出现时页面应当保留原因和下一步操作而不是把错误状态压成空白或成功提示。读者据此可以区分“没有数据”“资源不可用”和“动作尚未完成”。把这条边界写进文章还有一个维护价值当课程内容、页面布局或资源形式变化时验收仍可以围绕同一个事实来源进行而不必依赖某张旧截图或某个控件曾经显示过的文字。这样得到的结论能被下一次修改复查。排查顺序应先确认的事实不应采用的替代做法输入课程、记录或题目是否有稳定标识从页面文本推断业务对象服务判断或写入是否经过唯一入口在多个页面复制同一段临时逻辑回读重进后是否从持久化或模型得到同一结果只凭一次按钮变化判定成功ArkTS 响应式状态的基础机制可参考 HarmonyOS ArkTS 状态管理文档。这里的重点不是堆叠状态字段而是让页面在每次进入时都从同一业务事实重新得到可见结果。当需求继续扩展时应把新增条件加入现有服务或模型的明确入口并为该条件补充可观察的回读动作。这样课程内容、页面交互和持久化数据仍可沿同一条链路解释而不会在不同入口形成互相矛盾的结论。