1. 记忆进度之前,先把"记什么"这件事想明白
1.1 一次完整的断点续播链路
做视频播放器开发的朋友应该都有体会:断点续播这个功能听上去特别简单,一句话就能说清——"记录用户上次看视频的进度,并且从记录的时间继续观看"。但真正上手做的时候,你会发现这个功能横跨播放器状态管理、本地存储、服务端接口、多端同步、异常恢复好几个层面,任何一个环节偷懒,最终呈现出来的体验都会很糟。
我们先画一下这个功能完整跑通时的样子。用户打开视频详情页,点进播放页,播放器初始化,紧接着去读取历史播放记录,拿到上次的播放位置,然后等视频元数据加载完成,直接seek到那个位置继续播放。与此同时,播放过程中会每隔几秒把当前进度写到存储层;用户暂停、退出、切后台、播放结束这些关键时刻,也要触发一次进度保存。下次再进来,重复上面的流程。
这条链路看起来每个环节都直白得很,但实际开发中真正的难点在于:进度不是一个静止的数字,它是播放器运行过程中由无数个事件交织出来的"快照"。你什么时候取这个快照、存在哪、恢复的时候用什么样的时序去应用它,直接决定了用户看到的是"无缝续播"还是"黑屏一下跳进度"。
1.2 "进度"其实有三种不同的含义
很多初学者会以为,保存进度就是把播放器的currentTime拿出来存一下。但实际做下来你会发现,"进度"这个词在播放器里至少有三层含义:
第一层是播放位置(playback position),就是用户当前看到的那一帧对应的时间点,也就是currentTime。这是大多数人理解的"进度"。
第二层是缓冲位置(buffered position),播放器当前已经下载到内存里的数据覆盖到哪个时间点了。这个值影响到你要不要在seek之后等缓冲。
第三层是播放时长(duration),视频总长度。单独看currentTime是没意义的,必须和duration放在一起,才能判断进度是否合理,才能决定"接近片尾就认为看完了"这种阈值怎么定。
所以真正合理的一条进度记录,至少应该包含这几个字段:视频的唯一标识(videoId)、播放位置(position)、视频总时长(duration)、最后更新的时间戳(updatedAt),如果有用户体系,还要带上userId。如果视频有分集、清晰度、音轨之类的变体信息,建议也一并存下来,因为不同清晰度、不同音轨的资源时长可能有细微差异,恢复时如果拿错版本,seek的目标就可能超出边界。
1.3 为什么不能拍脑袋存一个秒数就完事
这里要专门说一下很多人踩过的坑:只存秒数,不存上下文。
举个例子,用户看到1小时02分35秒的位置退出,你存了"3755"这个数字。下次用户进来,如果视频源还是同一个文件,那没问题。但如果视频被重新转码了、片头被替换了、或者你更新了视频版本,那这个3755秒对应的内容可能跟上次完全不同。再比如用户上次用的是标清,这次网络好默认进了高清,如果两版的片头长度不一致,秒数对不上,续播就会偏。
所以一家成熟的做法是,不只存秒数,还要存储内容的指纹信息,比如视频的唯一ID、版本的MD5或者文件大小。恢复的时候先比对视频是否一致,不一致就不强行续播,或者做一次二次确认。这个细节在长视频、教学视频这种强连续性的场景下尤其重要,宁可让用户手动点一下,也不要让他带着错误预期看错内容。
2. 存储层设计:本地缓存和服务端上报怎么配合
2.1 本地存储选型:不同端的方案和取舍
进度存哪里,第一反应肯定是本地。本地存储的好处是快、离线可用、不依赖网络,主流的客户端平台都有现成的方案。
Web端,最简单的选择是localStorage。它同步读写、API简单,拿来做进度记忆绰绰有余。缺点是容量只有5MB左右,不适合存大对象,而且同一个浏览器下不同标签页共享同一份数据,要注意并发覆盖。稍微复杂一点的场景可以用IndexedDB,异步、容量大得多,但API更繁琐,一般用localforage这类库包一层。
Android端,常见选择是SharedPreferences(适合存轻量键值对)和Room数据库(适合存多条记录、做复杂查询)。如果只是记录每条视频的播放进度,SharedPreferences基本够用;但如果还要记录历史列表、用户多端互踢、缓存清理策略,建议直接上Room,数据模型更清晰,后续扩展更方便。
iOS端,NSUserDefaults是最快的方案,适合存简单键值;如果涉及云同步、跨设备,可以上Core Data或者直接用Keychain存敏感字段。要注意的是,iOS对本地文件的备份策略会直接影响进度数据在用户换机恢复时是否还在,如果不需要跨设备同步,建议把数据放在Library/Caches目录之外的地方,避免被系统清理掉。
下面这张表是本地存储方案的一个粗略对比,方便选型时参考:
| 平台 | 推荐方案 | 优势 | 注意点 |
|---|---|---|---|
| Web | localStorage | API简单、同步读写 | 容量小,多标签页并发覆盖需处理 |
| Web | IndexedDB | 容量大、支持索引 | API复杂,建议用封装库 |
| Android | SharedPreferences | 轻量、读写快 | 不适合存复杂结构,多进程读取需留意 |
| Android | Room | 数据建模清晰、支持查询 | 引入额外依赖,体量略大 |
| iOS | NSUserDefaults | 轻量、系统级兼容 | 不适合存大量数据 |
| iOS | Core Data | 结构化存储、支持迁移 | 上手成本高,数据迁移要预留兼容 |
2.2 跨设备续播:服务端的关键字段与更新策略
只存本地意味着用户换一台设备、清一次缓存、或者换个浏览器,进度就全丢了。对很多视频产品来说这是不可接受的,所以真实项目里基本都会加一条服务端上报链路。
服务端存一条进度记录,核心是一个复合主键:userId + videoId。下面的字段可以按需加:position、duration、updatedAt、deviceType、fromSource(比如是点播页还是推荐流带进来的)。存这些字段的目的不只是为了恢复播放,将来做"最近观看""猜你喜欢""续播率分析"这些功能时都能用上。
服务端更新策略有个很重要的原则:不是无脑覆盖,而是比较updatedAt。我见过不少团队在这个地方偷懒,客户端每次上报直接REPLACE,结果用户在手机上看到30分钟退出,平板上的进度还是10分钟,两条记录互相覆盖,最终进度越同步越乱。正确做法是客户端上报时带上本地最后保存的时间,服务端只接受比当前记录更新的数据。如果出现冲突(比如一个端上报的时间戳更早),服务端应该保留较新的那条,并把旧的那条回推给客户端作为修正。
另外,上报接口要做节流和合并。视频播放过程中每250ms就会触发一次timeupdate,如果每次都打接口,用户看半小时视频就能打几千个请求,纯粹是给自己制造性能压力。一般实践是:播放中最多每10秒上报一次,暂停、退出、切后台时强制上报一次,这样既保证了进度新鲜度,又不会把服务端打爆。
3. 从记录到恢复:几步核心实现逐一拆解
3.1 播放进度采集:事件监听与节流
先看采集端。无论是Web的<video>还是移动端的播放器SDK,播放进度的原始数据源都是一个高频事件。以Web为例,就是timeupdate,大概每250ms触发一次;Android的ExoPlayer对应的是AnalyticsListener.onPositionDiscontinuity或者定时轮询getCurrentPosition();iOS的AVPlayer则一般用addPeriodicTimeObserver。
事件触发很频繁,但我们的目标不是每次都保存,而是挑有价值的时机保存。下面是一段Web端比较稳的采集逻辑:
class PlaybackProgressTracker { constructor(videoElement, videoId, options = {}) { this.video = videoElement; this.videoId = videoId; this.storageKey = `playback_${videoId}`; // 默认每10秒落盘一次,外界可以覆盖 this.saveInterval = options.saveInterval || 10000; this.flushOnPause = options.flushOnPause !== false; this.lastSaved = 0; this._bindEvents(); } _bindEvents() { this.video.addEventListener('timeupdate', () => { const now = Date.now(); if (now - this.lastSaved >= this.saveInterval) { this._savePosition(); this.lastSaved = now; } }); this.video.addEventListener('pause', () => { if (this.flushOnPause) this._savePosition(); }); this.video.addEventListener('ended', () => { this._savePosition(0); // 播完就归零,下次从头播 }); document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') { this._savePosition(); } }); window.addEventListener('pagehide', () => { this._savePosition(); }); } _savePosition(forcePosition) { const position = forcePosition !== undefined ? forcePosition : this.video.currentTime; const record = { videoId: this.videoId, position: Math.floor(position), duration: this.video.duration, updatedAt: Date.now(), }; localStorage.setItem(this.storageKey, JSON.stringify(record)); } }这段代码里有两个容易被忽略的点。第一,pagehide事件一定要监听。beforeunload在移动端Safari上触发并不可靠,pagehide才是更通用的兜底。第二,播放结束要主动把进度归零,否则用户看完一集之后隔天进来,又从上一次80%的位置继续,体验非常离谱。
3.2 保存时机怎么选:不是越频繁越好
保存时机的设计,本质上是在"进度新鲜度"和"写入成本"之间做取舍。写入本地成本低,可以稍微频繁一点;写入服务端成本高,必须节流。
我为团队定过一套比较稳妥的规则,直接抄作业也行:
- 播放中,每隔10秒保存一次本地,同时判断距上次服务端上报是否超过15秒,超了就上报一次。
pause时立刻保存本地,并上报服务端。- 页面隐藏(切后台、看通知栏)时保存本地并上报,这非常关键,因为很多用户退出方式就是直接切走应用。
- 播放结束,本地和服务端都把进度归零。
- App完全退出或刷新前,再兜底保存一次本地。
这套策略的好处是,即使中间某个节点崩溃了,最多丢失10秒的进度,用户可以接受。而且因为每次退出时都有强保存,用户再次进入时恢复的进度通常非常接近退出点。
3.3 恢复进度:初始化后的seek时序问题
采集做好了,恢复阶段才是真正翻车高发区。最常见的bug是:播放器刚初始化就去seek,结果视频元数据还没有加载完,seek被播放器忽略,或者seek的目标值被内部缓冲逻辑修正,最终进度停在错误的位置上。
Web端的标准做法是先等到loadedmetadata事件触发后再设置currentTime:
const video = document.getElementById('player'); const saved = JSON.parse(localStorage.getItem('playback_video123')); if (saved && saved.position > 0 && saved.position < video.duration - 5) { video.addEventListener('loadedmetadata', () => { if (saved.duration === video.duration || Math.abs(saved.duration - video.duration) < 2) { video.currentTime = saved.position; } }); }Android端用ExoPlayer做同样的事情,更推荐封装成一个SeekWhenReady的帮助类:
class ResumePlaybackHelper( private val player: ExoPlayer, private val resumePositionMs: Long ) { fun prepare(source: MediaItem) { var resumed = false player.addListener(object : Player.Listener { override fun onPlayerStateChanged(playWhenReady: Boolean, playbackState: Int) { if (playbackState == Player.STATE_READY && !resumed) { if (resumePositionMs > 0) { player.seekTo(resumePositionMs) } resumed = true player.removeListener(this) } } }) player.setMediaItem(source) player.prepare() } }这里的关键是:必须先等播放器进入STATE_READY,再做seek。因为只有进入了READY状态,才能保证时长、视频宽度高度等元数据已经加载完成,此时seek才是有效的。
另外一个细节:seek的目标值要加一个安全边界。比如视频总长100秒,用户上次看99秒,那基本等于看完了,不要让他再花时间seek到99秒再黑屏一下,直接把进度归零从头播,或者弹一个"重新播放"选项。我一般用"距离片尾不足5秒就视为看完"这个规则。
3.4 从秒数到服务端记录:处理多端进度的交叉覆盖
多端场景下要处理的一个经典问题叫"交叉覆盖"。用户在手机上看电视剧看到第5集30分钟,退出了;晚上在平板上登录,接着看同一部剧第5集,看到60分钟。手机和平板都记录进度,如果不做处理,最终同步到服务端的可能是30分钟,也可能是60分钟,取决于哪条数据先到达。
解决办法很简单:服务端用updatedAt做最后写赢(last-write-wins),只保留更新时间最新的记录。客户端拉取进度时,发现拉回来的进度比本地新,就用服务端的值覆盖本地;反过来,发现本地进度比服务端新(比如本地离线看了一会儿),就主动把本地进度上报覆盖服务端。
这还不够保险,因为用户可能同时开着手机直播推流和平板客户端,服务端逻辑还必须校验客户端上报的updatedAt不能比当前记录旧。有一种更好的方案是服务端存一个单调递增的版本号,客户端每次拉取时带着版本号,上报时如果版本落后于服务端则拒绝。但大多数项目用时间戳就已经足够了,注意时区统一用UTC,避免不同时区的端互相覆盖出错。
4. 真实项目里绕不开的坑,以及排错思路
4.1 进度反复横跳:竞态条件
症状:用户恢复播放后,进度条明明显示在30分钟,播了几秒突然跳回10分钟,然后再跳到30分钟。
原因基本就两个:一是恢复流程里多次触发seek,后一次把前一次覆盖了;二是本地和服务端还有个旧进度,在播放过程中又被加载回来覆盖了当前进度。
排查思路是按事件顺序打日志,重点看这三个时间点:播放器初始化时读进度、ready后第一次seek、播放过程中是否还有异步任务读进度。我建议定一个死规矩:一次播放会话只允许恢复一次进度,恢复完成后立即把进度标志位置为"已恢复",后续任何存储层回调都不得再触发seek。这能直接干掉一整个类别的bug。
4.2 恢复进度后黑屏闪烁
症状:用户一进播放页,画面闪了一下,或者看到一秒钟片头才跳到正确位置。
原因大概率是播放器先从头(position=0)开始播放了,画面已经渲染出来,然后我们再执行seek。哪怕只播了一帧,用户也会注意到闪动。
解决办法是设置startPosition而不只是init之后再seek。ExoPlayer的MediaItem构造时可以传startPositionMs,Web端可以在注册资源阶段就把currentTime设置好,或者配合preload="metadata"早点拿元数据。真要全程黑屏,也要保证seek发生在播放器可见之前。
4.3 用户拖进度条后记忆的是"中间值"
症状:用户正在观看,突然手动拖到片尾,然后退出。下次进来不是从新位置续播,而是跳到之前停留的某个中间位置。
这是保存时机惹的祸。如果节流周期是10秒,而用户在两次保存之间拖动了进度条并直接退出,那么最后一次保存的可能是拖动前的旧位置。
这里建议在seeked事件后清零节流计时,或者立刻保存一次。用户在拖进度条这个动作本身就代表进度发生了突变,应该被特殊对待,而不是等定时器慢慢触发。
4.4 分片流和长视频的起播慢问题
症状:HLS或者DASH流,用户恢复进度后要等很久才开始播放,而且缓冲圈一直转。这是因为seek的目标时间片不在初始缓冲范围内,播放器要重新请求分片,冷启动成本高。
优化思路有几个:一是服务端支持#EXT-X-PLAYLIST-TYPE:VOD时,可以直接从目标分片开始下发;二是客户端提前预加载目标分片附近的几个分片;三是在seeked后主动调用播放器的prefetch接口。移动端还可以利用thumbnail预览做一个"快速起播"的假画面,让用户等待时感觉没那么久。
4.5 特殊场景处理:直播、广告、片尾和清除缓存
有几个边界场景必须列出来,否则上线后会被用户骂:
- 直播流不记忆进度:直播是一个不断前进的时间轴,记录的秒数没有意义。检测到流是直播(
duration无限或接近无限)时,直接禁用进度恢复。 - 前贴片广告:恢复进度时应该跳过广告,直接定位正片位置。如果正片是一段长视频,要在广告播放完成后再执行正片部分的seek,否则ad播放器会把seek事件吞掉。
- 用户主动清除历史:产品里一定要有"清除播放历史"入口,这不仅是需求问题,很多应用市场上架审核也会看有没有隐私相关设置。
- 进度过期:建议给进度记录加一个时效,比如7天前的进度不强制恢复,因为用户早就不记得当时的上下文了。
下面整理一份常见问题速查表,方便后续出问题时快速定位:
| 现象 | 可能原因 | 排查方向 | 推荐解法 |
|---|---|---|---|
| 进度跳回开头 | 元数据未就绪时执行了seek | 打印seek前后播放器状态 | 等STATE_READY/loadedmetadata后再seek |
| 进度反复横跳 | 多次异步恢复进度 | 查看会话内seek调用次数 | 恢复只允许一次,用标志位锁死 |
| 闪一下旧画面 | 播放器先播了0秒再seek | 观察首帧渲染时序 | 初始化时传startPosition,保证首帧前seek完成 |
| 拖进度条后记忆错乱 | 节流保存覆盖了seek后的新进度 | 检查seeked后是否触发了保存 | seeked事件后立即保存并重置节流计时 |
| 跨设备同步后进度倒退 | 老数据覆盖新数据 | 检查updatedAt是否比较 | 服务端做last-write-wins,客户端拉取时也做对比 |
| 播放结束进度还在 | ended时没有保存0 | 检查ended监听 | ended时主动归零 |
4.6 一个未被讨论过的点:播放进度的隐私与合规
顺带提一句,播放进度数据虽然看起来不起眼,但在某些领域可能涉及用户行为数据。如果你做的是教育、医疗、金融类App,建议在隐私政策里明确说明"我们会记录您上次播放的位置,用于提供续播功能",并且给用户提供关闭选项。虽然这听起来像法务的活,但开发阶段提前留好开关,后面会省很多事。
进度记忆这件事,做得粗糙,用户感知不强;做得精致,用户会觉得"这个App很懂我"。差别就在于你有没有在保存时机、恢复时序、多端一致性这些细节上较真。
最后分享一个我个人的体会:不要迷信事件驱动的保存策略,放弃"靠事件猜用户行为"的思路,改为"以播放器状态为准",会省掉大量边角bug。比如判断用户是否退出,不用猜他点了哪个按钮,直接监听应用生命周期;判断是否看完,不用等ended,看currentTime和duration的差值就够了。把状态建模做对,断点续播就是水到渠成的事。