Writing
用流式下載避免 AVPlayer 播放時的內存爆炸
iOS,Swift,AVFoundation,Player · 2026-04-26
在 AVPlayer 播放遠端媒體時,一個常見錯誤是把整個檔案先下載到內存,再交給播放器處理。這在小檔案上看不出問題,但一旦遇到長影片或高碼率素材,內存會快速膨脹,最終導致 App 被系統殺掉。
更合理的做法是把下載、寫盤、播放拆開:網路資料一段一段抵達,立即寫入檔案,同時只把播放器當前需要的資料回應給 AVFoundation。這篇筆記整理兩個原始實驗:URLSessionDataDelegate 的流式寫盤,以及 AVAssetResourceLoaderDelegate / AVAssetResourceLoadingRequest 在播放鏈路中的介入方式。
問題:播放不應該等於整包進內存
媒體播放的資料量通常遠高於普通 API 回應。如果用 Data(contentsOf:)、一次性 URLSession completion handler,或自己把所有 data append 到內存裡,再把完整檔案交給播放器,這條路在工程上很脆弱。
真正需要控制的是兩件事:第一,下載過程中不要持有完整資料;第二,播放器請求資料時,能從正在下載或已經落盤的內容中取到它需要的 byte range。
第一層:URLSessionDataDelegate 流式寫盤
最基礎的方案,是使用 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。
第二層:讓 AVPlayer 向我們要資料
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()
}
}真正的難點:Range、快取與請求生命週期
上面的 loader 只是骨架。實際落地時,RangeDownloader 不能只做普通下載,它需要理解 byte range。AVPlayer 可能先要頭部資料,也可能跳到中間 seek,還可能同時有多個 loading request。
- 如果本地檔案已經有這段資料,直接從 FileHandle 讀出並 respond。
- 如果本地檔案還沒有這段資料,就用 HTTP Range request 下載缺失區間。
- 如果伺服器不支援 Range,需要降級成順序下載,或者放棄邊下邊播。
- 如果 AVPlayer 取消 request,需要停止對應的網路任務,否則會浪費頻寬與磁碟寫入。
這也是為什麼原始筆記裡「一邊 URLSession 下載、一邊 respond 給 AVAssetResourceLoadingRequest」的方向是對的,但實作上不能把每次 didReceive data 都立即 finishLoading。finishLoading 的語義是這次 request 已經完成;如果播放器要的是一段 range,就必須等這段 range 足夠後再完成。
比較務實的架構
- URL rewrite:把遠端 URL 改成自定義 scheme,讓 AVAssetResourceLoaderDelegate 有機會接管。
- Metadata:先用 HEAD 或第一個 range request 拿 contentLength、mimeType、Accept-Ranges。
- Cache file:本地建立媒體檔案與索引,索引記錄哪些 byte range 已經可用。
- Request coordinator:維護 active loading requests,當新資料落盤後,檢查哪些 request 已經可以 respond 或 finish。
- Download scheduler:根據播放器當前需求優先下載 requestedOffset 附近的資料,而不是盲目順序下載整個檔案。
什麼時候不值得自己做
如果只是普通影片播放,優先考慮 HLS。HLS 本身就是分片、可 seek、可快取的播放格式,AVPlayer 支援也成熟。自己接 AVAssetResourceLoadingRequest,通常是因為你有特殊需求:需要自定義鑑權、私有快取策略、下載後離線播放,或者遠端資源不是標準 HLS。
如果只是想避免下載時內存過大,URLSessionDataDelegate + FileHandle 已經足夠。只有當「播放過程也要被自己的快取層接管」時,才需要把 AVAssetResourceLoaderDelegate 拉進來。
結論
流式下載解決的是內存問題,AVAssetResourceLoadingRequest 解決的是播放器資料供應權問題。兩者組合起來,才能構成一個真正可控的「邊下邊播邊快取」系統。
這套方案的關鍵不是某個 API,而是不要讓任何一層持有完整媒體資料:網路層分段收,磁碟層分段寫,播放器層按需讀。只要這三個邊界守住,長影片播放才不會把 App 推向內存爆炸。
Notion 公開地址:https://qoli.notion.site/AVPlayer-34ec1b36c401810e8405df7510cfde52