ZhgChg.Li

AVPlayer 本地 Cache 实作攻略|使用 AVAssetResourceLoaderDelegate 节省 iOS 音乐串流流量

针对 iOS 音乐串流播放需求,实现 AVPlayer 本地 Cache 功能,避免重复下载同一档案,降低流量成本;透过自订 Resource Loader 与 PINCache 管理快取,支援分段 Range 请求与播放不中断,提升使用体验与效能。

AVPlayer 本地 Cache 实作攻略|使用 AVAssetResourceLoaderDelegate 节省 iOS 音乐串流流量
本文使用 AI 翻译,如有不妥敬请告知。"

AVPlayer 实践本地 Cache 功能大全

AVPlayer/AVQueuePlayer with AVURLAsset 实作 AVAssetResourceLoaderDelegate

Photo by Tyler Lastovich

Photo by Tyler Lastovich

[2023/03/12] Update

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

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

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

  • 使用 Combine 实现 Data Flow 策略

  • 写了一些测试

前言

既上一篇「 iOS HLS Cache 实践方法探究之旅 」后已过了大半年,团队还是一直想要实现边播边 Cache 功能因为对成本的影响极大;我们是音乐串流平台,如果每次播放同样的歌曲都要重新拿整个档案,对我们或对非吃到饱的使用者来说都很伤流量,虽然音乐档案顶多几 MB,但积沙成塔都是钱!

另外因为 Android 那边已经有实作边播边 Cache 的功能了,之前有比较过花费,Android 端上线后明显节省了许多流量;相对更多使用者的 iOS 应该能有更好的节流体现。

根据 上一篇 的经验,如果我们要继续使用 HLS ( .m3u8/.ts) 来达成目的;事情将会变得非常复杂甚至无法达成;我们退而求其次退回去使用 mp3 档,这样就能直接使用 AVAssetResourceLoaderDelegate 进行实作。

目标

  • 播放过的音乐会在本地产生 Cache 备份

  • 播放音乐时先检查本地有无 Cache 读取,有则不再重伺服器要档案

  • 可设 Cache 策略;上限总容量,超过时开始删除最旧的 Cache 档案

  • 不干涉原本 AVPlayer 播放机制 (不然最快的方法就是自己先用 URLSession 把 mp3 载下来塞给 AVPlayer,但这样就失去原本能播到哪载到哪的功能,使用者需要等待更长时间&更消耗流量)

前导知识 (1)— HTTP/1.1 Range 范围请求、Connection Keep-Alive

HTTP/1.1 Range 范围请求

首先我们要先了解在播放影片、音乐时是怎么跟伺服器要求资料的;一般来说影片、音乐档案都很大,不可能等到全部拿完才开始播放常见的是播到哪拿到了,只要有正在播放区段的资料就能运作。

要达到这个功能的方法就是透过 HTTP/1.1 Range 只返回指定资料字节范围的资料,例如指定 0–100 就只返回 0–100 这 100 bytes 大小的资料;透过这个方法,可以依序分段取得资料,然后再汇整再一起成完整的档案;这个方法也能运用在档案下载续传功能上。

如何应用?

我们会先使用 HEAD 去看 Response Header 了解到伺服器是否支援 Range 范围请求、资源总长度、档案类型:

curl -i -X HEAD http://zhgchg.li/music.mp3

使用 HEAD 我们能从 Response Header 得到以下资讯:

  • Accept-Ranges: bytes 代表伺服器支援 Range 范围请求 如果没有 Response 这个值或是是 Accept-Ranges: none 都代表不支援

  • Content-Length: 资源总长度,我们要知道总长度才能去分段要资料。

  • Content-Type: 档案类型,AVPlayer 播放时需要知道的资讯。

但有时我们也会使用 GET Range: bytes=0–1 ,意思是我要求 0–1 范围的资料但实际我根本不 Care 0–1是什么内容,我只是要看 Response Header 的资讯; 原生 AVPlayer 就是使用 GET 去看,所以本篇也照旧使用

但比较建议使用 HEAD 去看,一方法比较正确,另一方面万一伺服器不支援 Range 功能;用 GET 去摸就会变强迫下载完整档案。

curl -i -X GET http://zhgchg.li/music.mp3 -H "Range: bytes=0–1"

使用 GET 我们能从 Response Header 得到以下资讯:

  • Accept-Ranges: bytes 代表伺服器支援 Range 范围请求 如果没有 Response 这个值或是是 Accept-Ranges: none 都代表不支援

  • Content-Range: bytes 0–1/资源总长度 ,「/」后的数字及资源总长度,我们要知道总长度才能去分段要资料。

  • Content-Type: 档案类型,AVPlayer 播放时需要知道的资讯。

知道伺服器支援 Range 范围请求后,就能分段发起范围请求:

curl -i -X GET http://zhgchg.li/music.mp3 -H "Range: bytes=0–100"

伺服器会返回 206 Partial Content:

Content-Range: bytes 0-100/总长度
Content-Length: 100
...
(binary content)

这时我们就得到 Range 0–100 的 Data,可再继续发新请求拿 Range 100–200. .200–300…到结束。

如果拿的 Range 超过资源总长度会返回 416 Range Not Satisfiable。

另外,想拿完整档案资料除了可以请求 Range 0-总长度,也可以使用 0- 方式即可:

curl -i -X GET http://zhgchg.li/music.mp3 -H "Range: bytes=0–"

其他还可以同个请求要求多个 Range 资料及下条件式子,但我们用不到,详情可 参考这

Connection Keep-Alive

http 1.1 预设是开启状态, 此特性能实时取得已下载的资料 ,例如档案 5 mb,能 16 kb、16 kb、16 kb… 的取得,不用等到 5mb 都好才给你。

Connection: Keey-Alive

如果发现伺服器不支援 Range、 Keep-Alive

那也不用搞这么多了,直接自己用 URLSession 下载完 mp3 档案塞给播放器就好….但这不是我们要的结果,可以请后端帮忙修改伺服器设定。

前导知识 (2) — AVPlayer 原生是如何处理 AVURLAsset 资源?

当我们使用 AVURLAsset init with URL 资源并赋予给 AVPlayer/AVQueuePlayer 开始播放之后,同上所述,首先会用 GET Range 0–1 去取得是否支援 Range 范围请求、资源总长度、档案类型这三个资讯。

有了档案资讯后,会再发起第二次请求,请求从 0-总长度 的资料。

⚠️ AVPlayer 会请求从 0-总长度 的资料,并透过实时取得已下载的资料特性 ( 16 kb、16 kb、16 kb…) 取得到他觉得资料足够后,会发起 Cancel 取消这个网路请求 (所以实际也不会拿完,除非档案太小)。

继续播放后才会透过 Range 往后请求资料。

(这部分跟我之前想的不一样,我以为会是0–100、100–200. .这样请求)

AVPlayer 请求范例:

1. GET Range 0-1 => Response: 总长度 150000 / public.mp3 / true
2. GET 0-150000...
3. 16 kb receive
4. 16 kb receive...
5. cancel() // current offset is 700
6. 继续播放
7. GET 700-150000...
8. 16 kb receive
9. 16 kb receive...
10. cancel() // current offset is 1500
11. 继续播放
12. GET 1500-150000...
13. 16 kb receive
14. 16 kb receive...
16. If seek to...5000
17. cancel(12.) // current offset is 2000
18. GET 5000-150000...
19. 16 kb receive
20. 16 kb receive...
...

⚠️ iOS ≤12 的情况下,会先发几个较短的请求试著摸摸看(?然后才会发要求到总长度的请求; iOS ≥ 13 则会直接发要求到总长度的请求。

还有个题外的坑,就是在观察怎么拿资源的时候,我使用了 mitmproxy 工具嗅探,结果发现它显示有错,会等到 response 全部回来才会显示,而不是显示分段、使用持久连接接续下载;害我吓了一大跳!以为 iOS 很笨居然每次都要整个档案回来!下次要用工具时要有保持一点怀疑 Orz

Cancel 发起的时机

  1. 前面说到的第二次请求,请求从 0 开始 到总长度的资源,有足够 Data 后会发起 Cancel 取消请求。

  2. Seek 时会先发起 Cancel 取消先前的请求。

⚠️ 在 AVQueuePlayer 中切换到下一个资源、AVPlayer 更换播放资源时并不会发起 Cancel 取消前一首的请求。

AVQueue Pre-buffering

其实也是同样呼叫 Resource Loader 处理,只是他要求的资料范围会比较小。

实现

有了以上前导知识后我们来看实现 AVPlayer 本地 Cache 功能的原理方式。

就是之前有提到的 AVAssetResourceLoaderDelegate ,这个接口让我们能 自行实践 Resource Loader 给 Asset 用。

Resource Loader 实际就是个打工仔,播放器是要档案资讯还是档案资料,范围哪里都哪里都是他告诉我们,我们去做就是。

看到有范例是一个 Resource Loader 服务所有 AVURLAsset ,我觉得是错的,应该要一个 Resource Loader 服务一个 AVURLAsset,跟著 AVURLAsset 的生命周期,他本来就属于 AVURLAsset。

一个 Resource Loader 服务所有 AVURLAsset 在 AVQueuePlayer 上会变得非常复杂且难以管理。

进入自订的 Resource Loader 的时机点

要注意的是不是实践了自己的 Resource Loader 他就会理你,只有当系统无法辨识处理这个资源的时候,才会走你的 Resource Loader。

所以我们在将 URL 资源给予 AVURLAsset 之前要先将 Scheme 换成我们自订的 Scheme,不能是 http/https… 这些系统能处理的 Scheme。

http://zhgchg.li/music.mp3 => cacheable://zhgchg.li/music.mp3

AVAssetResourceLoaderDelegate

只有两个方法需要实现:

  • func resourceLoader( _ resourceLoader: AVAssetResourceLoader, shouldWaitForLoadingOfRequestedResource loadingRequest : AVAssetResourceLoadingRequest) -> Bool :

此方法问我们能不能处理此资源,return true 能,return false 我们也不处理(unsupported url)。

我们能从 loadingRequest 取出要请求什么(第一次请求档案资讯还是请求资料,请求资料的话 Range 是多少到多少);知道请求后我们自行发起请求去拿资料, 在这我们就能决定要发起 URLSession 还是从本地返回 Data

另外也能在此做 Data 加解密操作,保护原始资料。

  • func resourceLoader( _ resourceLoader: AVAssetResourceLoader, didCancel loadingRequest : AVAssetResourceLoadingRequest) :

前述说到的 Cancel 发起时机 发起 Cancel 时…

我们可以在这去取消正在请求的 URLSession。

本地 Cache 实现方式

Cache 的部分我直接使用 PINCache ,将 Cache 工作交由他处理,免去我们要处理 Cache 读写 DeadLock、清除 Cache LRU 策略 实作上的问题。

️️⚠️️️️️️️️️️️OOM警告!

因为这边是针对音乐做 Cache 档案大小顶多 10 MB 上下,所以才能使用 PINCache 作为本地 Cache 工具;如果是要服务影片就无法使用此方法(可能一次要载入好几 GB 的资料到记忆体)

有这部分需求可参考大大的做法,用 FileHandle seek read/write 的特性进行处理。

开工!

不啰唆,先上完整专案:

AssetData

本地 Cache 资料物件映射实现 NSCoding,因 PINCache 是依赖 archivedData 方法 encode/decode。

import Foundation
import CryptoKit

class AssetDataContentInformation: NSObject, NSCoding {
    @objc var contentLength: Int64 = 0
    @objc var contentType: String = ""
    @objc var isByteRangeAccessSupported: Bool = false
    
    func encode(with coder: NSCoder) {
        coder.encode(self.contentLength, forKey: #keyPath(AssetDataContentInformation.contentLength))
        coder.encode(self.contentType, forKey: #keyPath(AssetDataContentInformation.contentType))
        coder.encode(self.isByteRangeAccessSupported, forKey: #keyPath(AssetDataContentInformation.isByteRangeAccessSupported))
    }
    
    override init() {
        super.init()
    }
    
    required init?(coder: NSCoder) {
        super.init()
        self.contentLength = coder.decodeInt64(forKey: #keyPath(AssetDataContentInformation.contentLength))
        self.contentType = coder.decodeObject(forKey: #keyPath(AssetDataContentInformation.contentType)) as? String ?? ""
        self.isByteRangeAccessSupported = coder.decodeObject(forKey: #keyPath(AssetDataContentInformation.isByteRangeAccessSupported)) as? Bool ?? false
    }
}

class AssetData: NSObject, NSCoding {
    @objc var contentInformation: AssetDataContentInformation = AssetDataContentInformation()
    @objc var mediaData: Data = Data()
    
    override init() {
        super.init()
    }

    func encode(with coder: NSCoder) {
        coder.encode(self.contentInformation, forKey: #keyPath(AssetData.contentInformation))
        coder.encode(self.mediaData, forKey: #keyPath(AssetData.mediaData))
    }
    
    required init?(coder: NSCoder) {
        super.init()
        self.contentInformation = coder.decodeObject(forKey: #keyPath(AssetData.contentInformation)) as? AssetDataContentInformation ?? AssetDataContentInformation()
        self.mediaData = coder.decodeObject(forKey: #keyPath(AssetData.mediaData)) as? Data ?? Data()
    }
}

AssetData 存放:

  • contentInformation : AssetDataContentInformation AssetDataContentInformation : 存放 是否支援 Range 范围请求(isByteRangeAccessSupported)、资源总长度(contentLength)、档案类型(contentType)

  • mediaData : 原始音讯 Data (这边档案太大会 OOM)

PINCacheAssetDataManager

封装 Data 存入、取出 PINCache 逻辑。

import PINCache
import Foundation

protocol AssetDataManager: NSObject {
    func retrieveAssetData() -> AssetData?
    func saveContentInformation(_ contentInformation: AssetDataContentInformation)
    func saveDownloadedData(_ data: Data, offset: Int)
    func mergeDownloadedDataIfIsContinuted(from: Data, with: Data, offset: Int) -> Data?
}

extension AssetDataManager {
    func mergeDownloadedDataIfIsContinuted(from: Data, with: Data, offset: Int) -> Data? {
        if offset <= from.count && (offset + with.count) > from.count {
            let start = from.count - offset
            var data = from
            data.append(with.subdata(in: start..<with.count))
            return data
        }
        return nil
    }
}

//

class PINCacheAssetDataManager: NSObject, AssetDataManager {
    
    static let Cache: PINCache = PINCache(name: "ResourceLoader")
    let cacheKey: String
    
    init(cacheKey: String) {
        self.cacheKey = cacheKey
        super.init()
    }
    
    func saveContentInformation(_ contentInformation: AssetDataContentInformation) {
        let assetData = AssetData()
        assetData.contentInformation = contentInformation
        PINCacheAssetDataManager.Cache.setObjectAsync(assetData, forKey: cacheKey, completion: nil)
    }
    
    func saveDownloadedData(_ data: Data, offset: Int) {
        guard let assetData = self.retrieveAssetData() else {
            return
        }
        
        if let mediaData = self.mergeDownloadedDataIfIsContinuted(from: assetData.mediaData, with: data, offset: offset) {
            assetData.mediaData = mediaData
            
            PINCacheAssetDataManager.Cache.setObjectAsync(assetData, forKey: cacheKey, completion: nil)
        }
    }
    
    func retrieveAssetData() -> AssetData? {
        guard let assetData = PINCacheAssetDataManager.Cache.object(forKey: cacheKey) as? AssetData else {
            return nil
        }
        return assetData
    }
}

这边多抽出 Protocol 因为未来可能使用其他储存方式替代 PINCache,所以其他程式在使用时是依赖 Protocol 而非 Class 实体。

⚠️ mergeDownloadedDataIfIsContinuted 这个方法极其重要。

照线性播放只要一直 append 新 Data 到 Cache Data 中即可,但现实情况复杂得多,使用者可能播了 Range 0~100,直接 Seek 到 Range 200–500 播放;如何将已有的 0-100 Data 与新的 200–500 Data 合并就是一个很大的问题。

⚠️Data 合并有问题会出现可怕的播放鬼畜问题….

这边的答案是, 我们不处理非连续资料 ;因为敝专案仅为音讯,档案也就几 MB (≤ 10MB) 以考量开发成本就没做了,我只处理合并连续的资料(例如目前已有 0~100,新资料是 75~200,合并之后变0~200;如果新资料是 150~200,我则会忽略不合并处理)

如果要考虑非连续合并,除了在储存上要使用其他方法(要有办法辨识空缺部分);在 Request 时也要能 Query 出哪段需要发网路请求去拿、哪段是从本地拿;要考量到这情况实作会非常复杂。

图片取自: iOS AVPlayer 视频缓存的设计与实现

图片取自: iOS AVPlayer 视频缓存的设计与实现

CachingAVURLAsset

AVURLAsset 是 weak 持有 ResourceLoader Delegate,所以这边建议自己建立一个 AVURLAsset Class 继承自 AVURLAsset,在内部建立、赋予、持有 ResourceLoader ,让他跟著 AVURLAsset 的生命周期;另外也可以储存原始 URL、CacheKey 等资讯…。

class CachingAVURLAsset: AVURLAsset {
    static let customScheme = "cacheable"
    let originalURL: URL
    private var _resourceLoader: ResourceLoader?
    
    var cacheKey: String {
        return self.url.lastPathComponent
    }
    
    static func isSchemeSupport(_ url: URL) -> Bool {
        guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
            return false
        }
        
        return ["http", "https"].contains(components.scheme)
    }
    
    override init(url URL: URL, options: [String: Any]? = nil) {
        self.originalURL = URL
        
        guard var components = URLComponents(url: URL, resolvingAgainstBaseURL: false) else {
            super.init(url: URL, options: options)
            return
        }
        
        components.scheme = CachingAVURLAsset.customScheme
        guard let url = components.url else {
            super.init(url: URL, options: options)
            return
        }
        
        super.init(url: url, options: options)
        
        let resourceLoader = ResourceLoader(asset: self)
        self.resourceLoader.setDelegate(resourceLoader, queue: resourceLoader.loaderQueue)
        self._resourceLoader = resourceLoader
    }
}

使用:

if CachingAVURLAsset.isSchemeSupport(url) {
  let asset = CachingAVURLAsset(url: url)
  let avplayer = AVPlayer(asset)
  avplayer.play()
}

其中 isSchemeSupport() 是用来判断 URL 是否支援挂我们的 Resource Loader(排除 file:// )。

originalURL 存放原始资源 URL。

cacheKey 存放这个资源的 Cache Key,这边直接用档案名称当 Cache Key。

cacheKey 请依照现实场景做调整,如果档案名称未 hash 可能重复就建议先 hash 后当 key 避免碰撞;如果要 hash 整个 URL 当 key 也要注意 URL 是否会变动 (例如有用 CDN)。

Hash 可使用 md5…sha. .,iOS ≥ 13 可直接使用 Apple 的 CryptoKit ,其他就上 Github 找吧!

ResourceLoaderRequest

import Foundation
import CoreServices

protocol ResourceLoaderRequestDelegate: AnyObject {
    func dataRequestDidReceive(_ resourceLoaderRequest: ResourceLoaderRequest, _ data: Data)
    func dataRequestDidComplete(_ resourceLoaderRequest: ResourceLoaderRequest, _ error: Error?, _ downloadedData: Data)
    func contentInformationDidComplete(_ resourceLoaderRequest: ResourceLoaderRequest, _ result: Result<AssetDataContentInformation, Error>)
}

class ResourceLoaderRequest: NSObject, URLSessionDataDelegate {
    struct RequestRange {
        var start: Int64
        var end: RequestRangeEnd
        
        enum RequestRangeEnd {
            case requestTo(Int64)
            case requestToEnd
        }
    }
    
    enum RequestType {
        case contentInformation
        case dataRequest
    }
    
    struct ResponseUnExpectedError: Error { }
    
    private let loaderQueue: DispatchQueue
    
    let originalURL: URL
    let type: RequestType
    
    private var session: URLSession?
    private var dataTask: URLSessionDataTask?
    private var assetDataManager: AssetDataManager?
    
    private(set) var requestRange: RequestRange?
    private(set) var response: URLResponse?
    private(set) var downloadedData: Data = Data()
    
    private(set) var isCancelled: Bool = false {
        didSet {
            if isCancelled {
                self.dataTask?.cancel()
                self.session?.invalidateAndCancel()
            }
        }
    }
    private(set) var isFinished: Bool = false {
        didSet {
            if isFinished {
                self.session?.finishTasksAndInvalidate()
            }
        }
    }
    
    weak var delegate: ResourceLoaderRequestDelegate?
    
    init(originalURL: URL, type: RequestType, loaderQueue: DispatchQueue, assetDataManager: AssetDataManager?) {
        self.originalURL = originalURL
        self.type = type
        self.loaderQueue = loaderQueue
        self.assetDataManager = assetDataManager
        super.init()
    }
    
    func start(requestRange: RequestRange) {
        guard isCancelled == false, isFinished == false else {
            return
        }
        
        self.loaderQueue.async { [weak self] in
            guard let self = self else {
                return
            }
            
            var request = URLRequest(url: self.originalURL)
            self.requestRange = requestRange
            let start = String(requestRange.start)
            let end: String
            switch requestRange.end {
            case .requestTo(let rangeEnd):
                end = String(rangeEnd)
            case .requestToEnd:
                end = ""
            }
            
            let rangeHeader = "bytes=\(start)-\(end)"
            request.setValue(rangeHeader, forHTTPHeaderField: "Range")
            
            let session = URLSession(configuration: .default, delegate: self, delegateQueue: nil)
            self.session = session
            let dataTask = session.dataTask(with: request)
            self.dataTask = dataTask
            dataTask.resume()
        }
    }
    
    func cancel() {
        self.isCancelled = true
    }
    
    func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
        guard self.type == .dataRequest else {
            return
        }
        
        self.loaderQueue.async {
            self.delegate?.dataRequestDidReceive(self, data)
            self.downloadedData.append(data)
        }
    }
    
    func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) {
        self.response = response
        completionHandler(.allow)
    }
    
    func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
        self.isFinished = true
        self.loaderQueue.async {
            if self.type == .contentInformation {
                guard error == nil,
                      let response = self.response as? HTTPURLResponse else {
                    let responseError = error ?? ResponseUnExpectedError()
                    self.delegate?.contentInformationDidComplete(self, .failure(responseError))
                    return
                }
                
                let contentInformation = AssetDataContentInformation()
                
                if let rangeString = response.allHeaderFields["Content-Range"] as? String,
                   let bytesString = rangeString.split(separator: "/").map({String($0)}).last,
                   let bytes = Int64(bytesString) {
                    contentInformation.contentLength = bytes
                }
                
                if let mimeType = response.mimeType,
                   let contentType = UTTypeCreatePreferredIdentifierForTag(kUTTagClassMIMEType, mimeType as CFString, nil)?.takeRetainedValue() {
                    contentInformation.contentType = contentType as String
                }
                
                if let value = response.allHeaderFields["Accept-Ranges"] as? String,
                   value == "bytes" {
                    contentInformation.isByteRangeAccessSupported = true
                } else {
                    contentInformation.isByteRangeAccessSupported = false
                }
                
                self.assetDataManager?.saveContentInformation(contentInformation)
                self.delegate?.contentInformationDidComplete(self, .success(contentInformation))
            } else {
                if let offset = self.requestRange?.start, self.downloadedData.count > 0 {
                    self.assetDataManager?.saveDownloadedData(self.downloadedData, offset: Int(offset))
                }
                self.delegate?.dataRequestDidComplete(self, error, self.downloadedData)
            }
        }
    }
}

针对 Remote Request 的封装,主要是服务 ResourceLoader 发起的资料请求。

RequestType :用来区分此 Request 是 第一次请求档案资讯(contentInformation)、还是请求资料(dataRequest)

RequestRange :请求 Range 范围,end 可指定到哪(requestTo(Int64) )或全部(requestToEnd)。

档案资讯可由:

func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void)

中取得 Response Header,另外要注意如果要改 HEAD 去摸,不会进这个要用其他方法接。

  • isByteRangeAccessSupported :看 Response Header 中的 Accept-Ranges == bytes

  • contentType :播放器要的档案类型资讯,格式是统一类识别符,不是 audio/mpeg ,而是写作 public.mp3

  • contentLength :看 Response Header 中的 Content-Range :bytes 0–1/ 资源总长度

⚠️这边要注意伺服器给的格式大小写,不一定是写作 Accept-Ranges/Content-Range;有的伺服器的格式是小写 accept-ranges、Accept-ranges…

补充:如果要考量大小写可以写 HTTPURLResponse Extension

import CoreServices

extension HTTPURLResponse {
    func parseContentLengthFromContentRange() -> Int64? {
        let contentRangeKeys: [String] = [
            "Content-Range",
            "content-range",
            "Content-range",
            "content-Range"
        ]
        
        var rangeString: String?
        for key in contentRangeKeys {
            if let value = self.allHeaderFields[key] as? String {
                rangeString = value
                break
            }
        }
        
        guard let rangeString = rangeString,
              let contentLengthString = rangeString.split(separator: "/").map({String($0)}).last,
              let contentLength = Int64(contentLengthString) else {
            return nil
        }
        
        return contentLength
    }
    
    func parseAcceptRanges() -> Bool? {
        let contentRangeKeys: [String] = [
            "Accept-Ranges",
            "accept-ranges",
            "Accept-ranges",
            "accept-Ranges"
        ]
        
        var rangeString: String?
        for key in contentRangeKeys {
            if let value = self.allHeaderFields[key] as? String {
                rangeString = value
                break
            }
        }
        
        guard let rangeString = rangeString else {
            return nil
        }
        
        return rangeString == "bytes" \\|\\| rangeString == "Bytes"
    }
    
    func mimeTypeUTI() -> String? {
        guard let mimeType = self.mimeType,
           let contentType = UTTypeCreatePreferredIdentifierForTag(kUTTagClassMIMEType, mimeType as CFString, nil)?.takeRetainedValue() else {
            return nil
        }
        
        return contentType as String
    }
}

使用:

  • contentLength = response.parseContentLengthFromContentRange( )

  • isByteRangeAccessSupported = response.parseAcceptRanges( )

  • contentType = response.mimeTypeUTI( )

func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data)

同前导知识所述,会实时取得已下载的资料,所以这个方法会一直进,片段片段的拿到 Data;我们将他 append 进 downloadedData 存放。

func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?)

任务取消或结束时都会进这个方法,在这将已下载的资料保存下来。

如前导知识中提到的 Cancel 机制,因播放器在拿到足够资料后就会发起 Cancel,Cancel Request;所以进到这个方法时实际会是 error = NSURLErrorCancelled ,因此不管 error 我们有拿到资料都会尝试存下来。

⚠️ 因 URLSession 会用并行方式出去请求资料,所以请保持操作都在DispatchQueue里,避免资料错乱(资料错乱一样会出现可怕的播放鬼畜)。

️️⚠️URLSession 没有呼叫 finishTasksAndInvalidateinvalidateAndCancel 两个方法都会强持有物件导致 Memory Leak;所以不管是取消或是完成我们都要呼叫,这样才能在任务结束释放 Request。

️️⚠️️️️️️️️️️️如果怕 downloadedData OOM,可以在 didReceive Data 中就存入本地。

ResourceLoader

import AVFoundation
import Foundation

class ResourceLoader: NSObject {
    
    let loaderQueue = DispatchQueue(label: "li.zhgchg.resourceLoader.queue")
    
    private var requests: [AVAssetResourceLoadingRequest: ResourceLoaderRequest] = [:]
    private let cacheKey: String
    private let originalURL: URL
    
    init(asset: CachingAVURLAsset) {
        self.cacheKey = asset.cacheKey
        self.originalURL = asset.originalURL
        super.init()
    }

    deinit {
        self.requests.forEach { (request) in
            request.value.cancel()
        }
    }
}

extension ResourceLoader: AVAssetResourceLoaderDelegate {
    func resourceLoader(_ resourceLoader: AVAssetResourceLoader, shouldWaitForLoadingOfRequestedResource loadingRequest: AVAssetResourceLoadingRequest) -> Bool {
        
        let type = ResourceLoader.resourceLoaderRequestType(loadingRequest)
        let assetDataManager = PINCacheAssetDataManager(cacheKey: self.cacheKey)

        if let assetData = assetDataManager.retrieveAssetData() {
            if type == .contentInformation {
                loadingRequest.contentInformationRequest?.contentLength = assetData.contentInformation.contentLength
                loadingRequest.contentInformationRequest?.contentType = assetData.contentInformation.contentType
                loadingRequest.contentInformationRequest?.isByteRangeAccessSupported = assetData.contentInformation.isByteRangeAccessSupported
                loadingRequest.finishLoading()
                return true
            } else {
                let range = ResourceLoader.resourceLoaderRequestRange(type, loadingRequest)
                if assetData.mediaData.count > 0 {
                    let end: Int64
                    switch range.end {
                    case .requestTo(let rangeEnd):
                        end = rangeEnd
                    case .requestToEnd:
                        end = assetData.contentInformation.contentLength
                    }
                    
                    if assetData.mediaData.count >= end {
                        let subData = assetData.mediaData.subdata(in: Int(range.start)..<Int(end))
                        loadingRequest.dataRequest?.respond(with: subData)
                        loadingRequest.finishLoading()
                       return true
                    } else if range.start <= assetData.mediaData.count {
                        // has cache data...but not enough
                        let subEnd = (assetData.mediaData.count > end) ? Int((end)) : (assetData.mediaData.count)
                        let subData = assetData.mediaData.subdata(in: Int(range.start)..<subEnd)
                        loadingRequest.dataRequest?.respond(with: subData)
                    }
                }
            }
        }
        
        let range = ResourceLoader.resourceLoaderRequestRange(type, loadingRequest)
        let resourceLoaderRequest = ResourceLoaderRequest(originalURL: self.originalURL, type: type, loaderQueue: self.loaderQueue, assetDataManager: assetDataManager)
        resourceLoaderRequest.delegate = self
        self.requests[loadingRequest]?.cancel()
        self.requests[loadingRequest] = resourceLoaderRequest
        resourceLoaderRequest.start(requestRange: range)
        
        return true
    }
    
    func resourceLoader(_ resourceLoader: AVAssetResourceLoader, didCancel loadingRequest: AVAssetResourceLoadingRequest) {
        guard let resourceLoaderRequest = self.requests[loadingRequest] else {
            return
        }
        
        resourceLoaderRequest.cancel()
        requests.removeValue(forKey: loadingRequest)
    }
}

extension ResourceLoader: ResourceLoaderRequestDelegate {
    func contentInformationDidComplete(_ resourceLoaderRequest: ResourceLoaderRequest, _ result: Result<AssetDataContentInformation, Error>) {
        guard let loadingRequest = self.requests.first(where: { $0.value == resourceLoaderRequest })?.key else {
            return
        }
        
        switch result {
        case .success(let contentInformation):
            loadingRequest.contentInformationRequest?.contentType = contentInformation.contentType
            loadingRequest.contentInformationRequest?.contentLength = contentInformation.contentLength
            loadingRequest.contentInformationRequest?.isByteRangeAccessSupported = contentInformation.isByteRangeAccessSupported
            loadingRequest.finishLoading()
        case .failure(let error):
            loadingRequest.finishLoading(with: error)
        }
    }
    
    func dataRequestDidReceive(_ resourceLoaderRequest: ResourceLoaderRequest, _ data: Data) {
        guard let loadingRequest = self.requests.first(where: { $0.value == resourceLoaderRequest })?.key else {
            return
        }
        
        loadingRequest.dataRequest?.respond(with: data)
    }
    
    func dataRequestDidComplete(_ resourceLoaderRequest: ResourceLoaderRequest, _ error: Error?, _ downloadedData: Data) {
        guard let loadingRequest = self.requests.first(where: { $0.value == resourceLoaderRequest })?.key else {
            return
        }
        
        loadingRequest.finishLoading(with: error)
        requests.removeValue(forKey: loadingRequest)
    }
}

extension ResourceLoader {
    static func resourceLoaderRequestType(_ loadingRequest: AVAssetResourceLoadingRequest) -> ResourceLoaderRequest.RequestType {
        if let _ = loadingRequest.contentInformationRequest {
            return .contentInformation
        } else {
            return .dataRequest
        }
    }
    
    static func resourceLoaderRequestRange(_ type: ResourceLoaderRequest.RequestType, _ loadingRequest: AVAssetResourceLoadingRequest) -> ResourceLoaderRequest.RequestRange {
        if type == .contentInformation {
            return ResourceLoaderRequest.RequestRange(start: 0, end: .requestTo(1))
        } else {
            if loadingRequest.dataRequest?.requestsAllDataToEndOfResource == true {
                let lowerBound = loadingRequest.dataRequest?.currentOffset ?? 0
                return ResourceLoaderRequest.RequestRange(start: lowerBound, end: .requestToEnd)
            } else {
                let lowerBound = loadingRequest.dataRequest?.currentOffset ?? 0
                let length = Int64(loadingRequest.dataRequest?.requestedLength ?? 1)
                let upperBound = lowerBound + length
                return ResourceLoaderRequest.RequestRange(start: lowerBound, end: .requestTo(upperBound))
            }
        }
    }
}

loadingRequest.contentInformationRequest != nil 则代表是第一次请求,播放器要求先给档案资讯。

请求档案资讯时我们需要赋予这三项资讯:

  • loadingRequest.contentInformationRequest?.isByteRangeAccessSupported :是否支援 Range 拿 Data

  • loadingRequest.contentInformationRequest?.contentType :统一类识别符

  • loadingRequest.contentInformationRequest?.contentLength :档案总长度 Int64

loadingRequest.dataRequest?.requestedOffset 可取得要求 Range 的起始 offset。

loadingRequest.dataRequest?.requestedLength 可取得要求 Range 的长度。

loadingRequest.dataRequest?.requestsAllDataToEndOfResource == true 则不管要求 Range 的长度,直接拿到底。

loadingRequest.dataRequest?.respond(with: Data) 返回已载入的 Data 给播放器。

loadingRequest.dataRequest?.currentOffset 可取得当前 data offset, dataRequest?.respond(with: Data)currentOffset 会跟著推移。

loadingRequest.finishLoading() 资料都载完了,告知播放器。

func resourceLoader(_ resourceLoader: AVAssetResourceLoader, shouldWaitForLoadingOfRequestedResource loadingRequest: AVAssetResourceLoadingRequest) -> Bool

播放器请求资料,我们先看本地 Cache 有无资料,有则返回;若只有部分资料则一样返回部分,例如我本地有 0–100 ,播放器要求 0–200,则先返回 0–100。

若没有本地 Cache、返回的资料不够,则会发起 ResourceLoaderRequest 请求从网路拿资料。

func resourceLoader(_ resourceLoader: AVAssetResourceLoader, didCancel loadingRequest: AVAssetResourceLoadingRequest)

播放器取消请求,取消 ResourceLoaderRequest。

你可能有发现 resourceLoaderRequestRange 的 offset 是看 currentOffset ,因为我们会先从本地 dataRequest?.respond(with: Data) 已下载 Data;所以直接看推移后的 offset 即可。

func private var requests: [AVAssetResourceLoadingRequest: ResourceLoaderRequest] = [:]

⚠️ requests 有的范例是只用 currentRequest: ResourceLoaderRequest 来存放,这会有个问题,因为可能当前的 request 正在拿取,使用者又 seek 这时会取消旧的发起新的;但因不一定会照顺序发生,可能先走发新请求再走取消;所以用 Dictionary 去存取操作还是比较安全!

⚠️让所有操作都在同个 DispatchQueue 防止出现资料鬼畜。

deinit 时取消所有还在请求的 requests Resource Loader Deinit 即代表 AVURLAsset Deinit,代表播放器已经不需要这个资源了;所以我们可以 Cancel 还在取资料的 Request,已经载的一样会写入 Cache。

补充及鸣谢

感谢 Lex 汤 大大指点。

感谢 外孙女 提供开发上的意见及支持。

本篇只针对音乐小档

影片大档案可能会在 downloadedData、AssetData/PINCacheAssetDataManager 发生 Out Of Memory 问题。

同前述,如果要解决这个问题请使用 fileHandler seek read/wirte 去操作本地 Cache 读取写入(取代AssetData/PINCacheAssetDataManager);或找看看 Github 有没有大 data write/read to file 的专案可用。

AVQueuePlayer 切换播放项目时取消正在下载的项目

同前导知识中所述,在更换播放目标时是不会发起 Cancel 的;如果是 AVPlayer 会走 AVURLAsset Deinit 所以下载也会中断;但 AVQueuePlayer 不会,因为都还在 Queue 里,只是播放目标换到下一首而已。

这边唯一做法就只能接收变换播放目标通知,然后在收到通知后取消上一手的 AVURLAsset loading。

asset.cancelLoading()

音讯资料加解密

音讯加解密可在 ResourceLoaderRequest 中拿到 Data 进行、还有储存时能在 AssetData 的 encode/decode 对存在本地的 Data进行加解密。

CryptoKit SHA 使用范本:

class AssetData: NSObject, NSCoding {
    static let encryptionKeyString = "encryptionKeyExzhgchgli"
    ...
    func encode(with coder: NSCoder) {
        coder.encode(self.contentInformation, forKey: #keyPath(AssetData.contentInformation))
        
        if #available(iOS 13.0, *),
           let encryptionData = try? ChaChaPoly.seal(self.mediaData, using: AssetData.encryptionKey).combined {
            coder.encode(encryptionData, forKey: #keyPath(AssetData.mediaData))
        } else {
          //
        }
    }
    
    required init?(coder: NSCoder) {
        super.init()
        ...
        if let mediaData = coder.decodeObject(forKey: #keyPath(AssetData.mediaData)) as? Data {
            if #available(iOS 13.0, *),
               let sealedBox = try? ChaChaPoly.SealedBox(combined: mediaData),
               let decryptedData = try? ChaChaPoly.open(sealedBox, using: AssetData.encryptionKey) {
                self.mediaData = decryptedData
            } else {
              //
            }
        } else {
            //
        }
    }
}

PINCache 相关操作

PINCache 包含 PINMemoryCache 和 PINDiskCache,PINCache 会帮我们处理从档案读到 Memory 或从 Memory 写入档案的事,我们只需要对 PINCache 进行操作。

在模拟器中查找 Cache 档案位置:

使用 NSHomeDirectory() 取得模拟器档案路径

Finder -> 前往 -> 贴上路径

在 Library -> Caches -> com.pinterest.PINDiskCache.ResourceLoader 就是我们建的 Resource Loader Cache 目录。

PINCache(name: “ResourceLoader”) 其中的 name 就是目录名称。

也可以指定 rootPath ,目录就可以改到 Documents 底下(不怕被系统清掉)。

设定 PINCache 最大上限:

 PINCacheAssetDataManager.Cache.diskCache.byteCount = 300 * 1024 * 1024 // max: 300mb
 PINCacheAssetDataManager.Cache.diskCache.byteLimit = 90 * 60 * 60 * 24 // 90 days

系统预设上限

系统预设上限

设 0 的话就不会主动删除档案。

后记

原先太小看这个功能的困难度,以为三两下就能处理好;结果吃尽苦头,大概又多花了两周处理资料储存的问题,不过也就此彻底了解整个 Resource Loader 运作机制、 GCD 、Data。

参考资料

最后附上研究如何实作的参考资料

  1. iOS AVPlayer 视频缓存的设计与实现 仅讲原理

  2. 基于AVPlayer实现音视频播放和缓存,支持视频画面的同步输出 [ SZAVPlayer ] 有附程式(很完整,但很复杂)

  3. CachingPlayerItem (简易实现,较好懂但不完整)

  4. 可能是目前最好的 AVPlayer 音视频缓存方案 AVAssetResourceLoaderDelegate

  5. 仿抖音 Swift 版 [ Github ](蛮有意思的专案,复刻抖音 APP;里面也有用到 Resource Loader)

  6. iOS HLS Cache 实践方法探究之旅

延伸

在 GitHub 上补充修正
编辑这篇文章
本文首次发表于 Medium
点此查看原文
分享这篇文章
复制链接 · 分享到社群
ZhgChgLi
作者

ZhgChgLi

An iOS, web, and automation developer from Taiwan 🇹🇼 who also loves sharing, traveling, and writing.

留言 · Comments