美文网首页
AVFoundation实现HLS音频播放

AVFoundation实现HLS音频播放

作者: 冰三尺 | 来源:发表于2018-12-15 13:33 被阅读82次

    AVFoundation框架是iOS中很牛X的框架,所有与视频音频相关的软硬件控制都在这个框架里面,这个框架真心的厉害, 苹果还是够贴心, 音视频播放只是这个框架里的一小部分而已.

    如果要做一个流媒体音乐播放器, 强烈建议使用AVFoundation自己去开发, 不要使用别人基于AVFoundation封装的, 因为会有很多坑. 因为项目急的原因使用了一个用OC封装的AVFoundation, github上几千个star, 应该是比较靠谱了, 但是使用之后发现大问题没有, 小问题有一些, 再加上公司项目是Swift 开发, 还要混编, 煎熬了2个月, 狠心用swift 重写了一套, 自己埋得坑, 自己填呀, 还要麻烦测试人员再来一次测试, 吃力不讨好呀, 心塞塞的.


    音乐播放相比于视频播放最大的一个特点就是音乐播放器是存在于整个App运行期间的, 不会销毁, 比如QQ音乐播放器, 是永远随着App的运行而运行的. 那么问题来了, 如何保证播放器一直存在呢?

    自然而然的想到的就是单例, 那么问题来了, 是把AVPlayer写成单例还是把控制器写成单例呢?因为播放器一定是伴随着播放页面同时存在的, 比如QQ音乐, 每一首歌曲都会有一个对应的播放页面.

    我个人觉得音乐播放器是管理音频数据的, 而控制器是用于渲染音频数据的, 两者可以说没有必然的关系, 这不正是MVC的的设计模式吗? 播放器充当着MC的角色.

    1. 播放

    播放无非就是使用AVPlayer, 来加载一个HLS的地址, 然后就可以播放了. 但是播放的时候, 我们需要知道当前播放的一些状态, 是否可以播放, 比如播放的进度, 音频的时长等等, 这些都是通过一些监听来实现的.

    这里第一个坑, 音频播放是一个比较耗费资源和时间的, 如果某一时间, 网络比较差, 从网络加载资源比较耗时, 会出现阻塞UI线程的卡死现象, 在加载资源的时候就不应该在UI线程处理

        public func loadValuesAsynchronously(forKeys keys: [String], completionHandler handler: (() -> Void)? = nil)
    

    这个方法是一个异步加载资源方法, 其中keys是需要加载资源的属性. 为了播放资源的可使用首先应该判断AVURLAsset的是否可用isPlayablehasProtectedContent 来判断资源是否可用.

        static let assetKeysRequiredToPlay = [
            "playable",
            "hasProtectedContent"
        ]
    
    // newAsset是AVURLAsset的实例
    newAsset.loadValuesAsynchronously(forKeys: assetKeysRequiredToPlay) {
                DispatchQueue.main.async {
                      // 资源不可播放
                    if !newAsset.isPlayable || newAsset.hasProtectedContent {
                        let message = NSLocalizedString("error.asset_not_playable.description", comment: "Can't use this AVAsset because it isn't playable or has protected content")
        
                        return
                    }
                }
    }
    
    

    当接收到loadValuesAsynchronously的加载完成回调的时候, 需要回到主线程, 如果资源不可用或者受保护, 则不可播放.

    如果可播放, 接下来就是监听播放了

           let observedKeyPaths = [
                #keyPath(BYAudioStreams.player.currentItem.status),
                #keyPath(BYAudioStreams.player.currentItem.loadedTimeRanges),
                #keyPath(BYAudioStreams.player.currentItem.seekableTimeRanges),
                #keyPath(BYAudioStreams.player.currentItem.isPlaybackBufferEmpty),
                #keyPath(BYAudioStreams.player.currentItem.isPlaybackLikelyToKeepUp),
                #keyPath(BYAudioStreams.player.currentItem.isPlaybackBufferFull),
                #keyPath(BYAudioStreams.player.timeControlStatus)
            ]
            for keyPath in observedKeyPaths {
                addObserver(self, forKeyPath: keyPath, options: [.new, .initial], context: nil)
            }
    

    这里使用keyPath开监听播放器的各种状态, 在监听的回调里面可以拿到响应的播放状态和数据.

    override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
            DispatchQueue.main.async {
            }
    }
    

    在收到监听回调的时候, 由于AVFoundation 没有指定在哪个线程执行status通知, 所以要确保应用程序回到主线程, 向其传递一个主队的引用

    #补充一个坑# 如果当前音频正在播放, 此时暂停播放, 然后退到后台, 不要杀掉App, 然后过几分钟大概3-5分钟吧(具体的时间长度由于苹果官网也没有说明, 我自己也没有准确的测试过)这是再打开App, 会发现自动开始播放了, what F! 明明是暂停了.

    之所以自动播放了, 原因就在于当回到App是又走了监听的回调, status 的状态是 AVPlayerItem.Status.readyToPlay (暂停时也是这个状态, 这个状态代表的是资源是否可以播放, 与暂停无关) 如果只是简单的在AVPlayerItem.Status.readyToPlay中执行播放, 那么就会自动播放了.
    所以这里要单独处理了, 根据自己定义的状态来却别当前是播放还是暂停, 如果是暂停就不要执行播放了.
    因为监听了status的状态, 回到前台会走监听的方法.

    override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
            //由于AVFoundation 没有指定在哪个线程执行status通知, 所以要确保应用程序回到主线程, 向其传递一个主队了引用
            DispatchQueue.main.async {
                guard let playItem:AVPlayerItem = (object as! BYAudioStreams).playerItem else {
                    return
                }
                if keyPath == #keyPath(BYAudioStreams.player.currentItem.status) {
                    if playItem.status == AVPlayerItem.Status.readyToPlay {
                        /// 暂停播放, 退到后台几分钟, 进入App, 会走这个方法, 进行判断, 如果是正在播放则进行播放, 否则不自动播放.
                        if 如果是播放状态 {
                            self.play()
                        }
                    }else if playItem.status == AVPlayerItem.Status.failed {
    
                    }else if playItem.status == AVPlayerItem.Status.unknown {
    
                    }
               }
    }
    

    自己定义的关于播放的状态

    enum PlaybackState {
        case unknown
        case playing
        case paused
        case failed
        case stopped
        case buffering
    }
    
    enum PlaybackLoadState {
        case unknown
        case loading
        case loaded
        case failed
        case cancelled
    }
    

    播放进度监听

    //定期监听
        private func addPlayItemTimeObserver() {
            let interval = CMTime(seconds: 0.5,
                                  preferredTimescale: CMTimeScale(NSEC_PER_SEC))
            let mainQueue = DispatchQueue.main
            timeObserver = self.player.addPeriodicTimeObserver(forInterval: interval, queue:mainQueue) { [weak self]time in
                let currentTime:TimeInterval = CMTimeGetSeconds(time)
                let duration:TimeInterval = CMTimeGetSeconds(self?.playerItem?.asset.duration ?? CMTimeMake(value: 0, timescale: 0))
                self?.audioStreamsProtocol?.audioPlayTimeChange(currentTime: currentTime, duration: duration)
                //print("currentTime = \(currentTime) duration = \(duration)")
            }
        }
    

    判断是否播放

        func isPlaying() -> Bool {
            if #available(iOS 10.0, *) {
                return self.player.timeControlStatus == AVPlayer.TimeControlStatus.playing
            } else {
                return self.player.rate == 1
            }
        }
    

    第二个坑, 这个判断播放暂停的状态, 有系统来决定其状态, 比如被打断时, 会自动变为暂停, 缓冲不足也会自动变为暂停, 使用时需要注意这些情况. 播放和暂停不仅仅由用于手动出发play和pause事件, 还有系统的一些事件来决定.

    播放和暂停

        func play() {
            player.play()
        }
        
        func pause() {
            player.pause()
        }
        
        func stop() {
            //AVPlayer cannot service a synchronized playback request via setRate:time:atHostTime: until its status is AVPlayerStatusReadyToPlay.
            guard let playItem = player.currentItem else {
                return
            }
            if playItem.status == .readyToPlay {
                playItem.asset.cancelLoading()
                player.setRate(0.0, time: CMTime.zero, atHostTime: CMTime.zero)
       
        }
    

    AVFoundation提供了play和pause的事件, 但是没有stop事件, 想想也是可以理解为啥没有stop事件, play和pause只是播放器播放状态的改变, 而stop是彻底销毁了播放器, 如果要想销毁播放器, 仅仅靠一个stop远远不够的, 还要要移除相应的监听, 通知, 释放AVPlayer, 所以是否是stop这个需要开发者自己去定义, 但是对于播放器而言, 一般是不需要释放销毁AVPlayer的, 因为音乐播放只有播放和暂停状态, 没必要使用stop.

    播放通知

    可以通过注册通知的方法来监听播放的一些状态

        private var playbackFinishedNotification: NSObjectProtocol?
        private var playbackStalledNotification: NSObjectProtocol?
        private var playbackFailedToPlayToEndTimeNotification: NSObjectProtocol?
        private var playbackAccessLogEntryNotification: NSObjectProtocol?
        private var playbackErrorLogEntryNotification: NSObjectProtocol?
     //播放失败的通知
        private func registerFailedToPlayToEndTimeNotification() {
            playbackStalledNotification = NotificationCenter.default.addObserver(forName: NSNotification.Name.AVPlayerItemFailedToPlayToEndTime, object: audioStream.player.currentItem, queue: OperationQueue.main, using: { (notification:Notification) in
            })
        }
        //播放中断的通知
        private func registerPlaybackStalledNotification() {
            playbackStalledNotification = NotificationCenter.default.addObserver(forName: NSNotification.Name.AVPlayerItemPlaybackStalled, object: audioStream.player.currentItem, queue: OperationQueue.main, using: { (notification:Notification) in
                print("AVPlayerItemPlaybackStalled")
            })
        }
        //播放完成的通知
        private func registerPlayToEndTimeNotification() {
            playbackFinishedNotification = NotificationCenter.default.addObserver(forName: NSNotification.Name.AVPlayerItemDidPlayToEndTime, object: audioStream.player.currentItem, queue: OperationQueue.main) { (notification:Notification) in
       
            }
        }
        
        //播放日志
        private func registerPlaybackLogNotification() {
            playbackAccessLogEntryNotification = NotificationCenter.default.addObserver(forName: NSNotification.Name.AVPlayerItemNewAccessLogEntry, object: audioStream.player.currentItem, queue: OperationQueue.main, using: { (notification:Notification) in
                let playItem = (notification.object as! AVPlayerItem)
                var totalDurationWatched = 0.0
                if let accessLog = playItem.accessLog() {
                    for event in accessLog.events {
                        if event.durationWatched > 0 {
                            totalDurationWatched += event.durationWatched
                        }
                    }
                }
            })
            
            playbackErrorLogEntryNotification = NotificationCenter.default.addObserver(forName: NSNotification.Name.AVPlayerItemNewErrorLogEntry, object: audioStream.player.currentItem, queue: OperationQueue.main, using: { (notification:Notification) in
                
            })
        }
    

    释放AVPlayer

    既然我们的播放器已经被写成了单例, 也就是说整个App运行期间, 只存在一个AVPlayer实例对象, 根本不要去释放, 也不要要去移除监听, 这样反而少做了一些处理.

    那么如果只有一个AVPlayer对象, 但是我们的音乐不是只有一个, 更换音乐的时候使用open func replaceCurrentItem(with item: AVPlayerItem?)来更改AVPlayer的playItem来实现切换音频的目的.
    但是如果是使用AVPlayer来实现视频播放器, 那就应该来释放相应的资源了.


    弱网处理

    以上都是一些基础的AVPlayer使用简介, 这些都是正常的流程, 但是我们在播放HLS资源时, 取决于网络, 因为网络产生的问题才是最棘手的部分.

    在最初的KVO监听时其中有以下四个监听

    #keyPath(BYAudioStreams.player.currentItem.isPlaybackBufferEmpty),
                #keyPath(BYAudioStreams.player.currentItem.isPlaybackLikelyToKeepUp),
                #keyPath(BYAudioStreams.player.currentItem.isPlaybackBufferFull),
    #keyPath(BYAudioStreams.player.timeControlStatus)
    
    

    下面是弱网情况下执行的顺序, 使用Charles模拟弱网.

    /**
         当网络很差的情况下, 从开始缓冲到播放的执行顺序
         //1. 接受到缓存不足的回调, #keyPath(BYAudioStreams.player.currentItem.isPlaybackBufferEmpty)
         isPlaybackBufferEmpty = true
         //2. 系统执行暂停, #keyPath(BYAudioStreams.player.timeControlStatus)
         AVPlayer.TimeControlStatus.paused
         //3. 系统执行中断的通知, NSNotification.Name.AVPlayerItemPlaybackStalled
         AVPlayerItemPlaybackStalled
         //4. 开始缓冲, #keyPath(BYAudioStreams.player.currentItem.isPlaybackBufferEmpty)
         buffering...
         AVPlayer.TimeControlStatus.paused
         //5. 缓冲到可以播放时, 开始播放.
         isPlaybackBufferEmpty = false
         AVPlayer.TimeControlStatus.playing
         //  true 可以播放, false 不可以
         isPlaybackLikelyToKeepUp = false
         */
    

    isPlaybackBufferEmpty 表示已经消耗了所有的缓冲数据
    1. 可能是缓冲数据没有缓冲完, 但是已经播放到了缓冲的地方, 不能继续播放, 此时暂停
    2. 可能是数据已经缓冲完了, 此时播放到了最后, 此时播放完成

    如果想要在缓冲不足的时候加一个菊花, 可以在第一步时候添加, 第五步的时候移除.

    AVAudioSession

    AVAudioSession就是用来管理多个APP对音频硬件设备(麦克风,扬声器)的资源使用。

    音频播放器肯定少不了锁屏控制, 后台播放, 这些事必须有的, 否则AppStore无法上架.

    监听打断(比如音频播放的时候来了电话), 监听播放路径变化(耳级插拔)

    let notificationCenter = NotificationCenter.default
            notificationCenter.addObserver(self,
                                           selector: #selector(handleInterruption),
                                           name: AVAudioSession.interruptionNotification,
                                           object: nil)
    
    @objc func handleInterruption(notification: Notification) {
            guard let userInfo = notification.userInfo,
                let typeValue = userInfo[AVAudioSessionInterruptionTypeKey] as? UInt,
                let type = AVAudioSession.InterruptionType(rawValue: typeValue) else {
                    return
            }
            if type == .began {
               
                // Interruption began, take appropriate actions
            }else if type == .ended {
                if let optionsValue = userInfo[AVAudioSessionInterruptionOptionKey] as? UInt {
                    let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue)
                    if options.contains(.shouldResume) {
                     
                        // Interruption Ended - playback should resume
                    } else {
                      
                        // Interruption Ended - playback should NOT resume
                    }
                }
            }
        }
    

    这个有一个需要注意的点, 打断开始一定会执行, 但是打断结束不一定会执行, 只有打断结束并且状态时shouldResume时, 才可以执行继续播放

    锁屏显示与控制

    锁屏控制使用MPRemoteCommandCenter, 锁屏显示使用MPNowPlayingInfoCenter

    class BYRemoteCommandCenter: NSObject {
        class func setupRemoteTransportControls() {
            // Get the shared MPRemoteCommandCenter
            let commandCenter = MPRemoteCommandCenter.shared()
    //        commandCenter.playCommand.isEnabled = true
            // Add handler for Play Command
            commandCenter.playCommand.addTarget { event in
               // 播放
                return .success
            }
            // [unowned self]
            // Add handler for Pause Command
            commandCenter.pauseCommand.addTarget { event in
                // 暂停
                return .success
            }
            
            commandCenter.nextTrackCommand.addTarget { (event) -> MPRemoteCommandHandlerStatus in
                // 如果有下一个可播放 
                if BYPlaybackSession.shareInstance().isCanPlayNext() {
                    // 播放下一个
                    return .success
                }else {
                    if #available(iOS 9.1, *) {
                        return .noActionableNowPlayingItem
                    }else {
                        return .success
                    }
                }
            }
            
            commandCenter.previousTrackCommand.addTarget { (event) -> MPRemoteCommandHandlerStatus in
                // 如果有上一个可播放
                if BYPlaybackSession.shareInstance().isCanPlayPrevious() {
                    // 播放上一个
                    return .success
                }else {
                    if #available(iOS 9.1, *) {
                        return .noActionableNowPlayingItem
                    }else {
                        return .success
                    }
                }
            }
        }
    }
    

    关于锁屏控制, 比较老的文档会写到重写remoteControlReceived

    override func remoteControlReceived(with event: UIEvent?) {
            if event?.type == UIEventType.remoteControl {
                switch event?.subtype {
                case .remoteControlPlay?:
                    break
                case .remoteControlPause?:
                    XLPlayFmOps.defaultPlay().pause {
                    }
                    break
                case .remoteControlNextTrack?:
                    print("next")
                    break
                case .remoteControlPreviousTrack?:
                    print("previous")
                    break
                default: break
    
                }
            }
        }
    

    我自己使用这个出现一些很诡异的问题, 各种谷歌都找不到解决方案, 不推荐使用.

    关于播放还不够完整, 现在还需要一个缓存的功能, HLS资源, 每次播放完了, 如果再播放还需要再次加载, 如果能实现边听边存, 那就跟好了, 好在Apple给我们提供了一个边听边存的类AVAssetResourceLoader, 这个由于资源真的很少, 也没有听说有谁开源过成熟的方案, 自己也在摸索中. 如果你有资源, 还请共享下.


    2018.12.17日补丁

    相关文章

      网友评论

          本文标题:AVFoundation实现HLS音频播放

          本文链接:https://www.haomeiwen.com/subject/ysftoftx.html