iOS AVPlayer 实现边下载边播放

AVPlayer详解 —— 参数设置
https://juejin.cn/post/6844903824809787399

iOS 视频边下边播(缓存,预加载)
https://www.jianshu.com/p/c4599f947609

iOS 音频视频播放器实现边下载边播放缓存视频
https://www.jianshu.com/p/b767788e9d57

iOS音视频实现边下载边播放
https://www.jianshu.com/p/e334d9271356

KTVHTTPCache 实现视频缓存和预加载
https://www.jianshu.com/p/84bd89605150


简单版

在 Swift 中实现一个支持边下载边播放的网络视频播放器,可以使用 AVPlayerAVAssetResourceLoaderDelegate。以下是一个示例代码:

  1. 使用 AVURLAsset 加载视频资源。
  2. 自定义 AVAssetResourceLoaderDelegate,通过拦截视频请求实现边下载边播放。
  3. 将视频缓存到本地,减少后续播放的网络消耗。
示例代码:
import UIKit
import AVFoundation

class VideoPlayerViewController: UIViewController {
    private var player: AVPlayer?
    private var playerLayer: AVPlayerLayer?
    private var resourceLoaderDelegate: VideoResourceLoaderDelegate?

    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .black

        // 视频 URL
        let videoURL = URL(string: "https://www.example.com/video.mp4")!
        
        // 创建自定义 AVAssetResourceLoaderDelegate
        resourceLoaderDelegate = VideoResourceLoaderDelegate()
        let asset = AVURLAsset(url: videoURL)
        asset.resourceLoader.setDelegate(resourceLoaderDelegate, queue: DispatchQueue.global())

        // 使用 AVPlayer 播放
        let playerItem = AVPlayerItem(asset: asset)
        player = AVPlayer(playerItem: playerItem)

        // 创建播放器图层
        playerLayer = AVPlayerLayer(player: player)
        playerLayer?.frame = view.bounds
        playerLayer?.videoGravity = .resizeAspect
        if let playerLayer = playerLayer {
            view.layer.addSublayer(playerLayer)
        }

        // 播放视频
        player?.play()
    }

    override func viewDidLayoutSubviews() {
        super.viewDidLayoutSubviews()
        playerLayer?.frame = view.bounds
    }
}

class VideoResourceLoaderDelegate: NSObject, AVAssetResourceLoaderDelegate {
    private var cache = [URL: Data]() // 简单缓存
    
    func resourceLoader(_ resourceLoader: AVAssetResourceLoader, shouldWaitForLoadingOfRequestedResource loadingRequest: AVAssetResourceLoadingRequest) -> Bool {
        guard let url = loadingRequest.request.url else {
            loadingRequest.finishLoading(with: NSError(domain: "Invalid URL", code: -1, userInfo: nil))
            return false
        }

        // 模拟网络加载(实际使用 URLSession 下载)
        DispatchQueue.global().async {
            if let cachedData = self.cache[url] {
                // 缓存中有数据
                self.respond(to: loadingRequest, with: cachedData)
            } else {
                // 下载视频数据
                self.downloadVideo(from: url) { data in
                    if let data = data {
                        self.cache[url] = data
                        self.respond(to: loadingRequest, with: data)
                    } else {
                        loadingRequest.finishLoading(with: NSError(domain: "Download Failed", code: -2, userInfo: nil))
                    }
                }
            }
        }
        return true
    }

    private func downloadVideo(from url: URL, completion: @escaping (Data?) -> Void) {
        let task = URLSession.shared.dataTask(with: url) { data, response, error in
            completion(data)
        }
        task.resume()
    }

    private func respond(to loadingRequest: AVAssetResourceLoadingRequest, with data: Data) {
        guard let contentInformationRequest = loadingRequest.contentInformationRequest else { return }
        
        // 填充内容信息
        contentInformationRequest.contentType = "public.mp4"
        contentInformationRequest.isByteRangeAccessSupported = true
        contentInformationRequest.contentLength = Int64(data.count)
        
        // 填充数据
        if let dataRequest = loadingRequest.dataRequest {
            let requestedOffset = Int(dataRequest.requestedOffset)
            let requestedLength = dataRequest.requestedLength
            let endOffset = requestedOffset + requestedLength
            let subdata = data.subdata(in: requestedOffset..<min(endOffset, data.count))
            dataRequest.respond(with: subdata)
        }
        loadingRequest.finishLoading()
    }
}

VideoResourceLoaderDelegate

  • 实现了 AVAssetResourceLoaderDelegate 协议,通过拦截资源请求来自定义视频加载逻辑。
  • 在示例中,简单地使用内存缓存并通过 URLSession 下载视频。

边下载边播放

  • AVAssetResourceLoaderDelegate 支持处理分段请求(如数据分块下载),使得视频播放可以在数据部分加载完成后立即开始。

优化建议

  • 将视频数据缓存到文件系统(如沙盒目录),以减少重复加载。
  • 增加错误处理和超时机制,提升稳定性。

完善缓存逻辑、下载报错重试逻辑

实现一个边播放边下载并缓存的功能,同时支持下载失败后的重试逻辑,可以结合 AVAssetResourceLoaderURLSession 实现自定义的资源加载,并将数据流缓存到本地沙盒。以下是完整的实现步骤和代码示例:

以下是完整的实现代码,包括以下功能:

  1. 播放网络视频时,支持边下载边播放。
  2. 下载好的视频缓存到本地沙盒。
  3. 如果有缓存数据,优先播放缓存数据,同时继续下载剩余部分。
  4. 下载过程中支持出错重试逻辑。
  5. 退出界面时暂停下载,但保留缓存数据,下次进入继续播放和下载。
完整代码实现

1. VideoDownloader

负责处理视频的下载、缓存管理、以及断点续传逻辑。

import Foundation

class VideoDownloader {
    private var downloadTask: URLSessionDataTask?
    private var isDownloading = false
    private var downloadedData = Data()
    
    private let url: URL
    private let cachePath: String
    private let retryLimit = 3
    private var retryCount = 0
    
    init(url: URL, cacheFileName: String) {
        self.url = url
        self.cachePath = FileManager.default.temporaryDirectory.appendingPathComponent(cacheFileName).path
    }
    
    func startDownload(range: NSRange? = nil, completion: @escaping (Data?, Error?) -> Void) {
        guard !isDownloading else { return }
        isDownloading = true
        
        var request = URLRequest(url: url)
        if let range = range {
            request.setValue("bytes=\(range.location)-\(range.location + range.length - 1)", forHTTPHeaderField: "Range")
        }
        
        downloadTask = URLSession.shared.dataTask(with: request) { [weak self] data, response, error in
            guard let self = self else { return }
            self.isDownloading = false
            
            if let error = error {
                self.retryCount += 1
                if self.retryCount <= self.retryLimit {
                    print("Retrying download... (\(self.retryCount))")
                    self.startDownload(range: range, completion: completion)
                } else {
                    completion(nil, error)
                }
                return
            }
            
            if let data = data {
                self.retryCount = 0
                self.saveToCache(data: data)
                self.downloadedData.append(data)
                completion(data, nil)
            } else {
                completion(nil, NSError(domain: "VideoDownloader", code: -1, userInfo: [NSLocalizedDescriptionKey: "No data received"]))
            }
        }
        
        downloadTask?.resume()
    }
    
    func pauseDownload() {
        downloadTask?.cancel()
        isDownloading = false
    }
    
    private func saveToCache(data: Data) {
        if FileManager.default.fileExists(atPath: cachePath) {
            if let fileHandle = FileHandle(forWritingAtPath: cachePath) {
                fileHandle.seekToEndOfFile()
                fileHandle.write(data)
                fileHandle.closeFile()
            }
        } else {
            FileManager.default.createFile(atPath: cachePath, contents: data, attributes: nil)
        }
    }
    
    func getCachedData() -> Data? {
        return try? Data(contentsOf: URL(fileURLWithPath: cachePath))
    }
}

2. VideoResourceLoader

自定义 AVAssetResourceLoader 实现数据的边下载边加载。

import AVFoundation

class VideoResourceLoader: NSObject, AVAssetResourceLoaderDelegate {
    private let downloader: VideoDownloader
    
    init(url: URL, cacheFileName: String) {
        self.downloader = VideoDownloader(url: url, cacheFileName: cacheFileName)
    }
    
    func resourceLoader(_ resourceLoader: AVAssetResourceLoader, shouldWaitForLoadingOfRequestedResource loadingRequest: AVAssetResourceLoadingRequest) -> Bool {
        guard let dataRequest = loadingRequest.dataRequest else {
            loadingRequest.finishLoading(with: NSError(domain: "ResourceLoader", code: -1, userInfo: nil))
            return false
        }
        
        let cachedData = downloader.getCachedData() ?? Data()
        let requestedOffset = Int(dataRequest.requestedOffset)
        let requestedLength = dataRequest.requestedLength
        
        if requestedOffset < cachedData.count {
            // Respond with cached data
            let availableData = cachedData.subdata(in: requestedOffset..<min(requestedOffset + requestedLength, cachedData.count))
            dataRequest.respond(with: availableData)
            
            if requestedOffset + requestedLength <= cachedData.count {
                loadingRequest.finishLoading()
                return true
            }
        }
        
        // Continue downloading
        let downloadRange = NSRange(location: cachedData.count, length: requestedOffset + requestedLength - cachedData.count)
        downloader.startDownload(range: downloadRange) { [weak self] data, error in
            guard let self = self else { return }
            if let data = data {
                dataRequest.respond(with: data)
                loadingRequest.finishLoading()
            } else {
                loadingRequest.finishLoading(with: error)
            }
        }
        
        return true
    }
    
    func pauseDownload() {
        downloader.pauseDownload()
    }
}

3. VideoPlayerViewController

主控制器,负责播放视频并管理资源加载器。

import UIKit
import AVFoundation

class VideoPlayerViewController: UIViewController {
    private var player: AVPlayer?
    private var resourceLoader: VideoResourceLoader?
    
    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .black
        
        let videoURL = URL(string: "https://example.com/video.mp4")!
        let cacheFileName = "cached_video.mp4"
        
        // 初始化资源加载器
        resourceLoader = VideoResourceLoader(url: videoURL, cacheFileName: cacheFileName)
        
        // 创建自定义 AVAsset
        let asset = AVURLAsset(url: URL(string: "streaming://video.mp4")!)
        asset.resourceLoader.setDelegate(resourceLoader, queue: DispatchQueue(label: "resourceLoaderQueue"))
        
        let playerItem = AVPlayerItem(asset: asset)
        player = AVPlayer(playerItem: playerItem)
        
        // 添加播放器视图
        let playerLayer = AVPlayerLayer(player: player)
        playerLayer.frame = view.bounds
        playerLayer.videoGravity = .resizeAspect
        view.layer.addSublayer(playerLayer)
        
        // 开始播放
        player?.play()
    }
    
    override func viewWillDisappear(_ animated: Bool) {
        super.viewWillDisappear(animated)
        
        // 暂停下载和播放
        resourceLoader?.pauseDownload()
        player?.pause()
    }
}
测试建议
  1. 测试网络正常情况下的播放和缓存功能。

  2. 测试在网络中断或下载失败时的重试逻辑。

  3. 测试中途退出界面并重新进入,确保从上次缓存位置继续播放和下载。

  4. 下载管理器 (VideoDownloader)

    • 支持分片下载。
    • 将数据写入沙盒缓存,避免重复下载。
    • 内置重试逻辑,自动重新尝试失败的下载请求。
  5. 资源加载器 (VideoResourceLoader)

    • 拦截 AVPlayer 的加载请求。
    • 使用 VideoDownloader 实现边下载边返回数据。
  6. 播放器集成

    • 通过 AVURLAsset 和自定义的 resourceLoader 播放视频。
    • 使用 AVPlayer 播放从网络加载的数据。
优化建议
  1. 缓存校验
    添加缓存校验逻辑,避免重复下载同一资源。

  2. 多线程优化
    使用并发队列提升下载效率。

  3. 断点续传
    检查是否有未完成的下载,并从断点处恢复。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,542评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,596评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 158,021评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,682评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,792评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,985评论 1 291
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,107评论 3 410
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,845评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,299评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,612评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,747评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,441评论 4 333
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,072评论 3 317
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,828评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,069评论 1 267
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,545评论 2 362
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,658评论 2 350

推荐阅读更多精彩内容