Post

iOS HLS Cache 实作全攻略|AVPlayer 串流边播边快取技术解析

针对 iOS AVPlayer 播放 HLS m3u8 串流时无法拦截快取的痛点,深入分析多种快取方案,从 AVAssetResourceLoaderDelegate 到 Reverse Proxy Server,揭示技术瓶颈与可行解法,助你优化串流体验并有效节省伺服器与流量成本。

iOS HLS Cache 实作全攻略|AVPlayer 串流边播边快取技术解析

Click here to view the English version of this article.

點擊這裡查看本文章正體中文版本。

基于 SEO 考量,本文标题与描述经 AI 调整,原始版本请参考内文。


iOS HLS Cache 实践方法探究之旅

使用 AVPlayer 播放 m3u8 串流影音档时如何做到边播放边 Cache 的功能

photo by [Mihis Alex](https://www.pexels.com/zh-tw/@mcraftpix?utm_content=attributionCopyText&utm_medium=referral&utm_source=pexels){:target="_blank"}

photo by Mihis Alex

[2023/03/12] Update

我将之前的实作开源了,有需求的朋友可直接使用。

  • 客制化 Cache 策略,可以用 PINCache or 其他…

  • 外部只需呼叫 make AVAsset 工厂,带入 URL,则 AVAsset 就能支援 Caching

  • 使用 Combine 实现 Data Flow 策略

  • 写了一些测试

关于

HTTP Live Streaming (简称HLS) 是苹果提出基于HTTP的串流媒体网络传输协议。

以播放音乐来说,非串流情况下我们使用 mp3 作为音乐档,这个档案有多大就要花多久时间全部下载下来才能播放;而 HLS 就是把一个档案分割成多个小档案,读到哪播到哪,所以拿到第一个分割区块就能开始播放,不用整个都下载完!

.m3u8 档就是纪录这些分割的 .ts 小档案的码率、播放顺序、时间 还有整个音讯的资讯,另外也可以做加解密保护、低延迟直播…等等

.m3u8 档范例(aviciiwakemeup.m3u8):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-ALLOW-CACHE:YES
#EXT-X-TARGETDURATION:10
#EXT-X-MEDIA-SEQUENCE:0
#EXTINF:9.900411,
aviciiwakemeup–00001.ts
#EXTINF:9.900400,
aviciiwakemeup–00002.ts
#EXTINF:9.900411,
aviciiwakemeup–00003.ts
#EXTINF:9.900411,
.
.
.
#EXTINF:6.269389,
aviciiwakemeup-00028.ts
#EXT-X-ENDLIST

*EXT-X-ALLOW-CACHE 已在 iOS≥ 8/Protocol Ver.7 deprecated ,有没有这行都没有用意义了。

目标

对于一个影音串流服务, Cache 非常之重要 ;因为每个音讯档案小则 MB 大则几 GB ,如果每次重播都要再从伺服器拉一次档案,对 Server 的 Loading 来说非常吃力,而且流量都是 \(\) ,如果有个 Cache 层能为服务节省许多金钱,对使用者来说也不用浪费网路、浪费时间重新下载;是一个双赢的机制 (但要记得设定上限/定时清除,避免把使用者的设备塞爆)。

问题

以往非串流时 mp3/mp4 没什么好处理的,就是在播放前先下载到设备上,下载完成才开始播放;反正不管怎样都要载完才能播,那不如我们自己用 URLSession 下载完档案后再喂 file:// 下载在本地的档案路径给 AVPlayer 做播放即可;或正规方式,使用 AVAssetResourceLoaderDelegate 在 Delegate 方法中对下载的资料进行 Cache 缓存。

遇到串流想法其实也很直白,就是先读 .m3u8 档,然后在解析里面的资讯,对每个 .ts 档做 Cache 即可;但实作发现事情没有这么简单,处理难度超乎我的想像,所以才会有此篇文章!

播放部分我们一样直接使用 iOS AVFoundation 的 AVPlayer,在操作上串流/非串流档案没有差异。

Example:

1
2
3
let url:URL = URL(string:"https://zhgchg.li/aviciiwakemeup.m3u8")
var player: AVPlayer = AVPlayer(url: url)
player.play()

2021–01–05 更新:

我们退而求其次退回去使用 mp3 档,这样就能直接使用 AVAssetResourceLoaderDelegate 进行实作,详细实作可参考「 AVPlayer 边播边 Cache 实战 」。

实践方案

针对我们的目标能达成的几个方案及实践时遇到的问题。

方案 1. AVAssetResourceLoaderDelegate ❌

第一个想法就是,那我们就照 mp3/mp4 时的做法就好啦!一样用 AVAssetResourceLoaderDelegate 在 Delegate 方法中缓存 .ts 档案。

不过很抱歉,此路不通,因为无法在 Delegate 中拦截到 .ts 档案的下载请求资讯,可以在这则 问答官方文件 上确切此事。

AVAssetResourceLoaderDelegate 实作可参考「 AVPlayer 边播边 Cache 实战 」。

方案 2.1 URLProtocol 拦截请求 ❌

URLProtocol 也是最近才学到的方法,所有基于 URL Loading System 的请求 (URLSession、Call API、下载图片…) 都可以被我们拦截下来修改 Request、Response 然后再返回,一切就像没发生一样,偷偷来;关于 URLProtocol 可以参考 此篇文章

应用此方法,我们打算拦截 AVFoundation AVPlayer 在要求 .m3u8.ts 的请求时,拦截下来然后如果本地有 Cache 就直接返回 Cache Data,没有则再真的再发 Request 出去;这样也能达到我们的目标。

一样,很抱歉,此路也不通;因为 AVFoundation AVPlayer 的请求不是在 URL Loading System 上,我们无从拦截。 *有一说是 模拟器上可以但实机上不行

方案 2.2 暴力让他能进 URLProtocol ❌

根据 方案 2.1 脑洞大开的暴力法,如果我把请求网址换成一个自订的 Scheme (EX: streetVoiceCache://),因 AVFoundation 无法处理这个请求,所以会丢出来,这样我们的 URLProtocol 就能拦截到,做我们想做的事。

1
2
3
let url:URL = URL(string:"streetVoiceCache://zhgchg.li/aviciiwakemeup.m3u8?originSchme=https")
var player: AVPlayer = AVPlayer(url: url)
player.play()

URLProtocol 会拦截到 streetVoiceCache://zhgchg.li/aviciiwakemeup.m3u8?originSchme=https ,这时我们只要帮他还原成原来的网址,然后发个 URLSession 去要资料就能在这边自己做 Cache;m3u8 中的 .ts 档案请求一样也会被 URLProtocol 拦截到,一样我们能在这自己做 Cache。

一切看似都那么完美,但当我兴高采烈的 Build-Run 完 APP 后,苹果直接搧了我一巴掌:

Error: 12881 “CoreMediaErrorDomain custom url not redirect”

他不吃我给 .ts 档案 Request 的 Response Data,我只能用 urlProtocol:wasRedirectedTo 这个方法 redirectTo 原始 Https 请求才能正常播放,即使我把 .ts 档案下载到本地然后 redirectTo 那个 file:// 档案;他也不接受,查 官方论坛 得到答案就是不能这样做; .m3u8 只能是来源于 Http/Https (所以即使你把整个 .m3u8 还有所有分割档 .ts 都放在本地,有无法使用 file:// 给 AVPlayer播放),另外 .ts 也不能使用 URLProtocol 自行给予 Data。

fxxk…

方案 2.2–2 同方案 2.2 但是搭配 方案 1 AVAssetResourceLoaderDelegate 来实现 ❌

实作方式如方案 2.2 ,喂给 AVPlayer 自订的 Scheme 让他进 AVAssetResourceLoaderDelegate;然后我们在自己处理。

同 2.2 结果:

Error: 12881 “CoreMediaErrorDomain custom url not redirect”

官方论坛 同样的回答。

可以拿来做解密处理(可以参考 此篇文章此范例 )但还是无法实现 Cache 功能。

方案 3. Reverse Proxy Server ⍻ (可行,但非完美)

这个方法是在找如何处理 HLS Cache 时,最多人给的答案;就是在 APP 上起一个 HTTP Server 做 Reverse Proxy Server 服务。

原理也很简单,APP 上 On 一个 HTTP Server 假设是 8080 Port,网址就会是 http://127.0.0.1:8080/ ;然后我们可以对连进来的 Request 做处理,给出 Response。

套用到我们的案例就是,把请求网址换成: http://127.0.0.1:8080/aviciiwakemeup.m3u8?origin=http://zhgchg.li/

在 HTTP Server 的 Handler 上对 *.m3u8 拦截处理,这时有 Request 进来就会进到我们的 Handler 中,看我们想干嘛就干嘛,想 Response 什么 Data 都是我们自己控制, .ts 档同样会进来;这边就可以做我们想做的 Cache 机制。

对 AVPlayer 来说就是个 http://.m3u8 的标准串流音讯档,所以不会有任何问题。

完整实作范例可参考:

因为我也是参考此范例做的,所以 Local HTTP Server 的部分我也是使用 GCDWebServer ,另外还有更新的 Telegraph 可以使用。( CocoaHttpServer 太久没更新就不推荐用了)

看起来不错!但有个问题:

我们的服务是音乐串流而非影音播放平台,音乐串流很多时候使用者都是在背景执行音乐切换的;这时候 Local HTTP Server 还会在??

GCDWebServer 的说明是当进入背景时会自动断线、回前景自动恢复,但可以透过设置参数 GCDWebServerOption_AutomaticallySuspendInBackground:false 不让他有这个机制。

但是实测如果一段时间没有发送请求 Server 还是会断线 (且状态会是错的,还是 isRunning) 感觉就是被系统砍了;深掘了 HTTP Server 的做法 后发现底层都是基于 socket,查了 官方对 socket 服务的文件 后,此缺陷是无法解决的,本来在背景下没有新的连接时就会被系统暂停。

*网路上有找到很绕的方法…就是发个长请求、或不断发空的请求确保 Server 在背景不会被系统暂停掉。

以上都是针对 APP 在背景的状况,在前景时 Server 很稳,也不会因为闲置被暂停,没这问题!

是说毕竟是依赖在其他服务上,开发环境测试没问题,实际应用也建议要接个 rollback 处理(AVPlayer.AVPlayerItemFailedToPlayToEndTimeErrorKey 通知);否则有个万一服务挂掉,使用者会卡死。

所以说不完美啊…

方案 4. 使用 HTTP Client 本身的 caching 机制 ❌

我们的 .m3u8/.ts 档的 Response Headers 都有给予 Cache-ControlAgeeTag … 这些 HTTP Client Cache 资讯;我们的网站 Cache 机制在 Chrome 上使用也完全没问题,另外也在官方新的针对 Protocol Extension for Low-Latency HLS (低延迟HLS) 初步规格文件中提到 Cache 的地方也看到可以设定 cache-control headers 来做缓存。

但实际 AVFoundation AVPlayer 并没有任何 HTTP Client Caching 效果,此路也不通!单纯痴人说梦。

方案 5. 不使用 AVFoundation AVPlayer 播放音讯档 ✔

自己实现音讯档解析、缓存、编码、播放功能。

太硬核了,需要很深的技术能力及大量时间;没研究。

附上一个网路开源播放器做参考: FreeStreamer ,真要选择此方案不如站在巨人的肩膀上,直接用第三方套件了。

方案 5–1. 不使用 HLS

同方案 5 , 太硬核了,需要很深的技术能力及大量时间;没研究。

方案 6. 将 .ts 分割档转成 .mp3/.mp4 档案 ✔

没研究,但的确可行;不过想起来就觉得复杂,要处理已下载的 .ts 档案,个别转成 .mp3 或 .mp4 档案然后照顺序播放、或是压缩成一个档案什么的,想起来就不太好做。

有兴趣可参考 此篇文章

方案 7. 下载完整档案后再播放 ⍻

这个方法不能确切叫边播边 Cache,实际是载下整个音讯档案的内容,然后才开始播放;如果是 .m3u8 如同方案 2.2 提到的,不能直接载下来放在本地播放。

要实作的话要用到 iOS ≥ 10 的 API AVAssetDownloadTask.makeAssetDownloadTask ,实际会将 . m3u8 打包成 .movpkg 放在本地,供使用者播放。

这边比较像是做离线播放而非做 Cache 的功能。

另外使用者也能从「设定」->「一般」->「iPhone 储存空间」-> APP 中查看、管理已下载打包的音讯档案。

下方 已下载的影片 部分

下方 已下载的影片 部分

详细实作可参考此范例:

结语

以上的探索路程大概花了快一整周,绕来绕去、快要丧心病狂了;目前还没有一个可靠的、容易部署的方法。

如果有新的想法再来更新!

参考资料

有任何问题及指教欢迎 与我联络


Buy me a beer

本文首次发表于 Medium (点击查看原始版本),由 ZMediumToMarkdown 提供自动转换与同步技术。

Improve this page on Github.

This post is licensed under CC BY 4.0 by the author.