ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

播放器续播功能完整实现:数据模型、API与多端同步踩坑指南

播放器续播功能完整实现:数据模型、API与多端同步踩坑指南 做播放器的同行应该都经历过这种用户反馈内容看到一半切出去回个消息再点回来视频从头开始放用户只能手动拖进度条找回位置。遇到脾气好的问你“能不能记住上次看到哪”遇到脾气急的直接卸载。我最近刚好做完这个“记录用户上次看视频的进度并且从记录的时间继续观看”的功能做完之后最大的感受是产品文档里一句话落地全是细节。今天把这套东西从数据模型、三端API、上报时机、多端同步到上线踩坑完整拆一遍给准备做或正在做续播功能的朋友一个参考。先说结论续播不是“存一个播放秒数”那么简单它涉及数据生命周期、播放器时序、网络同步、完播判定和容错边界。很多播放器项目续播做得别扭问题不出在“不会写存读代码”而是出在“没把数据模型和同步规则想清楚”。下面我按一条完整实现链路的顺序来讲。1. 续播的本质是给每段内容建“书签”先想清楚数据模型1.1 第一版设计为什么一定会翻车很多项目第一次做续播都会先定义一个全局配置表类似于“当前正在播放的视频ID 上次播放位置 更新时间”然后打开App时读出来直接 seek 过去。这个方案在只有一部电影、一个用户的玩具项目里能跑通一旦内容形态稍微复杂就崩。比如用户在看一部36集的剧看到第8集32分钟时退出去再从“继续观看”入口进来系统只记得第8集32分钟这没问题。但用户如果在第8集过程中手动切去第9集看了一分钟再回到第8集时你要恢复的是第8集的第32分钟还是第9集的第1分钟再比如同一个内容有国语版、粤语版、导演剪辑版播放源时长都不一样进度要不要按版本隔离所以第一件事是把“进度”从单一字段升级成一张独立的数据表或者一组结构化的存储对象按“内容维度”做隔离。1.2 推荐的数据结构内容ID与分集ID隔离一个能覆盖长视频、剧集、短视频、直播回放的进度模型至少需要这些字段PlaybackProgress { userId: string, // 用户ID匿名用户可以是设备ID contentId: string, // 内容ID代表一部电影、一部剧、一个栏目 episodeId: string, // 分集ID单集内容与contentId相同 profileId: string, // 播放源/清晰度版本ID可选用于区分不同版本 positionMs: number, // 播放位置单位毫秒 durationMs: number, // 播放源总时长单位毫秒 status: in_progress | completed | not_started, updatedAt: number, // 服务端时间戳毫秒 deviceId: string, // 最后一次写入的设备 clientSeq: number // 客户端自增序号用于防止乱序覆盖 }主键设计成userId contentId episodeId不把“当前正在看的内容”当成单条全局配置而是让每个内容都有一份独立进度。这样“最近观看列表”可以直接查表拿到未来做多设备同步、断点统计、个性化推荐也有基础数据。positionMs 和 durationMs 统一用毫秒而不是秒。主要原因是很多播放器底层返回的本来就是毫秒ExoPlayer 的 currentPosition 返回毫秒秒数在浮点运算里会有精度损失而且跨端上报时单位不统一容易出现“iOS传秒、Web传毫秒”这种低级错误用毫秒能少踩很多坑。1.3 本地存储和服务端存储不是二选一进度存本地还是存服务端取决于产品形态。我见过只做本地的也见过纯服务端的都各有问题。方案优点缺点适用场景仅本地存储localStorage / SharedPreferences / UserDefaults实现简单、启动快、无网络依赖清缓存/换设备即丢失无法跨端匿名浏览、单机场景仅服务端存储可跨端同步可做数据分析和运营断网时无法恢复上报太频会费流量需要登录体系强账号体系产品本地兜底 服务端同步体验和数据安全兼顾需要处理同步冲突开发量最大主流长视频App、知识付费App我的建议是直接用混合方案本地存一份最新进度保证冷启动秒开服务端存一份权威数据用于多端同步和防丢。具体读写逻辑后面两节展开。2. 时间轴的写入与恢复三端播放器API的关键细节2.1 Web端loadedmetadata 之后再设置 currentTime浏览器端最基础的做法是监听 video 元素的 timeupdate 事件拿到当前播放位置恢复时在视频元数据加载完成后设置 currentTime。但这里有个常见的坑如果在 loadedmetadata 之前就设置 currentTime浏览器会直接忽略这个值尤其是设置得比较早、视频源还没准备好的时候。const video document.getElementById(videoPlayer); // 上报逻辑节流上报5秒一次 let lastReportTime 0; const REPORT_INTERVAL 5000; video.addEventListener(timeupdate, () { const now performance.now(); if (now - lastReportTime REPORT_INTERVAL) { reportProgress(video.currentTime * 1000, video.duration * 1000); lastReportTime now; } }); // 恢复逻辑等元数据加载完成后再seek video.addEventListener(loadedmetadata, () { const saved getLocalProgress(video.dataset.contentId, video.dataset.episodeId); if (saved saved.positionMs 0) { video.currentTime saved.positionMs / 1000; } });建议再监听 durationchange 事件因为部分 HLS/MP4 资源在加载过程中 duration 会变化如果你依赖 duration 做“是否已看完”的判定最好在 duration 稳定后再判断。Web端另一个容易忽略的是pagehide和visibilitychange事件。移动端浏览器在页面切后台时timeupdate 可能会停止触发这时必须立刻把当前时间上报一次否则用户退后台3分钟进度丢3分钟。2.2 iOS / Android端AVPlayer 和 ExoPlayer 的时序控制iOS 上使用 AVPlayer需要等 AVPlayerItem 状态变为.readyToPlay再执行 seek否则 seek 会被忽略或者触发后续状态错乱。let savedPosition loadProgress(contentId: contentId, episodeId: episodeId) playerItem.observe(\.status, options: [.initial, .new]) { [weak self] item, _ in guard item.status .readyToPlay else { return } let time CMTime(seconds: Double(savedPosition) / 1000, preferredTimescale: 600) // 使用零容差seek避免seek后位置偏离目标 self?.player.seek(to: time, toleranceBefore: .zero, toleranceAfter: .zero) }注意preferredTimescale: 600是苹果官方推荐的时间基适配常见的 24/25/30/60 fps避免用 1 秒时间基导致 seek 精度不够。Android 端用 ExoPlayer 的话推荐的做法是创建 MediaItem 时直接传 startPositionMs这样播放器在 prepare 阶段就会自动从指定位置开始播放比 prepare 之后再 seek 更稳val mediaItem MediaItem.Builder() .setUri(videoUri) .setStartPositionMs(savedPositionMs) .build() player.setMediaItem(mediaItem) player.prepare() player.playWhenReady true如果是老项目还在用 MediaPlayer也需要等 OnPreparedListener 回调后再 seekTo顺序不能反。2.3 恢复时的统一时序先准备好再跳转再播放所有平台其实都在做同一件事只是API不同。通用顺序是创建播放器实例装载数据源设置 URL / asset / mediaItem等待播放器进入“可播放”状态loadedmetadata / readyToPlay / prepared从本地缓存或服务端读取该内容的最后进度seek 到目标进度优先用播放器原生支持的方式减少二次状态切换恢复 UI 和上报逻辑不按这个顺序走最常见的症状就是“设置了进度但没生效”或者“首帧黑屏几秒后才跳过去”。这不是偶现是时序竞态。另外提醒一句如果播放的是 HLS 直播流千万不能直接把“上次观看位置”往下发直播的绝对时间没有意义要做基于DVR窗口或节目单的映射这块是另一个话题本文不讲。3. 上报节奏与落盘时机从每秒触发到“静默上报”的调优3.1 每次 timeupdate 都写库会发生什么HTML5 video 的 timeupdate 事件在播放时大概每 4~66Hz 触发一次取中间值差不多每秒 4~10 次。ExoPlayer 的onPositionDiscontinuity和onPlayerStateChanged组合起来频率也不会低。如果每次触发都写本地存储或者发起网络请求后果很直接网络请求满天飞弱网下直接卡播放本地存储频繁写入iOS 的 UserDefaults 和 Web 的 localStorage 都是全量读改写频繁写会触发主线程卡顿后台上报和前台上报混在一起数据乱序严重旧的覆盖新的。3.2 三层上报节奏短间隔、强制flush、低频兜底我实际落地用的是一套“三层节奏”第一层播放过程中每 5 秒检查一次位置变化超过阈值比如 3 秒才上报一次避免重复值刷屏。第二层页面隐藏、App进入后台、用户切走、网络从WiFi切到蜂窝、播放器销毁时立即强制上报一次当前进度。这层是保证“用户退出后下次能续播”的关键。第三层不管有没有变化每 30 秒由定时器兜底上报一次防止某些播放器长时间不触发 timeupdate 导致进度丢失。模拟代码const REPORT_INTERVAL 5000; const FORCE_FLUSH_EVENTS [pagehide, visibilitychange, offline]; let lastReportTime 0; let lastReportedPos 0; function maybeReport(force false) { const now performance.now(); const positionMs Math.round(video.currentTime * 1000); const changed Math.abs(positionMs - lastReportedPos) 3000; if (force || (changed now - lastReportTime REPORT_INTERVAL)) { reportProgress(positionMs, Math.round(video.duration * 1000)); lastReportTime now; lastReportedPos positionMs; } } video.addEventListener(timeupdate, () maybeReport(false)); setInterval(() maybeReport(true), 30000); FORCE_FLUSH_EVENTS.forEach(evt window.addEventListener(evt, () maybeReport(true)));这里我刻意用performance.now()而不是Date.now()因为 Date.now 受系统时间跳变影响用户手动改手机时间可能导致上报间隔或时间戳异常performance.now()在大部分端上都更稳定。3.3 乱序覆盖旧包比新包晚到的问题上报如果走网络默认是异步的。用户看到 10 分钟时切后台页面立刻发了一条“10分钟”的请求但 5 秒前发出的“7分钟”请求因为网络慢还悬在队列里等“10分钟”的请求先到服务端然后“7分钟”的请求才到服务端最终存的反而变成了 7 分钟。解决办法有两层。客户端层在强制 flush 之前把该上报会话里未完成的旧请求取消或者打上过期标记服务端层每条上报带上clientSeq客户端自增序号或updatedAt服务端只接受比自己更新更大的记录。推荐两个都用因为客户端取消请求只能管自己的页面管不了多标签页或多设备。4. 跨端同步与“谁才是最新进度”的冲突处理4.1 拉取时机不是每次进详情页都拉服务端做了服务端记录后有个隐藏的性能问题如果每次打开一个视频详情页都请求一次进度接口冷门视频天荒地老热门视频后端要扛的 QPS 翻三倍。合理的拉取时机是App/Web 冷启动完成后批量拉取“最近观看列表”一次性把最近 20 条内容的进度塞进本地缓存进入具体播放页时先读本地缓存秒开如果本地缓存无记录或已过期比如超过1天再单独拉一次该内容的进度从后台回前台、重新联网时对当前正在播放的内容做一次刷新。这样服务端接口压力小用户体感也最好。4.2 判断“最新”不能只用 position 大小很多开发在写冲突合并时会偷懒取“position 更大的一条”。这个逻辑大部分时候没错但有一个典型反例用户看到第 20 分钟后手动拖回第 5 分钟重新看这时候第 5 分钟是用户最新的意图如果用 position 取 max就会永远活在 20 分钟永远续不上第 5 分钟。正确做法是看updatedAt谁的服务端更新时间更靠后谁就是用户最新的状态。极端情况下服务端时间也会有偏差但加入clientSeq做二次排序后基本能覆盖。表格简单列一下几种场景场景position 大的updatedAt 新期望行为手机看到 60%平板看到 40%手机手机平板续播 60%手机看到 20%手动拖回 5%20%5% 这条记录更新手机续播 5%手机断网看到 80%服务端还停在 30%本地 80%服务端 30% 的时间可能比本地早联网后本地 80% 上传覆盖第三种场景是断网合并。我的做法是本地先展示联网后比较本地和服务端的updatedAt本地新就上传本地服务端新就下载服务端。这种 Last-Write-Wins 策略在视频进度场景足够用了没必要上向量时钟或 CRDT成本高收益低。4.3 一个容易漏掉的问题多标签页同账号互相覆盖PC 上同一个用户开两个标签页同时看不同的视频或同一个视频停在不同位置。A 标签页每 5 秒上报一次B 标签页也每 5 秒上报一次如果只按contentId episodeId做主键两边会互相覆盖最终存储的进度取决于哪个标签页最后上报而用户体验是“我明明在A页面看到一半刷新后却跳到B标签页的位置”。这个问题的根治手段是服务端存储模型里在同一个内容下支持“设备/会话维度”区分比如deviceId sessionId作为子维度拉取时优先取当前设备的进度。产品上也可以接受“全局只有一个进度”那就要在更新时额外加锁避免互相覆盖。我这边最后采用了“当前设备进度优先 最近观看列表取全局最新”的策略才彻底解决。5. 完播阈值、剧集切换与进度清理的边界规则5.1 不要死等 100% 才判定“看完”之前有个运营数据需求要统计“完播率”我一开始设定 position duration 就算完成。结果一堆用户反馈“我看完了但进度没打勾”。排查发现两个原因一是片尾有花絮或黑场用户可能在最后 5 秒退出二是一些 HLS 流 duration 比实际内容多了一截。调整成“位置达到总时长的 98%或者剩余播放时间小于 5 秒”即判定为 completed完播就正常了。阈值要按内容类型配置电影可以 98%30 分钟的剧集可以 95%短视频一般不看续播直接看是否播完。5.2 剧集切换时的继承规则连续剧的续播最容易出问题的不是单集进度而是“下一集自动播放”和“回到上一集”时状态怎么流转。我的建议是看完第 N 集上报第 N 集 completed并给第 N1 集插入一条in_progress且 position 为 0 的记录用户手动选择第 N 集重看第 N 集的 state 从 completed 改回 in_progress并清掉 position“最近观看列表”只取updatedAt最大的那条记录作为入口文案“看到第几集第几分钟”的历史明细由前端从进度表里聚合。只维护一个“当前播放位置”字段不做分集记录切两次后台就会乱。5.3 历史进度不能无限存要有清理策略如果有 100 万用户每人都看 5000 部片进度表就是 50 亿条这个量级光存数据就是不小的成本。我们要给进度数据设计生命周期已 completed 且超过 30 天未再被点开的记录由定时任务清除in_progress且超过 90 天未更新的记录视为僵尸数据清除单条非法数据position 大于 duration 5 秒、duration 为 0、contentId 为空定期洗掉用户主动删除“观看历史”时要连同进度一起删这块涉及隐私合规不能漏。本地端同样要做清理。localStorage 只有 5MB 左右放个几千条进度就可能顶爆建议本地最多保留最近 50 条内容进度超出后按 updatedAt 淘汰。6. 上线后我踩过的坑乱序覆盖、广告错位与首帧黑屏6.1 广告前贴片导致记录位置错位上线第二天就收到一条诡异反馈用户抱怨“从继续观看进入视频总是从广告开始那一秒播放”。排查时先看上报日志发现进度接口报上来的值确实在 1 秒以内但不是正片的第一秒而是广告播出的位置。根因是我们的播放器广告和正片都统一监听了 timeupdate 事件上报模块分不清当前播放的是广告流还是正片流。复现链路是用户点进视频先播 30 秒前贴广告这时 timeupdate 已经在触发上报模块把广告的 currentTime 当成正片进度写进了库正片开始时重新 seek又把进度覆盖了一次。修法很直接上报模块增加一个contentType上下文只在正片播放器实例触发时上报广告播放器、小窗预览播放器、试看播放器都不参与续播进度写入。上线后问题消失。6.2 多标签页并发导致“看A片却续播B片”另一个坑是文章前面提到的多标签页覆盖但当时更隐蔽两个标签页同时上报服务端按请求到达时间排序结果到达快的标签页覆盖了到达慢的。用户表现非常随机刷新一下跳到另一部片再刷新又跳到第一部。后来我在每条上报里加了clientSeq每次播放会话自增服务端对同一userId contentId episodeId的记录先比较updatedAt如果相同再比较clientSeq较大的胜出。客户端在强制上报时也会把当前会话里未发送的旧请求标记为 invalid。改完后随机跳变再没出现过。6.3 恢复播放后首帧黑屏seek 与网络数据竞争有一批 Android 低端机型用户反馈从桌面图标点进App点“继续观看”屏幕上一直转圈黑屏 5~8 秒才出画面。看播放器日志视频源已经加载完成seek 指令也执行了但播放器一直停在 BUFFERING。本质上这是 seek 到目标位置后需要的分片数据还没有下载完成播放器在等关键帧而我们的 loading UI 只在state BUFFERING时显示黑屏没有给用户任何提示体感上就像卡死。修法是在 seek 之后增加seeked事件的监听配合 3 秒超时如果超时仍未进入播放状态就显示“正在缓冲”提示并触发一次retry。对于 HLS 流还额外优化了播放器的初始缓存策略让加载优先级更接近恢复位置。6.4 其他容易忽略的小坑最后列一下我遇到的其他低频但恶心的场景供大家自查用户手动把系统时间改了往前后调导致本地上报时间戳比服务端新或旧服务端因此拒绝有效上报。解决办法是时间全部用服务端时间客户端不上传本地时间。切换清晰度时不同码率的源文件时长有细微差异导致上次记录的 position 超出新源的 duration。恢复前要做一次Math.min(savedPosition, durationMs - 2000)。用户用无痕模式或者本地存储被浏览器策略拦截时写入 localStorage 会直接抛异常要做 try/catch 降级不要让它影响播放。缓存清洗工具或系统清理会把本地进度清掉这时要能从服务端重新拉取所以“本地为主、服务端兜底”和“服务端为主、本地兜底”要分清主次整体应该是“体验上本地优先、数据可信度上服务端优先”。如果让我重新做一遍这个功能我会先把上面这些边界情况整理成测试用例清单再开始写代码。续播功能本身不大但涉及播放器时序、网络一致性、多端同步、存储生命周期每个环节都能挖出坑。每次状态上报前都假设会有乱序、延迟和覆盖发生用服务端时间戳和自增序号做裁决这套思路放进任何视频项目里都适用。
返回列表