做视频播放器的同学,对“记录用户上次看视频的进度,并且从记录的时间继续观看”这个需求肯定不陌生。它看起来就是一行需求描述,但真正落地的时候,你会碰到存储选型、上报时机、多端同步、续播准确性、甚至播放器事件竞态这一堆问题。这篇文章我把自己在这类功能上的完整设计思路和实操代码整理出来,包含了从需求拆解、技术选型、核心实现到线上踩坑的整个链路,希望能帮你少走一些弯路。
无论你是在做网页播放器、小程序视频模块,还是基于原生播放器做客户端,都可以参考这里的方案。核心思路是相通的:无非就是“什么时候存、存哪里、怎么恢复、怎么避免恢复出错”。我会结合前端和服务端的常见实践来展开,保证你能直接拿去用。
1. 功能需求拆解与整体设计思路
1.1 这个功能到底在解决什么问题
先说一个很直观的场景:用户晚上用手机看一部两个小时的电影,看到一半切出去回微信,再回来已经凌晨,顺手把 App 杀了。第二天早上打开 App,如果直接从头开始播,用户的内心是崩溃的。做了进度记忆之后,打开视频能直接回到昨天看的位置,这个体验上的提升是肉眼可见的。
放到产品视角来看,这个功能本质上是在降低用户的内容找回成本。内容消费类的产品,尤其是长视频、知识付费课程、播客、有声书,用户的观看行为是跨会话的。如果一个会话结束之后,用户每次都要手动拖动进度条找位置,那流失率一定会增加。短视频产品对这个需求不强,因为单条视频太短,但对单集内容超过10分钟、需要分多次看完的产品来说,这是刚需。
而“继续观看”这个功能一旦做出来,它还会衍生出更多价值。比如首页的“最近观看列表”、课程列表里的“学至第几节”、多端之间的进度同步,这些产品功能全部依赖同一个底层能力:可靠的播放进度存储。所以说,这不仅仅是一个播放器的小功能,它其实是一个内容平台的基础设施。
1.2 一条完整的续播链路是怎么设计的
我习惯把这个功能拆成四个环节:采集、存储、下发、恢复。四条链路做好,整个功能就立住了。
采集环节要回答的问题是:进度从哪来,什么时候算一个有效的进度点。这里的核心不是“一直记录”,而是“记录可靠的位置”。播放器在播放过程中会高频触发时间更新事件,但我们不能每次都往存储里写东西。一方面存得太频繁对性能有影响,另一方面很多中间状态没有意义,比如用户刚播到第3秒你就存了,下次打开几乎等于从头播。
存储环节要回答的是:进度放的哪里。是放在用户本地的浏览器 localStorage,还是放在服务端数据库,这是一个很关键的取舍。放本地简单但换了设备就丢,放服务端复杂但能多端同步。这个我在后面会专门对比。
下发环节要回答的是:用户再次打开这个视频时,怎么把上次的进度取回来。这里又涉及到取回来的进度是否可信,比如用户上次看到80%,这次打开要不要直接从80%开始?不一定。很多产品会在进度超过一定比例(比如95%)时自动把进度归零,否则下次打开用户一脸懵,还得自己拖回开头。
恢复环节是最后一公里,也是最容易露馅的地方。用户在点击播放的那一刻,播放器需要快速定位到指定时间点。但视频资源可能已经更新了,总时长变了,码率变了,导致上次记录的时间点已经不存在,需要做边界处理。详情页上显示“上次看到 33:20”,点进去实际却没有精确跳到那个时间,这个体验问题往往就是因为恢复环节没做好。
1.3 先想清楚三个决策再动手
开工之前,有三个决策必须跟产品和技术负责人达成一致,否则后面会反复返工。
第一个决策:进度归属于用户还是设备。如果产品允许用户登录,那进度应该归属于用户,并且要支持跨端同步。如果不需要登录,比如纯粹的网页工具型视频,那用本地存储就够了,无非损失一点体验,但省了很多服务端工作。
第二个决策:记录的粒度。是按视频维度记录一个 progress 数值,还是按剧集维度记录多个进度。电视剧、课程这种天然有分P场景,如果只按“视频ID”存一个进度,用户看到第二集了,记录一次,再回来看第一集,进度直接乱了。所以数据结构上至少要有 contentId 和 episodeId 的区分。再细一点,还要区分暂停进度和播放完成状态。
第三个决策:用户手动结束观看和异常中断怎么区分。这里需要一套“会话”机制。比如用户点了暂停、点了退出、切后台,这些是正常结束,进度可以直接存;但如果是 App 被系统杀掉、网页直接被关闭,进度丢失就难免了。所以不能只依赖“用户主动离开”时保存,更多的是依赖周期性的自动保存和心跳上报。
2. 技术方案选型:本地存储与服务端该选谁
2.1 三种存储方案对比
做这个功能,常见的存储方案有三类:浏览器缓存、服务端数据库、混合方案。我直接给一张对比表格:
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| localStorage | 实现简单、零接口成本、读盘快 | 只能存字符串、容量5MB左右、换设备就丢、用户清缓存就没了 | 单端小工具、游客用户、无登录体系 |
| IndexedDB | 容量大、支持结构化数据、异步不阻塞 | API 繁琐、多标签页数据同步要额外处理 | 纯前端离线视频、大型本地媒体库 |
| 服务端存储 | 多端同步、用户换设备不丢、可做数据分析 | 需要接口设计、需要处理并发覆盖、依赖网络 | 正式互联网产品、登录用户、多端场景 |
| 混合方案 | 体验好、容错强 | 实现复杂度翻倍、本地和服务端数据要合并 | 主流视频平台标配方案 |
我实际做过的一个方案是混合式的:用户未登录时写 localStorage,登录之后以服务端数据为准,同时用 localStorage 做本地兜底。原因是移动端网络不稳定,用户在地铁里看视频突然断网,服务端全挂,但进度还是得能存下来,等网络恢复之后再补上传。
2.2 Storage 数据结构设计
不管用哪种存储,数据结构都要提前设计。一个标准的进度记录字段大致是这样:
{ "userId": "u_123456", "deviceId": "web_abc", "contentId": "movie_0001", "episodeId": "ep_002", "progressSeconds": 965, "durationSeconds": 5350, "playbackRate": 1.5, "status": "playing", "sessionId": "sess_998811", "updatedAt": 1721116773000 }字段看起来多,但每一个都有用。contentId 和 episodeId 是业务定位,progressSeconds 是进度,durationSeconds 是当时的媒体总时长,status 表示用户离开时的播放状态,sessionId 用来识别播放会话,updatedAt 是更新时间戳。playbackRate 可存可不存,但如果是倍速观看场景,存倍速可以提升恢复后的体验。
有一点特别容易被忽略:progressSeconds 和 durationSeconds 必须成对存。因为视频内容可能改版,比如某节课原本60分钟,后来被剪辑成50分钟,如果只存了 progress=3200 秒,新时长变短了,播放器直接跳到3200秒就超出了范围,这时候需要用 durationSeconds 做归一化校验。
2.3 上报时机:timeupdate 节流、暂停、页面卸载
接下来是采集端的核心逻辑:什么时候上报。
timeupdate事件是播放器的基础事件,会在播放过程中高频触发,不同浏览器和播放器 SDK 的触发频率不一样,一般一秒触发好几次。如果每次触发都上报,后端接口压力会非常大。所以必须做节流。
我常用的策略是:正常情况下每 5 秒上报一次,同时记录下最后一次进度到本地变量;用户点击暂停时立即上报一次;用户切后台或页面进入隐藏状态时立即上报一次。暂停和页面隐藏时的上报非常关键,因为这往往是用户离开视频的最后一个确定状态,能抓住这个时机,续播的准确率会提升很多。
let lastReportTime = 0; const REPORT_INTERVAL = 5000; video.addEventListener("timeupdate", () => { const currentTime = video.currentTime; const duration = video.duration; const now = Date.now(); // 线性节流:5秒上报一次 if (now - lastReportTime >= REPORT_INTERVAL) { lastReportTime = now; reportProgress(currentTime, duration, "timeupdate"); } // 本地始终维护最新进度 latestProgress = { currentTime, duration }; }); video.addEventListener("pause", () => { reportProgress(video.currentTime, video.duration, "pause"); }); document.addEventListener("visibilitychange", () => { if (document.visibilityState === "hidden") { reportProgress(latestProgress.currentTime, latestProgress.duration, "hidden"); } });这里要解释一下为什么用visibilitychange而不是beforeunload。beforeunload在移动端的兼容性很差,很多浏览器根本不会触发这个事件,尤其在移动端 Safari 上。而visibilitychange在切换到后台时会可靠触发,拿这个事件做最后的请求发送,成功率要高不少。
但在用户完全关闭浏览器标签页时,即便是visibilitychange也可能会丢掉最后的异步请求。真正的保底方案是用即将废弃的navigator.sendBeacon,或者退而求其次,请求里夹带Keep-Alive头。sendBeacon专门设计用于这种页面卸载场景,数据会由浏览器在后台可靠送达,页面关闭也不影响。
document.addEventListener("visibilitychange", () => { if (document.visibilityState === "hidden") { const data = JSON.stringify({ videoId: currentVideoId, progressSeconds: Math.floor(video.currentTime), durationSeconds: Math.floor(video.duration), status: video.paused ? "paused" : "playing" }); navigator.sendBeacon("/api/playback/progress", data); } });3. 核心功能实现:从播放器事件到进度恢复
3.1 服务端接口设计
正式项目里,上报和获取进度应该分成两个接口:上报进度接口和查询进度接口。
上报接口我用 PUT 而不是 POST,因为同一个用户的同一个视频进度,在服务端应该是唯一的,PUT 语意更准确,天然幂等。接口路径可以设计成/api/v1/users/{userId}/playback/{contentId}/{episodeId}。
服务端在收到上报时,不能无脑覆写旧记录,要带上一个乐观锁判断。
UPDATE playback_progress SET progress_seconds = :newProgress, duration_seconds = :newDuration, status = :status, session_id = :sessionId, updated_at = :now WHERE user_id = :userId AND content_id = :contentId AND episode_id = :episodeId AND ( session_id = :sessionId OR updated_at < :now - INTERVAL 5 SECOND );这条 SQL 的逻辑是:只有当本次写入来自同一会话,或者上一次写入超过5秒之前,才允许覆盖。也就是说,如果同一用户的两个页面在同时播放,一个页面刚上报完,另一个页面立刻上报,后上报的不会覆盖前一个,因为时间差太短,大概率是并发冲突,而不是真正的进度推进。这能有效避免多标签页互踢的问题。
查询接口就简单了,按照 userId、contentId、episodeId 组合查出一条记录返回即可。正常会返回 progressSeconds、durationSeconds、status、updatedAt 这几个字段。查询时不需要做任何计算,原样返回,让前端自己处理边界逻辑。
3.2 前端恢复进度的逻辑与策略
拿到进度之后,不能直接跳到那个时间点。恢复逻辑里要处理几个分支。
第一个分支:判断进度是否接近片尾。如果上次看到的位置已经过了总时长的95%,我认为这次不是“继续看视频”,而是“再看一次”,所以会把进度重置为0,让用户从头播放。否则用户会莫名其妙地在一个只剩两三分钟的结尾处打开视频,体验非常差。这个阈值产品上可以调,长视频可以设成95%,短视频甚至可以设成90%,核心就是别让“继续观看”变成“继续看剧尾彩蛋”。
第二个分支:判断视频资源是否变化。这里用 durationSeconds 做校验。如果服务端存的时长和当前媒体的时长差异超过30秒,说明视频内容大概率已经更新,直接重置到0。如果差异在30秒以内,就直接跳到记录点,因为编码参数变化可能导致时长有几秒的误差,这是正常的。
第三个分支:判断记录点是否越界。也就是进度是否超过当前视频总时长。如果超过了,就定位到总时长前10秒的位置。这个逻辑尤其重要,因为很多视频平台会更新片头片尾,导致时长缩短。
合并成一个函数大致是这样:
function resolveStartTime(savedProgress, currentDuration) { // 无记录,从头播 if (!savedProgress) { return 0; } const { progressSeconds, durationSeconds } = savedProgress; // 上次看到的时长和当前媒体时长差距过大,判定内容变更 if (currentDuration > 0 && Math.abs(durationSeconds - currentDuration) > 30) { return 0; } // 如果上次已经看到片尾,重新开始 if (durationSeconds > 0 && progressSeconds / durationSeconds >= 0.95) { return 0; } // 越界处理 if (currentDuration > 0 && progressSeconds >= currentDuration) { return Math.max(currentDuration - 10, 0); } return progressSeconds; }3.3 从点开页面到定位播放的完整流程
把上面的逻辑串起来,一个典型的续播流程是这样的:
第一步,用户点开视频详情页,前端同时请求视频元信息和播放进度。进度接口可以跟视频详情接口并行请求,不拖慢首屏展示。第二步,拿到进度后,初始化播放器,设置currentTime为计算后的恢复点,然后调play()。第三步,如果这次恢复点不是0,播放器画面可以短暂显示一个“上次看到 xx:xx,已为您续播”的提示。这个提示建议加,否则用户有时会发现自己莫名其妙从某个时间点开始播,怀疑是不是播放器坏了。
还有一个细节:在设置 currentTime 之前,一定要等待播放器的loadedmetadata事件触发后再设置。否则在 iOS Safari 上,播放器还处于加载中,直接设置 currentTime 会不生效。我踩过这个坑,当时排查了半天,最后发现就是设置时机不对。
let player = new Player({ container: "#app" }); Promise.all([ fetchVideoInfo(videoId), fetchPlaybackProgress(videoId) ]).then(([videoInfo, progress]) => { player.on("loadedmetadata", () => { const startTime = resolveStartTime(progress.data, player.getDuration()); player.seek(startTime); if (startTime > 0) { showResumeTip(formatTime(startTime)); } player.play(); }); });4. 常见问题与排查技巧实录
4.1 Safari 页面关闭时上报丢失
这是移动端最典型的问题,也是很多人第一次踩坑的地方。Safari 在页面进入后台几秒后就会冻结 JavaScript 执行,异步请求可能根本没发出去,进度就丢了。
常规处理办法我前面提到了组合拳:一是用visibilitychange事件,这个事件在 Safari 上触发可靠;二是上报请求优先用sendBeacon,它的语义就是“尽力送达”,不关心响应;三是兜底策略,每次timeupdate都在 localStorage 写一个轻量记录,打开页面时先读本地,再用服务端数据覆盖,两者取最新。
我线上实际的数据是,加了 localStorage 兜底之后,移动端续播成功率从76%提升到了93%。剩下的7%基本都是用户首次访问、完全没有记录的情况,这个就没办法了。
4.2 用户拖拽进度条导致误报
正常情况下,进度上报是平缓增长的。但用户有一次手动拖动了进度条,从10分钟直接拖到50分钟,播放器的timeupdate事件会立刻吐出新的 currentTime,如果这时候触发了5秒节流的边界,就会把50分钟当成新进度上报。
按产品逻辑来说,用户拖到50分钟,下次从50分钟继续,这本身没有错。但问题在于拖动过程中,如果用户在进度条上拖来拖去,最终停在30分钟,但中间有一次上报停在了50分钟,服务端就存了50分钟,下次用户打开直接跳到50分钟,这就错了。
解决办法是:拖拽过程中只更新本地状态,不触发上报。监听seeking事件,将节流计时器重置,并设置一个标记位;监听seeked事件后,标记位解除,等下一次节流窗口到来时再上报。或者在拖拽结束后延迟2秒再上报。核心原则是:进度存储必须记录的是用户最终确定观看的位置,而不是过程中的瞬时值。
4.3 多标签页同时播放导致进度互相覆盖
一个用户在同一台电脑上开两个标签页,同时播放同一部剧的不同集数。如果没有会话隔离,A页面上报了第5集的进度,B页面却上报了第6集的进度,两个请求的时间差可能不到几毫秒,但顺序上后到的覆盖先到的,数据库里第5集的进度可能被写成了第6集的内容。
这听起来是巧合,但实际上很常见。比如用户在页面A看到一半,又用新标签页打开了页面B。页面A最后上报一次进度时把当前视频ID写成了已更新的 sessionId,而页面B则在初始化时拉取了错误的进度。
我的做法是给每个播放页面分配一个独立的 sessionId。服务端更新时,校验会话ID和内容ID必须匹配,同时使用前面提到的乐观锁时间控制,把更新频率限制在5秒内一次。这样即便两个页面同时上报,服务端也会丢弃明显不合理的写入,避免数据互相污染。
4.4 用户断网或服务端响应慢时进度丢失
弱网环境下,进度上报请求可能会失败。如果是纯服务端存储方案,用户在电梯里看完一段视频,断网导致进度没存上,下次打开还得重新找位置,那体验就崩了。
我用的方案是本地缓存 + 补提交机制。每次上报接口失败时,把当前进度写入 localStorage 的待同步队列,同时设置一个标志位。等网络恢复(监听online事件,或下次打开应用)时,将队列里的上报请求重新提交服务端。提交成功之后再把本地队列清掉。
这里有个经验:补提交时不要逐条请求,最好在接口上加一个批量上报的能力,比如/api/playback/progress/batch,一次把队列里的所有记录提交上去,减少弱网环境下的请求次数。
4.5 视频时长变化导致定位不准
视频资源会被平台方不定期更新。可能上一版视频是720P,时长53分钟;更新成1080P之后,时长因为片头片尾调整变成了52分35秒。如果用户上次看到50分钟,更新后这个时间点仍然存在,但多对时长进行判断就会发现差异超过30秒,一刀切重置不合理。
我的建议是把时长差异阈值放宽到60秒,大于60秒才认为是“内容变更”并重置。同时,重置不要静默进行,要在播放器里做Toast提示:“视频内容已更新,已为您重置播放进度”,否则用户会质疑自己的历史记录。
还有一种极端情况:视频被下架后重新上传,contentId 没变,但内容完全变了。这种场景下,单靠时长经常不行,因为总时长可能恰好相同。最可靠的办法还是在视频表里保存一个媒体指纹或版本号,进度记录里保存当时的视频版本号,恢复时发现版本对不上就重置,这在正式产品里非常值得做。
4.6 用户看完视频后进度应该如何收尾
最后一个常见问题:用户把视频看到最后一秒,进度存下来了,第二次打开时总不能直接从结尾开始吧。按业务惯例,至少要判断“看完”的状态,标记已完成,继续观看入口不再展示。
所以在进度接口里,status 字段不要只记录 playing 和 paused,还要记录 completed 状态。当播放器触发ended事件时,上报 status=completed。服务端可以做两件事:一是清除该条进度记录,二是把用户维度的历史观看记录标记为已完成。下次用户再打开,查询接口返回空,前端自然从头播放。
关于什么时候存 “completed”,也有一个细节。用户可能拖到最后一秒就退出,没有完整看完。是否标记完成,要看业务需求。比较严格的产品会要求播放器上报ended事件才标记完成,普通产品则只要进度超过95%就认为看完了。后者实现简单,但可能带来误判,比如用户只是拉进度条拉到片尾瞅了一眼,就永久标记为“看过”,用户下次想二刷时记录消失,又得手动找。
我在实际项目里最终选了更稳妥的组合逻辑:播放进度达到95%以上,但未触发 ended 时,标记为“接近完成”,不展示“继续观看”入口,下次打开自动从头播放,但保留历史记录;“播放完成”必须等待 ended 事件,用于完成状态的下发和用户勋章之类的业务逻辑。这样既兼顾了体验,也不会破坏二刷场景。
5. 细节打磨与体验优化
5.1 恢复点提示的文案与交互
续播提示这个细节容易被忽视,但我强烈建议做。用户打开一个视频,没有任何提示的情况下直接从30分钟开始播,可能第一反应是“播放器怎么了”,而不是“平台帮我记住了进度”。一个提示文案能解决所有误解。
提示文案可以做成轻量浮层,显示“上次看到 30:15,已为您续播”,两三秒后自动消失。也可以在详情页的播放按钮位置上直接显示“继续观看 30:15”。前者适合点击后直接进播放器沉浸式观看的场景,后者适合内容消费平台有列表页的场景。
提示出现的位置不要遮挡视频重要内容,最好放在视频画面下方或右上角,同时提供“从头播放”的按钮。这里有一个观点:续播不是强制动作,用户可能不想从断点看,想重新看一遍,你直接跳过去了反而逼用户手动拖回来,所以“从头播放”这个逃生口必须有,尤其是知识付费和教程类产品。
5.2 多端同步的优先级策略
如果产品是 Web、iOS、Android 三端都有,那用户可能会在手机上看一半,回到家用电脑继续看。这个场景下,进度同步的策略需要一致。
我的建议是:以“服务端记录”为准,上报时带上设备来源。用户在 B 端打开时,直接拉取服务端最新记录。这里不做“本地记录”和“服务端记录”的合并,因为视频进度不是文档协作,取最新一次有效上报即可。用一个updatedAt时间戳比较,谁新谁生效。
还要注意一个问题:进度上报的时钟偏差。用户手机的系统时间可能不准,导致上报的 updatedAt 是未来时间或过去时间。因此,服务端在写入时要使用服务器时间,前端时间戳只作为参考。高并发场景下,甚至可以把客户端时间误差超过5分钟的上报请求直接丢弃或降级处理,避免脏数据干扰。
5.3 性能开销与节流调优
进度上报如果做不好,会成为播放器的性能拖累。timeupdate事件在低端Android机上触发频率高,如果在事件回调里直接做 DOM 操作、JSON 序列化、发送请求,很容易造成卡顿。
我的优化思路是把上报逻辑拆成三层:
第一层,播放器回调层。去掉不必要的逻辑,只更新一个内存对象,不做任何存储和网络操作。
第二层,节流调度层。用一个5秒的定时器检查内存中的进度是否有更新,如果有,就触发上报。同时支持立即上报模式,比如暂停时。
第三层,传输层。统一处理请求、重试、失败队列、本地缓存。
这样可以做到不管播放器触发多少次timeupdate,真正产生的网络请求最多每5秒一次,而且上报的数据都是最新值。代码结构上更清晰,也方便后续扩展。
class ProgressReporter { constructor(video, options = {}) { this.video = video; this.interval = options.interval || 5000; this.latest = null; this.dirty = false; this.timer = null; this.batchQueue = []; this._bindEvents(); this._startTick(); } _bindEvents() { this.video.addEventListener("timeupdate", () => { this.latest = { progressSeconds: Math.floor(this.video.currentTime), durationSeconds: Math.floor(this.video.duration || 0) }; this.dirty = true; }); this.video.addEventListener("pause", () => this.flush(true)); this.video.addEventListener("ended", () => { this.flush(true); this.report({ ...this.latest, status: "completed" }); }); } _startTick() { this.timer = setInterval(() => this.flush(false), this.interval); } flush(force = false) { if (!this.dirty && !force) return; if (!this.latest) return; this.report(this.latest); this.dirty = false; } report(data) { // 实际发送逻辑 } }5.4 后端存储与查询的性能考量
前面代码里我用了一张 playback_progress 表来做演示。这张表的记录量会上涨得很快,因为每个用户看过的每个视频都会产生一条记录。查询接口通常需要根据 userId + contentId 加 episodeId 来查,所以索引设计要提前做好。
我建议建立联合索引:(user_id, content_id, episode_id)。同时,如果内容列表页要展示“最近观看”,就还要按updated_at倒序查询,那索引可以考虑(user_id, updated_at DESC)。这两个索引可以覆盖绝大多数场景。不要直接在没有任何索引的宽表上执行 where 查询,千万级数据一进来,接口必慢。
定期清理也可以做。很多用户看了一半就再也不看了,这些陈年进度记录一直占空间。可以按月定期清理三个月之前且 status 为 completed 或进度小于1%的记录。但要注意别把正在追剧的用户进度清了,清理条件要加上“最近更新时间超过 N 天”的限制。
数据量更大时,可以考虑把播放进度从关系型数据库迁到 Redis 之类的缓存里,用 Hash 结构按 userId 存储,再异步刷到数据库。不过对于绝大多数中小型产品,一张带索引的 MySQL 表完全够用了,没必要过早引入额外组件。
6. 小结之外的踩坑经验
最后说几个我在实际操作中印象比较深的点,都是一些容易被忽略的小细节。
第一个是进度恢复的“闪烁感”。播放器初始化时,如果从服务端拉进度比较慢,前端可能已经播放了几秒,然后进度返回了,再强制跳转,画面上就会一闪一闪。解决办法是把播放器初始化改成“等进度回来后,再调用 play”,而不是边加载边播。这需要接受一次额外的网络等待,但对体验是正向的。
第二个是 Android WebView 的特殊兼容问题。某些 Android 播放器的currentTime在视频未加载完成时读取的是 NaN,如果用 NaN 做进度上报,存储里就写入了一个非数字。所以上报之前必须做一次安全校验:Number.isFinite(currentTime),不满足就直接丢弃。这个校验能挡掉一大批脏数据。
第三个是“用户连续观看同一集”的情况。比如用户看到第9集,然后整个视频列表下滑,不小心又点进第9集,此时配合详情页自动定位到第9集的断点,是合理的。但有些产品会在用户已经主动选择了第10集之后,由于接口返回的是“最近观看第9集”,又把播放器拉回第9集。这个问题的根因是:进度恢复逻辑不应该在用户已经主动发起新的播放行为时覆盖用户的选择。实现时要注意,进入播放器时先等用户的播放意图,如果用户已经明确点击了某一集,就不应再应用恢复逻辑。
这个功能本身的代码量不算大,难的是把各种边界场景想清楚。尤其是多端同步、弱网容错、视频内容变更这些看似低频的问题,一旦在你的产品里爆发,排查难度远高于实现难度。所以一开始就把数据结构和服务端的乐观锁设计好,后面的返工成本会小很多。
如果你正准备开始做这个功能,我建议从最简单的单机 localStorage 版本起步,先把播放器采集和恢复链路跑通,再加入服务端上报和同步。这样的节奏可以让你快速上线验证,也能在推动服务端资源时给出足够有说服力的数据。我能想到的坑基本都在上面了,照着一套做下来,你的“继续观看”应该会比市面上一大半产品都稳。