上周一个做教育直播的同行把问题丢给我同一个 HLS 的 m3u8 地址安卓端 H5 用 hls.js 插件播得挺顺一到 iOS 的 H5 页面就黑屏控制台偶尔蹦一句Hls Error就没有然后了。他前后换了三个版本的 hls.js改了一堆配置项还是不行。我看了两分钟就笑了——不是插件版本的问题是 iOS 上这套技术路线本身就走不通。HLS 在 iOS 上有一条完全不同的通路系统原生支持 m3u8但偏偏不给浏览器的 MSE 接口而 hls.js 这类插件恰恰是踩在 MSE 上工作的。路线选错了配置怎么调都是白费。这篇文章就把这件事从头到尾讲清楚iOS 和各路 WebView 里 HLS 到底怎么播、hls.js 插件的适用边界在哪、双引擎方案怎么落地、索引和分片有哪些容易被忽略的硬性要求最后附上我自己踩过的坑和一份可以照抄的配置清单。不管你是刚接手 H5 播放器的新人还是被线上客诉追着跑的老手应该都能从里面找到能直接用的东西。1. 先别急着改代码iOS 上 hls.js 失效的真实原因1.1 HLS 在 iOS 上走的是另一条路先把最核心的事实摆出来HLS 这套协议本来就是苹果推出来的iOS 和 macOS 的系统播放器AVFoundation 那一层天生就能直接解析 m3u8你只要把地址丢给video srcxxx.m3u8剩下的分片下载、码率切换、音视频同步全部由系统完成H5 这一层压根不需要参与。这是 iOS 的原生能力不需要任何插件。而 hls.js 插件走的是另一条完全不同的技术路径。它的原理是自己用 XHR 把 m3u8 和 ts 分片拉下来然后通过MediaSource接口把数据一段段喂给 video 元素。这个接口就是MSEMedia Source Extensions本质上是一种让 JS 有能力自己造一条视频流的浏览器能力。问题就出在这iPhone 上的 Safari 长期不提供 MSE。也就是说 hls.js 赖以生存的地基在 iPhone 上压根不存在它自然什么都干不了。直到 iOS 17.1iPhone 上才出现了一个受限的ManagedMediaSource但它并不是桌面端那种可以任意 appendBuffer 的完整 MSEhls.js 依旧用不了。所以你会看到一个很反直觉的现象安卓 Chrome 上必须靠 hls.js 才能播的 m3u8在 iOS 上反而不需要任何插件直接扔给 video 就行。再补一个容易让人分裂的细节iPad 上情况不一样。iPadOS 13 之后 iPad 的 Safari 桌面化了MSE 是开着的所以同一个页面在 iPad 上Hls.isSupported()返回 truehls.js 能正常工作在 iPhone 上返回 false必须走原生。于是就出现了测试同学用 iPad 测都是好的用户拿 iPhone 一片黑这种经典事故。你要是不知道这层差异很容易把问题误判成兼容性问题去瞎改配置。1.2 hls.js 的能力边界isSupported 到底在判断什么很多人把Hls.isSupported()当成一个这个浏览器能不能播 HLS的判断这是个误解。它判断的其实是这个浏览器有没有可用的 MSE跟 HLS 协议本身没关系。源码层面的逻辑大致是检查window.MediaSource是否存在、MediaSource.isTypeSupported(video/mp4; codecsavc1.42E01E,mp4a.40.2)是否返回 true。在 iPhone Safari 上第一步就过不去直接返回 false。所以正确的判断逻辑应该是分层的而不是单点先看Hls.isSupported()为 true 说明 MSE 可用交给 hls.js 掌控你能拿到码率切换、缓冲统计、错误重试这些能力为 false 时再去看video.canPlayType(application/vnd.apple.mpegurl)iOS Safari 上会返回maybe说明系统原生能接两者都不行才需要提示当前环境不支持播放或者降级成 MP4 直链。这里有个坑得提醒某些安卓浏览器canPlayType也会返回maybe但实际播放会失败尤其是部分定制内核的 WebView它声称支持 HLS真播起来却卡在第一帧。所以我个人的习惯是主判Hls.isSupported()走 hls.js兜底才走原生并且给原生路径配一个错误监听一旦video.error报出来立刻切回 hls.js 或者提示重试而不是让用户对着黑屏发呆。还有一个细节iOS 上 hls.js 即使被你强行初始化attachMedia之后也不会有任何报错只是永远停在MEDIA_ATTACHING状态静悄悄地什么都不干。这种静默失败最坑人排查的时候一定要先确认isSupported()的返回值别一头扎进网络请求里查。1.3 一张环境矩阵表先定位你踩的是哪个坑不同运行环境对 HLS 的支持路径差别很大我在项目里一般会先画这么一张表对着表定位比盲猜快得多。运行环境渲染/播放内核MSE 可用推荐方案典型坑iPhone SafariWKWebView AVFoundation否原生video src误用 hls.js 静默失败iPad SafariWKWebView AVFoundation是iPadOS 13hls.js 或原生均可与 iPhone 表现不一致Mac Safari桌面 Safari是hls.js与 iOS 表现不一致iOS 微信内置浏览器WKWebView否原生video src全屏策略、自动播放限制安卓微信X5/XWeb 内核多数可用hls.js 为主内核差异大需实测App 内嵌 H5iOSWKWebView否原生需原生侧开内联播放强制全屏、需用户手势App 内嵌 H5安卓系统 WebView 或自研视内核而定hls.js需关闭必须用户手势这张表里最需要注意的是最后两行。App 内嵌 H5 场景下H5 侧怎么写只是一半原生容器那边的配置同样决定生死。iOS 的WKWebViewConfiguration里有allowsInlineMediaPlayback不打开的话视频在某些容器里会被强行拉去全屏播放mediaTypesRequiringUserActionForPlayback不设成WKAudiovisualMediaTypeNone自动播放就会被拦。安卓侧对应的是setMediaPlaybackRequiresUserGesture(false)。我在实际项目里遇到过好几回H5 代码一个字没改只是原生同学把这两个开关打开视频立刻就正常了——所以排查这类问题一定要把端上同学拉进群里一起看。2. 播放引擎选型原生与 hls.js 的双引擎方案2.1 能力探测的三行代码和它的坑能力探测这件事代码量很小但写错的概率很高。我见过不少项目是这么写的if (Hls.isSupported()) { // 用 hls.js } else { // 报错提示不支持 }这段代码在 iPhone 上会直接走进 else 分支然后给用户弹一个当前浏览器不支持视频播放但事实上 iPhone 完全能播。正确写法至少要有三层兜底const HLS_MIME application/vnd.apple.mpegurl; const video document.getElementById(player); const canPlayNative video.canPlayType(HLS_MIME) ! || video.canPlayType(application/x-mpegURL) ! ; let engine null; if (window.Hls Hls.isSupported()) { engine createHlsJsEngine(video, src); } else if (canPlayNative) { engine createNativeEngine(video, src); } else { showFallbackTip(); }这里有两个容易忽略的点。第一canPlayType的返回值有三个档位probably、maybe、只要不是空字符串就代表有一定支持能力不要写成 maybe因为不同内核返回probably的情况也存在。第二Hls这个全局变量本身可能不存在脚本没加载完、CDN 挂了所以window.Hls 这一层校验必须加上否则你的兜底逻辑会先因为Hls is not defined崩掉。再补一个实战经验探测要在拿到 video 元素之后做。canPlayType是 video 元素的方法不是全局方法。有些同学图省事写成document.createElement(video).canPlayType(...)虽然也能跑但如果你后面要对同一个元素做多次探测、或者元素上挂了自定义属性就会绕远路。直接用真实的那个元素最稳。2.2 统一播放器封装对外只暴露一套接口双引擎的麻烦在于两套 API 完全不一样hls.js 用的是hls.loadSource()hls.attachMedia()还能监听Hls.Events.ERROR原生走的是video.src直接赋值错误只能从video.error里拿。如果业务层到处if (engine hls)代码很快就会烂掉。我的做法是包一层统一的播放器对象对外只吐这几个方法play()、pause()、seekTo()、switchQuality()、destroy()内部用策略对象分别实现。function createHlsJsEngine(video, src) { const hls new Hls({ maxBufferLength: 30, maxMaxBufferLength: 60, enableWorker: true, lowLatencyMode: false, }); hls.loadSource(src); hls.attachMedia(video); hls.on(Hls.Events.ERROR, (event, data) { if (!data.fatal) return; if (data.type Hls.ErrorTypes.NETWORK_ERROR) hls.startLoad(); else if (data.type Hls.ErrorTypes.MEDIA_ERROR) hls.recoverMediaError(); else hls.destroy(); }); return { play: () video.play(), seekTo: (t) { video.currentTime t; }, destroy: () hls.destroy(), }; } function createNativeEngine(video, src) { video.src src; const onError () { const err video.error; console.warn(native video error, err err.code, err err.message); }; video.addEventListener(error, onError); return { play: () video.play(), seekTo: (t) { video.currentTime t; }, destroy: () { video.removeEventListener(error, onError); video.removeAttribute(src); video.load(); }, }; }注意 hls.js 那段的错误处理逻辑只有data.fatal为 true 时才需要干预。非致命的错误 hls.js 内部会自己重试你在外层再加一层重试反而会打架导致请求风暴。另外NETWORK_ERROR通常对应网络抖动或分片 404重试startLoad()就够了MEDIA_ERROR多是解码问题recoverMediaError()会尝试换个 buffer 重建如果是manifestParsingError这一类重试没有意义直接报错更合适因为索引本身就有问题。原生引擎那段我也加了 error 监听原因很简单iOS 原生播放器失败时页面是完全静默的日志里什么都没有不加监听你连用户报的是哪种错都不知道。video.error.code的取值是固定的四个1 是用户中止2 是网络错误3 是解码错误4 是源不支持或格式不兼容。在 iOS 上最常见的是 4它通常意味着 m3u8 索引本身不合法或者 404而不是编码问题——这个区分很重要方向错了会白查半天。2.3 实例销毁与资源回收别让页面越用越烫单页应用里播放器组件被反复挂载卸载是很常见的这里如果处理不好会出两种典型症状一是切了好几次视频之后手机开始发烫、掉帧二是内存涨上去就不下来了。hls.js 内部会持有 Worker、定时器、buffer 队列destroy()必须显式调用光把 video 的 src 清掉是不够的。我在组件卸载钩子里固定做两件事onBeforeUnmount(() { engine engine.destroy(); engine null; video.pause(); video.removeAttribute(src); video.load(); });原生引擎那边的video.load()是个关键动作。它会让浏览器重新走一遍资源加载流程把已经缓冲的分片释放掉。如果你只removeAttribute(src)而不调load()某些 iOS 版本上缓冲数据会一直挂着切十个视频就能明显感觉到卡顿。还有一个细节很多人不知道iOS 原生播放 m3u8 时会自己做缓存而且这个缓存是按 URL 走的。同一路径不同 query 参数会被认为是不同的资源。这个特性有两个用处一个坏处。用处是切换清晰度时可以通过加不同的 query 强制刷新坏处是如果你用的是带签名、会过期的地址缓存命中之后即便签名过期了它也不重新请求表现就是明明刷新了页面还是播的旧内容。我在后面第 6 节还会专门讲这个坑怎么绕。3. 让 iOS 真的播起来属性、编码与容器配置3.1 playsinline 与自动播放的三个前提iOS 上的 video 元素有一堆历史包袱式的行为最典型的是默认全屏播放。在早年iPhone 上点一下 video 就会全屏页面里的内联播放根本不存在。现在虽然支持了但必须显式声明video idplayer playsinline webkit-playsinline x5-playsinline x5-video-player-typeh5 preloadauto controls /video这几个属性的分工不一样别只写一个。playsinline是标准属性现代 iOS 认它webkit-playsinline是老版本 iOS 认的前缀写法为了兼容还在用旧系统的设备x5-playsinline和x5-video-player-type是安卓微信 X5 内核的私有标记写上能避免它劫持成全屏播放器。四个都写不冲突成本也低我一般是全加上。自动播放这块iOS 的规则是必须同时满足静音和内联播放两个条件才有机会自动播。缺一个都会被拦。而且即便满足video.play()返回的 Promise 也可能被 reject抛NotAllowedError所以调用处一定要 catchvideo.muted true; const p video.play(); if (p p.catch) { p.catch((e) { console.warn(autoplay blocked, e.name); showPlayButton(); }); }还有一个体验上的坑静音自动播放成功后用户想听声音得点一下取消静音而**video.muted false这个动作必须在用户手势的回调里执行**不能在定时器或者 Promise 里异步执行否则会被判定为非用户触发而失败表现就是点了按钮音量图标变了但没声音。我踩过这个坑后来统一改成按钮的 click 事件里同步执行。3.2 视频编码与切片规格的硬性要求编码这块的坑比很多人想的要多。hls.js 解码失败时你还能看到详细报错iOS 原生播放器则是直接黑屏给你看什么都不说。所以源本身的规格必须提前对齐。视频轨必须用 H.264Profile 建议卡在 Baseline 或 MainLevel 别超过 4.1音频轨必须是 AAC-LC采样率 44.1kHz 或 48kHz声道数 2。这些不是绝对红线但超出范围的组合在 iOS 上失败概率显著高于安卓。我遇到过一版源用了 HEVC 编码安卓 Chrome 因为落到了平台解码器上还能播iOS Safari 直接拒绝换成 H.264 立刻正常。还有一种情况是音频用了 PCM 或者不太主流的采样率视频画面能出来但一直没声音用户投诉视频是哑的查了半天才发现是编码问题。分片规格方面GOP关键帧间隔建议和分片时长对齐常见做法是分片 2 到 4 秒。如果 GOP 比分片长切分片时会找不到独立可解码的关键帧iOS 上容易出现起播慢、拖动卡顿。音频和视频的 PTS 起点要对齐否则音画会漂移这种问题在短视频里不明显长视频播到后面能差出好几秒。如果用 fMP4也就是 CMAF 那套切片m3u8 里必须有#EXT-X-MAP指向 init 段并且#EXT-X-VERSION要大于等于 7。iOS 10 之后是支持 fMP4 的但索引里少了 MAP 或者版本号写小了原生播放器会直接判为不支持。这一点在 hls.js 上反而宽松些所以就出现了安卓能播 iOS 不能播的分裂现象。3.3 App 内嵌 WKWebView 的额外开关如果你的 H5 是嵌在 App 里的现在大部分场景都是那有一半的工作在原生那边。iOS 的 WKWebView 需要关注这几个配置allowsInlineMediaPlayback允许内联播放。iOS 10 之后 iPhone 上默认是开的但 iPad 和部分定制容器里默认不开会强制全屏。我的建议是无论默认值如何都显式设成 true。mediaTypesRequiringUserActionForPlayback设成WKAudiovisualMediaTypeNone才能允许自动播放默认值是会拦截的。如果页面里用到了 canvas 截图或者需要读像素还得注意 CORS 配置否则 canvas 会被污染。安卓 WebView 侧对应的是setMediaPlaybackRequiresUserGesture(false)和setJavaScriptEnabled(true)。另外部分 App 会自己接管 video 标签把播放交给自己的播放器内核这时候 H5 侧的playsinline和样式可能全部失效表现就是页面里的小窗口突然变成全屏播放器还有自己的 UI。遇到这种情况别在 H5 里挣扎直接找端上确认是不是接管了播放。我在项目里形成的习惯是上线前拿一个最小化的 Demo 页面把 H5 代码固定在那一版让端上同学分别用开/关这两组配置各跑一遍。这样能一次性把H5 的问题和容器的问题分清楚比事后一层层甩锅高效得多。4. 索引与分片排查实录从 .png 分片说起4.1 m3u8 自查清单iOS 原生的容错比 hls.js 低得多这是我最想强调的一点同一个 m3u8hls.js 能播不代表 iOS 原生能播。原生播放器的校验严格得多很多在 Chrome 上被宽容处理的语法问题在 iOS 上是硬报错。所以当你从安卓切到 iOS 时第一件事不是改代码是拿索引去体检。下面这份清单是我自己每次排查都会过一遍的检查项要求不满足时的表现首行必须是#EXTM3U整体解析失败#EXT-X-VERSION与所用特性匹配fMP4 需 7iOS 报源不支持#EXT-X-TARGETDURATION不小于单个分片的最大时长iOS 报错hls.js 可能容忍#EXTINF与真实分片时长偏差合理起播慢、拖动异常#EXT-X-ENDLIST点播流必须存在被当成直播无法 seek#EXT-X-MAPfMP4 切片必须存在无法解码文件编码UTF-8 无 BOM首行解析失败换行符LF 或 CRLF 皆可不能混偶发解析异常URI 转义特殊字符需正确编码分片 404其中#EXT-X-TARGETDURATION这一条我要单独说说因为它太隐蔽了。这个字段的含义是所有分片时长的上限向上取整。假设你的分片实际时长是 4.2 秒那 TARGETDURATION 至少要写 5如果你写了 4hls.js 可能睁一只眼闭一只眼过去了iOS 原生播放器会直接判定清单不合法。我遇到过一次线上事故就是切片工具算错了这个值安卓一切正常iOS 全量黑屏最后就是改这一个数字解决的。另外#EXT-X-ENDLIST的缺失也值得警惕。点播流如果没有这个标记iOS 会按直播流处理表现形式是能播但进度条拖不动或者拖了之后回到起点。很多人以为是前端 seek 逻辑写错了其实是索引里少了一行。4.2 分片后缀与 Content-Type 错配怎么查有一种情况挺有意思也是最近被问到比较多的m3u8 语法本身完全合法是一份正常的点播清单但分片链接全部以.png结尾实际返回的却是 MPEG-TS 数据。这种扩展名和内容不一致的配置在历史项目里确实存在原因通常是 CDN 只放行了图片类扩展名或者早期为了统一缓存策略做过特殊处理。这种流在不同引擎下的表现差异很大值得展开说说。hls.js 这边它拉分片用的是 XHR 加responseType: arraybuffer拿到的是一段二进制它并不关心 URL 以什么结尾也不会去校验分片的 Content-Type。所以只要服务端老老实实把 TS 字节流吐出来hls.js 就能正常播你甚至感觉不到异常。iOS 原生播放器就不一样了。它对资源类型的判断更依赖响应头和内容嗅探一旦发现不匹配可能直接拒绝加载。所以排查这类问题时你要做的是绕过后缀看真实响应用抓包工具或者浏览器 Network 面板打开一个分片 URL看Content-Type到底是什么把响应体存下来用file命令或者十六进制查看器看头几个字节。TS 流一般以0x47开头也就是 ASCII 里的G如果开头是 PNG 的魔数89 50 4E 47那说明服务端确实返回了图片比较响应体大小和#EXTINF推算的码率是否吻合。如果确认是 TS 但声明成了image/png服务端那边至少要把Content-Type改成video/mp2t或者application/octet-stream。还有一种更隐蔽的坑某些图片 CDN 会对.png做自动无损压缩或者二次处理那 TS 数据就被破坏了表现是解码错误、花屏、卡在某一帧。这种情况只能改路径或者换 CDN 策略前端层面无解。顺带说一个相关的缓存问题。按扩展名分缓存策略的 CDN通常会给图片设置很长的缓存时间比如 30 天。如果分片挂在.png路径下直播场景会出现一直播旧分片的诡异现象因为 CDN 把分片按图片缓存住了。点播场景问题不大直播场景是致命的。4.3 加密流的播放要点EXT-X-KEY 与密钥获取HLS 支持 AES-128 加密索引里通过#EXT-X-KEY声明#EXT-X-KEY:METHODAES-128,URIhttps://example.com/key?tokenxxx,IV0x1a2b3c...播放侧其实不需要你手动解密hls.js 和 iOS 原生播放器都会自动去拉这个 key 然后解密分片前提是 key 能被正常获取。这里有几个特别容易翻车的地方。第一是 key 的跨域问题。hls.js 走 XHR 拉 key必须带上 CORS 头原生播放器则不受 CORS 限制。所以会出现切到原生之后就好了的假象实际上是 CORS 配置的问题被掩盖了。如果你的 key 服务和页面不同域务必让服务端配上Access-Control-Allow-Origin。第二是 key 的鉴权。iOS 原生播放器拉 key 时不会带你的自定义请求头只认 URL 里的参数。所以如果你的 key 地址是靠Authorization头鉴权的在 iOS 上一定失败。正确做法是把签名放到 query 参数里并且签名有效期要覆盖整个播放时长——因为直播流播放过程中会周期性重新拉 key如果签名只有 5 分钟有效期播着播着就断了。第三是 IV 的处理。如果索引里显式写了 IV就用这个 IV如果没写规范规定用分片序号作为 IV。这个细节在服务端和播放端理解不一致的时候表现就是画面全花。排查时把索引里的 IV 抄下来和切片时的加密参数对一对一般就能发现。我们团队后来形成一个约定所有加密流的 key 地址都必须能在浏览器地址栏里直接打开并返回 16 字节内容。这个简单的自检动作能挡掉八成的密钥问题。5. 报错定位错误码对照与常见问题速查5.1 hls.js 错误事件怎么读hls.js 的错误事件里有两个字段最关键type和details。type只有三个值networkError、mediaError、otherError。details更细标明了具体环节。我把常用的几个整理一下details 值含义常见原因处理方式manifestLoadError索引加载失败404、跨域、地址过期检查网络与 CORSmanifestParsingError索引解析失败语法不合法、编码有 BOM修索引levelLoadError码率层级加载失败子清单不可达检查主清单 URIfragLoadError分片加载失败404、签名过期重试或刷新签名fragParsingError分片解析失败数据损坏、后缀伪装查响应体是否被改写bufferAppendError数据入缓冲失败编码不兼容换编码或降级bufferStalledError缓冲停滞缓冲耗尽、码率过高降码率或增大缓冲判断是否要处理看data.fatal。真致命的时候才动手networkError走startLoad()mediaError走recoverMediaError()其他类型直接销毁并报错更干净。特别要提醒的是fragLoadError不要无脑重试如果是签名过期重试一百次也是失败只会在日志里刷出一堆请求。合理做法是给重试设一个上限超过就触发上层刷新播放地址。原生的错误码前面提过了再补一句iOS 上video.error.message有时会带上比较具体的描述比如提示清单格式或者分段相关问题这个信息比code值钱得多日志一定要打出来。5.2 常见问题速查表下面这张表是我这几年攒下来的基本覆盖了 iOS H5 播 HLS 的绝大多数现场问题。按症状查比按原因查效率高。现象大概率原因排查动作iPhone 黑屏无报错用了 hls.jsMSE 不可用打印Hls.isSupported()iPad 正常 iPhone 不正常iPad 有 MSEiPhone 没有同上做环境区分能播但自动全屏缺 playsinline 相关属性补齐三个属性有画面没声音音频编码不符非 AAC-LC检查音频轨规格点了播放没反应自动播放被拦Promise 被 rejectcatch 并引导用户点击进度条拖不动索引缺#EXT-X-ENDLIST补上该标记播放几秒后卡住签名过期或 CDN 缓存旧分片查分片请求与响应时间画面花屏加密 IV 不一致或数据被改写对比 IV 与响应体切清晰度黑屏很久原生切源需要重新加载记录时间点并回 seek播放久了页面发烫hls.js 实例未销毁检查 destroy 调用我特别想说播放几秒后卡住这一条。它的迷惑性在于用户描述的往往是网络不好但真实原因常常是签名过期。分片 URL 上带的签名有效期如果是 60 秒而视频缓冲了 30 秒播到第 40 秒需要拉新分片时就 403 了。排查方法是看 Network 里失败请求的响应头如果返回带时间戳的错误说明基本就能确认。解决办法要么延长签名有效期要么在播放器里监听错误后自动刷新地址重载。5.3 真机调试把 iOS Safari 的网页检查器用起来iOS 上的问题光靠加 console.log 效率太低一定要用真机调试工具。流程是iPhone 上打开设置 - Safari - 高级 - 网页检查器然后用数据线连到电脑在桌面 Safari 的开发菜单里选中你的设备就能打开当前 H5 页面的完整开发者工具Network、Console、Elements 全都有。这套工具能让你看到几个关键信息分片请求的真实响应头、m3u8 的响应内容、video.error的具体值。我遇到过好几次以为是编码问题一看 Network 发现是 404省掉大量瞎猜。如果 H5 是嵌在 App 里的 WKWebView 中普通连线可能看不到这时候可以让端上同学在开发包里开启 Web Inspector 支持inspectable设为 true。实在不行就用抓包工具看 HTTP 层虽然看不到 console但请求和响应足够定位大部分网络类问题。还有一个小技巧iOS Safari 对 m3u8 会做缓存有时候你改了服务端的清单真机上却还是旧的。这时候在开发者工具的 Network 面板里勾上停用缓存或者手动改一下 query 参数能避免很多改了没生效的乌龙。6. 几个踩过的坑和我的处理习惯6.1 清晰度切换在原生播放器上的代价在 hls.js 上切清晰度是很轻的动作调用hls.currentLevel n就行缓冲可以复用。但在 iOS 原生播放器上它没有暴露这种能力你只能改video.src重新加载代价是黑屏一下、缓冲清空、播放位置回到 0。处理办法是手动记位置再回跳function switchToNative(video, newUrl) { const t video.currentTime; const wasPlaying !video.paused; video.src newUrl; const onLoaded () { video.removeEventListener(loadedmetadata, onLoaded); if (t 0) video.currentTime t; if (wasPlaying) video.play().catch(() {}); }; video.addEventListener(loadedmetadata, onLoaded); }这段逻辑看着简单但有几个细节值得注意。loadedmetadata触发时视频的时长信息才可用这时候 seek 才有效如果提前 seek 会被忽略。另外新地址最好拼一个唯一的时间戳参数避免命中旧缓存。切换过程中的 loading 态要自己维护不要指望 video 元素给你状态。还有一个体验优化切换前先把 loading 蒙层显示出来等playing事件触发再隐藏。不然用户会看到一块黑屏闪一下观感很差。6.2 带签名 URL 过期与缓存引发的黑屏前面提过的签名问题这里展开讲一个完整的处理套路。我们的播放地址是服务端下发的带 10 分钟有效期。线上出现过用户暂停超过 10 分钟再点播放直接黑屏的情况。排查路径是这样的先看video.error.code是 4源不支持再抓包看分片请求返回 403确认是签名过期。最后的方案是加了两个机制一是播放器监听error事件一旦发现是网络类错误就回调上层重新拉一次播放地址二是页面重新获得焦点visibilitychange时检查地址签发时间超过阈值就主动刷新。这里要注意重新拉地址之后不要直接赋给video.src因为那样会丢失播放进度。正确做法还是走上面切换清晰度的那套逻辑记时间点、换源、回跳。我们上线之后这类客诉基本归零了。另外提醒一句iOS 原生对 m3u8 有缓存刷新地址时务必在 URL 上拼一个变化的参数否则拿到的可能是缓存里的旧清单里面还是过期的分片地址。6.3 我现在的默认配置清单最后把我在新项目里固定会用的那份配置清单放出来可以当模板抄。HTML 侧video idplayer classplayer playsinline webkit-playsinline x5-playsinline x5-video-player-typeh5 preloadauto controls muted /videoJS 侧的判断顺序是window.Hls Hls.isSupported()走 hls.js否则看canPlayType走原生都不行给降级提示。hls.js 的初始化参数用enableWorker: true、maxBufferLength: 30、maxMaxBufferLength: 60lowLatencyMode根据是不是直播决定点播关掉能省不少 CPU。错误处理上hls.js 只处理fatal错误networkError重试startLoadmediaError走recoverMediaError其他销毁并上报原生则监听error把code和message都打到日志里其中 code 为 4 时优先怀疑索引和地址而不是编码。组件卸载时固定执行destroy()、pause()、removeAttribute(src)、load()四连一个都不省。上线前跑一遍真机验证清单iPhone Safari、iOS 微信、安卓微信、App 内嵌各走一遍重点看自动播放、进度条拖动、切清晰度、播放 10 分钟以上四个场景。这几步花不了半小时但能挡掉线上绝大多数问题——毕竟这类问题一旦漏到线上用户只会告诉你视频打不开剩下的都得你自己猜。