在 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。