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目录之外的地方避免被系统清理掉。下面这张表是本地存储方案的一个粗略对比方便选型时参考平台推荐方案优势注意点WeblocalStorageAPI简单、同步读写容量小多标签页并发覆盖需处理WebIndexedDB容量大、支持索引API复杂建议用封装库AndroidSharedPreferences轻量、读写快不适合存复杂结构多进程读取需留意AndroidRoom数据建模清晰、支持查询引入额外依赖体量略大iOSNSUserDefaults轻量、系统级兼容不适合存大量数据iOSCore 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事件触发后再设置currentTimeconst 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 恢复进度后黑屏闪烁症状用户一进播放页画面闪了一下或者看到一秒钟片头才跳到正确位置。原因大概率是播放器先从头position0开始播放了画面已经渲染出来然后我们再执行seek。哪怕只播了一帧用户也会注意到闪动。解决办法是设置startPosition而不只是init之后再seek。ExoPlayer的MediaItem构造时可以传startPositionMsWeb端可以在注册资源阶段就把currentTime设置好或者配合preloadmetadata早点拿元数据。真要全程黑屏也要保证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的差值就够了。把状态建模做对断点续播就是水到渠成的事。