最近研究 iOS 中视频播放器的相关内容,基于 AVPlayer 依次完成了底层视频边下边播的缓存框架、自定义的视频播放控制器、TableView 视频播放列表。本文整理一下边下边播的实现原理和使用 AVPlayer 遇到的一些坑。

相关代码开源在视频播放器ZLPlayer, 支持边下边播,简单分片缓存。提供视频加载进度、播放进度、缓冲进度、播放错误处理、seek操作等回调接口。

使用 AVAssetResourceLoader 实现边下边播

AVPlayer 是支持直接从一个 url 下载并播放视频的:

let urlAssrt = AVURLAsset(url: URL(string: mediaUrl))
let playerItem = AVPlayerItem(asset: urlAssrt)
let player = AVPlayer(playerItem: playerItem)

但 AVPlayer 没有提供方法给我们直接获取它下载下来的数据,无法对下载过程进行干预控制如seek操作,也不支持分片缓存。AVAssetResourceLoader 通过委托对象去调节 AVURLAsset 所需要的加载资源,包括获取 AVPlayer 需要的数据信息、决定传递多少数据给 AVPlayer 等,可操作的自由度更高,因此最终采用这种方式实现。各层级关系如下:

基本原理

AVAssetResourceLoader 仅在 AVURLAsset 不知道如何去加载这个 URL 资源时才会被调用,因此在初始化 AVURLAsset 的时候需要把目标视频URL地址的scheme替换为系统不能识别的scheme (这一点很重要!), AVAssetResourceLoader 将 AVAssetResourceLoadingRequest 传给 AVAssetResourceLoaderDelegate, 我们先保存这些请求,然后通过 URLSession 自己构建下载任务。每次收到响应后把数据填充给 AVAssetResourceLoadingRequest, 并对数据进行缓存,就完成了边下边播,流程如图:

实现

必要的配置:自定义 URLScheme 来创建 AVPlayerItem

var component = URLComponents(url: originalURL, resolvingAgainstBaseURL: true)!
component.scheme = "Stream"
let urlAssrt = AVURLAsset(url: component.url!)
urlAssrt.resourceLoader.setDelegate(videoResourceLoaderDelegate, queue: videoResourceLoaderDelegate.resourceLoaderQueue)
playerItem = AVPlayerItem(asset: urlAssrt)

实现 AVAssetResourceLoaderDelegate 协议

两个必须实现的方法:

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

要求加载资源的代理方法,返回 true 表示该代理类现在可以处理该请求,我们需要在这里保存 loadingRequest 并开启下载数据的任务,下载回调中拿到响应数据后再对 loadingRequest 进行填充。

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

AVAssetResourceLoader 取消了本次请求,此时我们需要把该 loadingRequest 移出下载任务的回调列表(停止填充)。
具体实现:

public func resourceLoader(_ resourceLoader: AVAssetResourceLoader, shouldWaitForLoadingOfRequestedResource loadingRequest: AVAssetResourceLoadingRequest) -> Bool {
    // 启动下载任务,同时保留loadingRequest, progress 是 URLSession 响应数据的回调处理
    VideoDownloadManager.shared.startDownload(url: originalURL, loadingRequest: loadingRequest, progress: { [weak self] (loadingRequest, task) in
        if nil  == self {
            return
        }

        self!.resourceLoaderQueue.async { [weak self] in
            if nil  == self {
                return
            }

            // 数据填充操作,具体实现见后文
            let isFinish = self!.respondData(loadingRequest: loadingRequest!, data: task.cachedData, dataOffset: 0, contentLength: task.contentLength, mimeType: "video/mp4")
            if isFinish {
                // loadingRequest 填充结束,需要从该下载任务的回调列表中移除
                task.removeCallback(by: loadingRequest!)
            }
        }
    }, complete: {  _,_  in

    }, observer: self)

    return true
}

视频下载任务管理(简单分块)

    /// 启动下载任务
    func startDownload(url: URL, loadingRequest: AVAssetResourceLoadingRequest?, progress: VideoDownloadProgressHandler?, complete: VideoDownloadCompleteHandler?, observer: VideoResourceLoader? = nil) {

        // 已经存在任务(同一视频源由一个下载任务管理)
        if let task = urlTasks[url] {
            if let loadingRequest = loadingRequest {
                // 添加回调
                if let callback = VideoDownloadCallback(loadingRequest: loadingRequest, progress: progress, complete: complete) {
                    task.addCallback(callback)
                }

                // 添加观察者
                if let observer = observer {
                    let weakObject = WeakObject<VideoResourceLoader>(target: observer)
                    task.addObsever(weakObject)
                }

                // 回调上层(上一个 loadingRequest 填充完后 task 可能还有剩余数据,先填充)
                if let progress = progress {
                    progress(loadingRequest, task)
                }
            }
        } else { // 创建新任务
            // 框架支持部分缓存,当视频还未缓冲完 playerItem 即被销毁时,将本次已下载数据缓存起来
            // 新的 task 先异步载入缓存再继续下载,从而实现“断点续传”
            VideoCacheManager.shared.asynLoadCache(url: url) { (data, contentLength) in
                let cacheLength = (data != nil) ? data!.count : 0
                let isFullCache = (cacheLength > 0 && data!.count == contentLength!)

                // 创建下载任务 
                let request = self.createURLRequest(url: url, loadingRequest: (isFullCache ? nil : loadingRequest), cacheLength: cacheLength)
                let dataTask = self.session.dataTask(with: request)
                let newTask = VideoDownloadTask(url: url, dataTask: dataTask)

                // 添加回调
                if let callback = VideoDownloadCallback(loadingRequest: loadingRequest, progress: progress, complete: complete) {
                    newTask.addCallback(callback)
                }

                // 添加观察者
                if let observer = observer {
                    // 注意:newTask 维护一个 observers 数组,对每个 observer 都是强引用
                    // 避免内存泄漏,使用一个弱引用对象容器封装
                    let weakObject = WeakObject<VideoResourceLoader>(target: observer)
                    newTask.addObsever(weakObject)
                }

                // 填充缓存的数据
                if let data = data {
                    newTask.contentLength = contentLength!
                    newTask.appendData(data)

                    // 回调上层
                    if let loadingRequest = loadingRequest, let progress = progress {
                        progress(loadingRequest, newTask)
                    }
                }

                self.urlTasks[url] = newTask

                // 任务启动
                if !isFullCache {
                    dataTask.resume()
                }
            }
        }
    }

    /// 创建下载URL请求
    private func createURLRequest(url: URL, loadingRequest: AVAssetResourceLoadingRequest?, cacheLength: Int) -> URLRequest {
        var request = URLRequest(url: url)
        request.cachePolicy = .reloadIgnoringLocalCacheData

        // 设置Range, 如果有部分本地缓存,将缓存终点作为本次请求起点
        if let dataRequest = loadingRequest?.dataRequest {
            let requestedOffset = (cacheLength > 0 ? Int64(cacheLength) : dataRequest.requestedOffset)
            request.addValue("bytes=\(requestedOffset)-", forHTTPHeaderField: "Range")
        }

        return request
    }

    /// 从响应请求头中获取视频文件总长度 contentLength
    public func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive response: URLResponse, completionHandler: @escaping (URLSession.ResponseDisposition) -> Void) {
        if let downloadTask = downloadTask(for: dataTask) {
            guard response.mimeType == "video/mp4" else {
                return
            }

            // 请求头有两个字段需要关注
            // Content-Length表示本次请求的数据长度
            // Content-Range表示本次请求的数据在总媒体文件中的位置,格式是start-end/total,因此Content-Length = end - start + 1

            if let contentRange = (response as! HTTPURLResponse).allHeaderFields["Content-Range"] as? String {
                let contentLengthString = contentRange.split(separator: "/").map{String($0)}.last!
                downloadTask.contentLength = Int(contentLengthString)!
            } else {
                downloadTask.contentLength = Int(response.expectedContentLength)
            }
        }

        completionHandler(.allow)
    }

    /// 收到响应数据的处理
    public func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) {
        if let downloadTask = downloadTask(for: dataTask) {
            // 在已下载数据基础上填充
            downloadTask.appendData(data)
            let callbacks = downloadTask.callbacks
            for callback in callbacks {
                if let progress = callback.progressHandler {
                    progress(callback.loadingRequest, downloadTask)
                }
            }
        }
    }

把请求返回数据输出到 loadingRequest (VideoDownloadProgressHandler)

这一步主要是根据 URLRequest 下载数据和 loadingRequest 请求数据的范围,计算填充的起始和结束位置,并判断是否结束填充。

    private func respondData(loadingRequest: AVAssetResourceLoadingRequest, data: Data, dataOffset: Int, contentLength: Int, mimeType: String) -> Bool {
        if loadingRequest.isCancelled || loadingRequest.isFinished {
            return false
        }
        // 填充信息
        if let contentInformationRequest = loadingRequest.contentInformationRequest {
            let contentType =  UTTypeCreatePreferredIdentifierForTag(kUTTagClassMIMEType, mimeType as CFString, nil)?.takeRetainedValue()
            contentInformationRequest.contentType = contentType as String?
            contentInformationRequest.isByteRangeAccessSupported = true
            contentInformationRequest.contentLength = Int64(contentLength)
        }

        // 填充数据
        if let dataRequest = loadingRequest.dataRequest {
            guard dataRequest.currentOffset >= dataOffset && dataRequest.currentOffset < dataOffset.advanced(by: data.count) else {
                return false // 没有可用数据
            }

            // 可用cache长度
            let availableCacheLength = dataOffset.advanced(by: data.count).advanced(by: Int(-dataRequest.currentOffset))
            // 待填充数据长度
            let requestedEndIndex = (dataRequest.requestsAllDataToEndOfResource ? contentLength : Int(dataRequest.requestedOffset.advanced(by: dataRequest.requestedLength)))
            let unreadLength = requestedEndIndex.advanced(by: Int(-dataRequest.currentOffset))
            // 填充数据长度
            let respondDataLength = min(availableCacheLength, Int(unreadLength))

            // 计算填充数据的起点和终点,进行填充
            let beginIndex = dataRequest.currentOffset.advanced(by: -dataOffset)
            let endIndex = beginIndex.advanced(by: respondDataLength)
            let respondData = data.subdata(in: Int(beginIndex)..<Int(endIndex))
            dataRequest.respond(with: respondData)

            // 判断是否结束
            if dataRequest.currentOffset >= dataRequest.requestedOffset.advanced(by: dataRequest.requestedLength) {
                loadingRequest.finishLoading()
                return true
            }
        }

        return false
    }

下图可直观地表示上述代码的流程。直线表示完整的视频,直线下方是 VideoDownloadTask 的下载进度,直线上方是某一次 loadingRequest 的填充过程。通过比较 可用 cache 长度(availableCacheLength)待填充数据长度(unreadLength) 可确定本次所取data的范围。包含两种情况,图1表示 availableCacheLength > unreadLength, 取部分 data 填充,并调用loadingRequest.finishLoading()结束该loadingRequest. 这一步是必需的,不然可能没有继续的请求了。

图2表示 availableCacheLength < unreadLength, data 全部填充,并等待新的响应数据(didReceive data).

如果请求遇到错误、或者请求被取消我们也需要作响应处理, 从 urlTasks 移除下载任务(移除之前先检查任务是否还有其他观察者)、中断 URLRequest、保存视频缓存。

视频缓存管理

FileManager 可以帮助我们方便地建立视频缓存管理器。统一保存在 caches/videos/, 以 url md5 值作为文件名。如果是完整视频,后缀为.mp4, 否则当临时文件缓存.tmp:

/// 视频目录
// /var/mobile/Containers/Data/Application/B78CD5EE-540B-4C6E-8B49-47EE148881AE/Library/Caches/videos
static var videoDirectory: String = FileManager.cachesPath.appendingPathComponent("videos")

/// 视频文件名(url md5值)
static func videoFileName(url: URL) -> String {
    return url.absoluteString.md5String
}

/// 视频完整文件路径
    static func videoFilePath(url: URL) -> String {
    let videoName = videoFileName(url: url)
    return videoDirectory.appendingPathComponent(videoName) + ".mp4"
}

/// 视频临时文件路径
static func tmpFilePath(url: URL) -> String {
    let videoName = videoFileName(url: url)
    return videoDirectory.appendingPathComponent(videoName) + ".tmp"
}

VideoCacheManager 需要提供视频缓存保存和异步载入的逻辑:

    /// 保存视频缓存
    func storeCache(with downloadTask: VideoDownloadTask, completion: (() -> Void)?) {
        ioQueue.async {
            if !FileManager.default.fileExists(atPath: VideoCacheManager.videoDirectory) {
                do {
                    try FileManager.default.createDirectory(atPath: VideoCacheManager.videoDirectory, withIntermediateDirectories: true, attributes: nil)
                } catch {
                    print("Video|cache", "create video Directory error:\(error)")
                }
            }

            let url = downloadTask.url
            let tmpFile = VideoCacheManager.tmpFilePath(url: url)

            // 保存完整视频
            let data = downloadTask.cachedData
            let contentLength = downloadTask.contentLength
            let isFinished = (data.count == contentLength)
            if isFinished {
                let file = VideoCacheManager.videoFilePath(url: url)
                do {
                    try data.write(to: URL(fileURLWithPath: file))
                } catch {
                    print("Video|cache", "store video error:\(error)")
                }

                if FileManager.default.fileExists(atPath: tmpFile) {
                    do  {
                        try FileManager.default.removeItem(atPath: tmpFile)
                    } catch {
                        print("Video|cache", "remove video tmp file error:\(error)")
                    }
                }
            } else { // 保存临时视频信息
                let tmpInfo = [kDataKey: data, kContentLengthKey: contentLength] as [String : Any]
                let success = NSKeyedArchiver.archiveRootObject(tmpInfo, toFile: tmpFile)
                if !success {
                    print("Video|cache", "store video tmp file error")
                }
            }

            completion?()
        }
    }

至此一个支持简单分片、边下边播的视频缓存框架便完成了。「简单分片」的原因是暂不支持进度条seek操作。data 缓存一直是从0开始,这次缓存0-x%, 下次便从 x 处接着下载。支持seek的一个思路:

  1. seek 到已下载填充部分,直接 seek 成功;
  2. seek 到未下载部分,如下载进度60%, seek至70%, 则取消现有的 URLSessionDataTask 并删除已缓存数据, 创建新的 URLRequest 设置Range为70%, 请求和填充数据范围70%-100%. 事实上 AVPlayer 对 loadingRequest 也是这么处理的, seek 之后旧的 loadingRequest 会被 cancel, 创建新的 loadingRequest, dataRequest.requestedOffset 也会变成 seek 处的值;
  3. 若此时又 seek 到40%, 则新建任务下载范围是 40%-100%. 开始新的下载后,由于文件不完整,下载完的数据不再保存。

AVPlayer 一级封装

AVPlayer 需要手动实现播放逻辑,用法都大同小异。先使用 AVPlayerItem 实例初始化 AVPlayer, 然后使用 AVPlayer 实例初始化 AVPlayerLayer 实例,接着需要添加几类监听:

kvo 监听 playerItem 加载和缓冲状态

func addObserver(to playerItem: AVPlayerItem?) {
    if playerItem != nil {
        //监控视频加载状态
        playerItem?.addObserver(self, forKeyPath: "status", options: .new, context: nil)
        //监控网络加载情况
        playerItem?.addObserver(self, forKeyPath: "loadedTimeRanges", options: .new, context: nil)
        //正在缓冲
        playerItem?.addObserver(self, forKeyPath: "playbackBufferEmpty", options: .new, context: nil)
        //缓冲结束,可无延迟播放
        playerItem?.addObserver(self, forKeyPath: "playbackLikelyToKeepUp", options: .new, context: nil)
    }
}

播放进度监控

func addProgressObserver() {
    //这里设置每秒执行一次
    let interval = CMTime(value: 1, timescale: 1)
    playTimeObserverToken = player?.addPeriodicTimeObserver(forInterval: interval, queue: DispatchQueue.main, using: { [weak self] time in
        guard let `self` = self else {
            return
        }
        self.currentPlayTime = CMTimeGetSeconds(time)
        // 自定义行为,更新播放进度条等...
    })
}

播放完成、错误通知

func addNotification() {
    //给 playerItem 添加播放完成通知
    NotificationCenter.default.addObserver(self, selector: #selector(self.playbackFinished(_:)), name: .AVPlayerItemDidPlayToEndTime, object: player?.currentItem)

    //给 playerItem 添加播放错误通知
    NotificationCenter.default.addObserver(self, selector: #selector(self.playbackFail(_:)), name: .AVPlayerItemFailedToPlayToEndTime, object: player?.currentItem)
}

外部环境通知

//app进入后台
NotificationCenter.default.addObserver(self, selector: #selector(appResignActive(_:)), name: UIApplication.willResignActiveNotification, object: nil)
//app进入前台
NotificationCenter.default.addObserver(self, selector: #selector(appBecomeActive(_:)), name: UIApplication.didBecomeActiveNotification, object: nil)

// 监听耳机插入和拔掉通知
NotificationCenter.default.addObserver(self, selector: #selector(audioRouteChangeListenerCallback(_:)), name: AVAudioSession.routeChangeNotification, object: nil)
//中断处理(播放过程中有打电话等系统事件)
NotificationCenter.default.addObserver(self, selector: #selector(handleInterruption(_:)), name: AVAudioSession.interruptionNotification, object: nil)

// 通过 AVAudioSession 设置自己的APP是否和其他APP音频同时存在,是否会被静音键或锁屏键静音等
AVAudioSession.sharedInstance().setCategory(AVAudioSession.Category.playback /*允许后台*/, options: .mixWithOthers /*混合播放,不独占*/)

以上基本实现了通过 AVPlayer 控制并监听视频播放进度,代码开源在 Github. 包括边下边播的缓存逻辑,并提供了一个通用播放器 ZLAVPlayer, 将视频加载状态、播放进度、缓冲进度、播放错误、seek等操作通过闭包或代理的方式对外提供回调,你可以对 ZLAVPlayer 进行二级封装,自定义播放暂停按钮、进度条、加载动画等UI元素,只需要在 init 的时候提供视频 url 和实现各个回调。

var zlAVPlayer = ZLAVPlayer(withURL: url,
    process: { [weak self] (player, progress) in
     print("当前播放进度:\(progress), 当前播放时间:\(player.currentPlayTime).")

    }, compelete: { (player) in
        print("播放结束.")

    }, loadStatus: { (player, status) in
        if status == .readyToPlay {
            print("加载状态:准备完毕,可以播放。视频总时长:\(player.totalTime).")
        } else if status == .unknown {
            print("加载状态:未知状态,此时不能播放.")
        } else if status == .failed {
            print("加载状态:加载失败,网络或者服务器出现问题:\(player.playerItem.error).")
        }

    }, bufferPercent: {  (player, bufferPercent) in
        print("缓冲进度: \(bufferPercent).")

    }, willSeek: { (player, curtPos, toPos) in
        print("即将seek.")
        player.setSeek(to: toPos)

    }, seekComplete: { (player, prePos, curtPos) in
        print("seek 结束.")

    }, buffering: { (player) in
        print("可用缓冲耗尽,将停止播放.")
        player.pause()

    }, bufferFinish: { (player) in
        print("可无延迟播放.")
        player.play()

    }, error: { (player, error) in
        print("播放出错: \(error).")
})

使用问题记录

AVPlayer 最佳实践

首次创建 player 实例一般按照以下步骤:

// 1. 初始化AVAsset
let asset = AVURLAsset(url: url)

// 2. 根据AVAsset初始化AVPlayerItem
let playerItem = AVPlayerItem(asset: asset)

// 3. 根据AVPlayerItem初始化AVPlayer
let player = AVPlayer(playerItem: playerItem)

// 4. 绑定AVPlayer至AVPlayerLayer,显示内容。
let playerLayer = AVPlayerLayer(player: player)

第3步执行时,由于 player 没有绑定到layer,此时系统只会准备音频相关的资源。到第4步执行时,系统会把之前准备好的音频资源重置,重新绑定音视频资源。这里会有一定程度的资源消耗,系统需要重新绑定音视频管线,造成播放首帧变慢。
因此更推荐以下步骤:

// 1. 初始化AVAsset
let asset = AVURLAsset(url: url)

// 2. 根据AVAsset初始化AVPlayerItem
let playerItem = AVPlayerItem(asset: asset)

// 3. 直接初始化AVPlayer
let player = AVPlayer()

// 4. 绑定AVPlayer至AVPlayerLayer,让系统提前准备好视频渲染管线
let playerLayer = AVPlayerLayer(player: player) 

// 5. 最后输入playerItem,开始视频播放
player.replaceCurrentItemWithPlayerItem(playerItem)

可以看出两段代码只是简单地调整了播放器初始化的流程,却可以让系统在播放时节省初始化的时间。
为加快首帧播放,可以提前配置好 AVPlayer 和 AVPlayerItem, 但只是配置,不需要初始化相关依赖。提前将 AVPlayerLayer 连接到 AVPlayer 上,最后再通过replaceCurrentItem(with: playerItem), 将 player 与 item 绑定起来,开始视频播放。

获取视频播放总时间,playerItem.duration 返回nan

CMTimeGetSeconds(playerItem.duration)有时返回nan, 导致算不出进度条总长度,这可能是 AVPlayerItem 的一个bug, 改为CMTimeGetSeconds(playerItem.asset.duration)后没有再出现过这个问题。
另外系统加载视频资源是异步的,可能创建一个 playeritem 后,立马去访问视频相关的属性如时长等,会是一个错误的值,要等到 playeritem.status == .readyToPlay 后再取。

播放错误处理

通常是 URLSessionTask 下载过程出错,缓存框架本身有容错处理: 保存已下载数据,中断URLRequest, urlTasks remove 本次任务。反映到上层就是kvo playerItem.status 返回AVPlayerItem.Status.failed, 错误原因可以通过 playerItem.error查看。首先设置 retryCount, 超过重试次数提示用户加载错误:

  • 如果是常见错误码如网络问题NSURLErrorNotConnectedToInternet/NSURLErrorCannotFindHost, resetVideo, 调用 player.seekTo 会重启新的 loadingRequest, 从而重新创建 VideoDownloadTask.
  • 其他错误则 resrtPlayer, 直接重新初始化 player.

开始播放的时机

通常大家会在收到 playeritem.status == .readyToPlay 后立即播放,可能会导致开始播放时黑屏/花屏一下,原因是下载数据还不够,导致卡顿和解码异常,影响播放体验。控制播放/暂停逻辑最好结合playbackBufferEmptyplaybackLikelyToKeepUp这两个属性, 当监听到playbackLikelyToKeepUp, 表明现在 avplayer 可以无延迟播放,此时再play().

UITableView 视频列表

AVPlayer播放单个视频并没有太大的性能问题,但在 tableView 中滚动时,自动切换和播放视频容易造成滑动卡顿,使用时注意几个问题:

  1. AVPlayer 渲染通道是有上限的。 之前 tableviewCell 没有复用时,发现超过18个视频后,后续的 avplayer 都无法播放了,playeritem.error返回Code=-11839 Cannot Decode. 这是由于 AVPlayer 通道创建过多,被 AVFoundation 限制解码。当调用代码player = AVPlayer(playerItem: playerItem)即创建了一条“渲染通道”,AVFoundation 对这样的 AVPayer 实例是有个数限制的,上限值不定,和iOS 硬件/系统相关。所以要尽可能减少 player 个数,cell重用是基本款。参考这个问题
  2. 使用player = AVPlayer(playerItem: playerItem)新建一个 AVPayer, 或者使用player.replaceCurrentItem(with: playerItem)切换播放源都是比较耗时的操作,之前在每个cellForRow.. 刷新数据时执行,滑动可见肉眼卡顿,经 Instruments 排查,replaceCurrentItem(with: playerItem) 会花费 30ms 左右, 从而造成 UI 线程阻塞。网上一些资料讲到 「该方法在切换视频时底层会调用信号量等待」 导致当前线程卡顿,因此尝试将该方法放到子线程执行,卡顿问题有缓解,但引出一些播放异常问题(下一条)。
    重新思考下,列表滑动过程中是不需要播视频的,我们通常是滑动停止后,自动播放位于顶部或中间的视频,或者由用户点击触发。player 可以此时再切换视频源,滑动过程中cellForRow..只需展示视频封面等静态元素。缺点是每个视频首次加载时间略有延长,不过相比滑动时的卡顿,视频开始播放前的缓冲是容易接受的。
  3. replaceCurrentItem(with:)放到子线程后,由于播放无法及时响应,较高频率会出现滑动结束后,待播放 playerItem 一直加载失败,或者视频有声音开始播,但画面一直是黑色的等现象。查看苹果文档,注册和取消kvo的操作都应放到主线程,因为 AVFoundation 强制在主线程触发observeValue(forKeyPath:of:change:context:), 因此 playerItem 的初始化、replaceCurrentItem(with: playerItem), 以及kvo相关、addPeriodicTimeObserver都统一在主线程调用,避免出现状态不同步的情况。