在 AVPlayer 播放遠端媒體時,一個常見錯誤是把整個檔案先下載到內存,再交給播放器處理。這在小檔案上看不出問題,但一旦遇到長影片或高碼率素材,內存會快速膨脹,最終導致 App 被系統殺掉。
更合理的做法是把下載、寫盤、播放拆開:網路資料一段一段抵達,立即寫入檔案,同時只把播放器當前需要的資料回應給 AVFoundation。這篇筆記整理兩個原始實驗:URLSessionDataDelegate 的流式寫盤,以及 AVAssetResourceLoaderDelegate / AVAssetResourceLoadingRequest 在播放鏈路中的介入方式。
媒體播放的資料量通常遠高於普通 API 回應。如果用 Data(contentsOf:)、一次性 URLSession completion handler,或自己把所有 data append 到內存裡,再把完整檔案交給播放器,這條路在工程上很脆弱。
真正需要控制的是兩件事:第一,下載過程中不要持有完整資料;第二,播放器請求資料時,能從正在下載或已經落盤的內容中取到它需要的 byte range。
最基礎的方案,是使用 URLSessionDataDelegate。每次收到 didReceive data,就把資料寫入 FileHandle,而不是追加到一個長期存在的 Data。這可以把內存占用控制在很小的 buffer 範圍內。
final class StreamingDownloader: NSObject, URLSessionDataDelegate {
private let sourceURL: URL
private let destinationURL: URL
private var session: URLSession?
private var fileHandle: FileHandle?
private var downloadedSize: Int64 = 0
init(sourceURL: URL, destinationURL: URL) {
self.sourceURL = sourceURL
self.destinationURL = destinationURL
super.init()
}
func start() throws {
FileManager.default.createFile(atPath: destinationURL.path, contents: nil)
fileHandle = try FileHandle(forWritingTo: destinationURL)
let configuration = URLSessionConfiguration.default
session = URLSession(configuration: configuration, delegate: self, delegateQueue: nil)
session?.dataTask(with: URLRequest(url: sourceURL)).resume()
}
func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
fileHandle?.write(data)
downloadedSize += Int64(data.count)
print("downloaded", downloadedSize)
}
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
fileHandle?.closeFile()
fileHandle = nil
session.invalidateAndCancel()
if let error {
print("download failed", error)
} else {
print("download finished")
}
}
}
這一層已經能解決「下載時內存爆炸」的問題,但它還沒有解決「邊下邊播」的問題。AVPlayer 如果直接拿到普通 URL,仍然會按自己的節奏發起請求;如果我們想接管資料供應,就需要進入 AVAssetResourceLoaderDelegate。
AVAssetResourceLoaderDelegate 的核心價值,是讓你攔截 AVPlayer 對媒體資源的載入請求。常見做法是把原始 http/https URL 換成自定義 scheme,例如 custom-cache://,再由 delegate 把請求映射回真正的遠端 URL。
let remoteURL = URL(string: "<https://example.com/video.mp4>")!
var components = URLComponents(url: remoteURL, resolvingAgainstBaseURL: false)!
components.scheme = "stream-cache"
let asset = AVURLAsset(url: components.url!)
let loader = CachedResourceLoader(remoteURL: remoteURL)
asset.resourceLoader.setDelegate(loader, queue: DispatchQueue(label: "resource-loader"))
let item = AVPlayerItem(asset: asset)
let player = AVPlayer(playerItem: item)
player.play()
當 AVPlayer 開始讀取 asset,它會透過 delegate 送出 AVAssetResourceLoadingRequest。這個 request 可能是在問內容資訊,也可能是在問某段 byte range。這時我們要做三件事:填 contentInformationRequest、把資料餵給 dataRequest、在資料足夠或失敗時 finishLoading。
final class CachedResourceLoader: NSObject, AVAssetResourceLoaderDelegate {
private let remoteURL: URL
private let downloader: RangeDownloader
init(remoteURL: URL) {
self.remoteURL = remoteURL
self.downloader = RangeDownloader(url: remoteURL)
}
func resourceLoader(
_ resourceLoader: AVAssetResourceLoader,
shouldWaitForLoadingOfRequestedResource loadingRequest: AVAssetResourceLoadingRequest
) -> Bool {
Task {
do {
try await handle(loadingRequest)
} catch {
loadingRequest.finishLoading(with: error)
}
}
return true
}
private func handle(_ request: AVAssetResourceLoadingRequest) async throws {
if let info = request.contentInformationRequest {
let metadata = try await downloader.metadata()
info.contentType = metadata.mimeType
info.contentLength = metadata.contentLength
info.isByteRangeAccessSupported = true
}
if let dataRequest = request.dataRequest {
let offset = Int64(dataRequest.requestedOffset)
let length = dataRequest.requestedLength
let data = try await downloader.data(from: offset, length: length)
dataRequest.respond(with: data)
}
request.finishLoading()
}
}
上面的 loader 只是骨架。實際落地時,RangeDownloader 不能只做普通下載,它需要理解 byte range。AVPlayer 可能先要頭部資料,也可能跳到中間 seek,還可能同時有多個 loading request。