使用AVPlayer+AFNetworking封装一个带有缓存逻辑的音频播放器

音频播放一直是一个最常用的功能,不管是否是以该功能为主业务的app,音频播放都可以作为一个模块存在于其中。

一般来说,在普通的业务需求中,很少会遇到直接播放本地资源文件的情况,基本都是给一个资源链接再播放。

那么,在一个以音频播放为核心功能的app中,相关延伸的功能也就必须实现:

  • 边缓冲边播放。
  • 播放进度控制。
  • 缓存机制(即播放过一遍的链接再次播放时无需再次请求资源)
  • 后台播放以及远程控制响应(从锁屏和控制中心进行的播放控制)

那么为何使用AVPlayer作为播放框架,而不使用市面上其他的第三方封装的流媒体播放框架(AudloStreamer, freeStreamer等)呢?这里说明一下,我个人在工作中遇到的坑。

AudloStreamer是一款历史久远的流媒体播放框架,基于苹果的AudioToolbox封装,功能强大,不仅实现了播放,缓冲,缓存等必备机制,还可提供一些音频数据处理的相关功能,可谓是十分强大。而在我负责的项目中原本使用的也是该框架。此框架作为核心音频播放框架使用,一直表现稳定,并无任何bug出现。但是,iOS13的出现,彻底动摇了此框架的地位。

众所周知,iOS13出现的初期,可谓是烽烟四起,各大app频频出现被无故杀后台的现象,但知道真相的用户毕竟是少数,多数用户会把现象归结于程序本身,认为是程序自身出了bug,才导致的后台无故退出。当然苹果后来也对这种疯狂杀后台的机制做了调整(估计是收到了大量开发者的抗议),但某些机制并未作出调整。

比如,我负责的这个项目也出现了崩溃率暴涨的现象(因iOS杀后台是被系统级的watchDog强行结束进程,因此会被崩溃监听的框架认为是异常退出),而用户并不知道退出的真正原因,只从现象上进行反馈,比如:“为什么我播着播着就停了?”,“为什么从后台打开就重启了?”,“为什么后台播放停止了我在锁屏上点什么都没反应?”等等等等。这一系列反馈的根本原因都是因为我这个项目在后台播放时,莫名其妙的被系统看门狗干掉了。

在这个现象出现的初期,我实在一头雾水,因为崩溃的线程很奇怪,那是一个网络请求使用的线程,而这个网络请求使用的框架更是一个远古的框架,著名的ASIHTTPRequset。而在此之前,整个后台播放的流程运行到此处时并未出现问题,而且崩溃位置被记录到了框架代码内部。由于该框架早已无人维护,因此也无法向框架作者寻求帮助,于是只能自己开始研究。

过程就不多说了,直接说结果。经过一系列的尝试,最终问题锁定在了播放框架上,也就是AudloStreamer。我们都知道,如果想让你的app在后台播放音频,那必须要先启动系统的音频播放通道AVAudioSession。当然,这在之前肯定也是做了的,否则早就出问题了。但是,在iOS13中,当我这个项目退到后台播放时,只要播放结束,下一个音频开始播放之后程序就会被杀死。然而,我尝试使用苹果自己封装的AVAudioPlayer循环播放一个本地音频文件时,却可以正常播放。

这也就解释了为什么之前的崩溃会发生在网络层。请求发出了,等待响应的时候app直接被杀死了,当然是网络请求的进程会报错。所以我得出结论,如果使用苹果封装的音频播放框架,则程序可以正常存活在后台,如果使用自己封装的播放框架,则程序就不能正常在后台存活。

这就是我选择了AVPlayer的主要原因。一个以音频播放为核心功能的app,如果不能正常在后台持续播放那还玩个锤子。而且,AVPlayer的封装度很高,使用方便,可提供一切播放器所需的数据支持(除了缓存文件)。

好了,说了这么多,也该上代码了。

在使用之前,我们需要知道几个关键类:

  • AVPlayer(播放器类) :播放器主体,控制播放状态,播放进度,以及播放源
  • AVPlayerItem(播放源):可播放的资源,包含各种资源信息,以及资源的状态
  • CMTime(播放源中使用的时间相关结构体)  :AVPlayer中所有时间相关的属性都是该结构体
  • AVAudioSession(系统的音频通道):这个都知道
  • MPRemoteCommandCenter(远程控制命令中心):这个理论上也都知道

在实际工作中,会因为业务需求不同,我们所用的数据结构也不同,但最终,我们都会用到的一定有资源链接。所以在demo中,我定义的也只有链接,其他属性可根据自己需要再补充。

那么,我们从我们得到了资源链接之后开始。

有了资源链接,我们需要先创建播放源。

/// 创建播放源
/// @param url 链接
- (AVPlayerItem *)getAVPlayItemWithURL:(NSURL *)url{
    AVPlayerItem *playerItem = [[AVPlayerItem alloc] initWithURL:url];
    if (@available(iOS 10.0, *)) {
        playerItem.preferredForwardBufferDuration = 1;
    }
    return playerItem;
}

然后我们可以选择播放器的创建思路,如果你只需要播放一次音频,可直接用播放源创建播放器,当然也可直接用资源链接直接创建,但如果你需要监听播放时的各种属性,就需要播放源了。

AVPlayer *player = [AVPlayer playerWithPlayerItem:playerItem];

如果你需要的是播放音频列表,那懒加载创建一个播放器是更优选择。然后使用创建的播放源替换当前播放源

/// 懒加载生成player
- (AVPlayer *)player{
    if(!_player){
        _player = [[AVPlayer alloc] init];
        //添加音频播放结束时的通知监听
        [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(_playEOF) name:AVPlayerItemDidPlayToEndTimeNotification object:nil];
    }
    return _player;
}

/// 替换播放源
/// @param url 链接
- (void)resetPlayItemWithURL:(NSURL *)url{
    //移除正在当前播放源的监听
    [self removeObserversFromPlayerItem];
    //创建新播放源
    AVPlayerItem *playerItem = [self getAVPlayItemWithURL:url];
    
    //替换播放源
    [self.player replaceCurrentItemWithPlayerItem:playerItem];
    if (@available(iOS 10.0, *)) {
          self.player.automaticallyWaitsToMinimizeStalling = NO;
    }
    //给新播放源加监听
    [self addObserverToPlayerItem:playerItem];
}

给当前播放源添加监听事件,在添加之前,需移除上一个播放源的监听。

/// 给播放源添加监听
/// @param playerItem 播放源
- (void)addObserverToPlayerItem:(AVPlayerItem *)playerItem{
    //监听字段@"status":播放源状态
    [playerItem addObserver:self
                 forKeyPath:@"status"
                    options:NSKeyValueObservingOptionNew
                    context:nil];
    //监听字段@"playbackBufferEmpty":缓冲区为空
    [playerItem addObserver:self
                 forKeyPath:@"playbackBufferEmpty"
                    options:NSKeyValueObservingOptionNew
                    context:nil];
    //监听字段@"playbackLikelyToKeepUp":播放似乎可继续进行
    [playerItem addObserver:self
                 forKeyPath:@"playbackLikelyToKeepUp"
                    options:NSKeyValueObservingOptionNew
                    context:nil];
    //监听字段@"loadedTimeRanges":已加载的时间区间
    [playerItem addObserver:self
                 forKeyPath:@"loadedTimeRanges"
                    options:NSKeyValueObservingOptionNew
                    context:nil];
}

/// 移除播放源监听
- (void)removeObserversFromPlayerItem
{
    if (self.player.currentItem){
        [self.player.currentItem removeObserver:self forKeyPath:@"status"];
        [self.player.currentItem removeObserver:self forKeyPath:@"playbackBufferEmpty"];
        [self.player.currentItem removeObserver:self forKeyPath:@"playbackLikelyToKeepUp"];
        [self.player.currentItem removeObserver:self forKeyPath:@"loadedTimeRanges"];
    }
}

完成这些之后,其实已经可以播放音频了,其实如果只想播放,直接用链接创建播放器,然后play就可以了,所以说AVPlayer的封装度真的很高。

然后,我们需要实现监听方法。

#pragma mark- -KVO 监听实现

- (void)observeValueForKeyPath:(NSString *)keyPath
                      ofObject:(id)object
                        change:(NSDictionary *)change
                       context:(void *)context
{
    
    if (object == [self.player currentItem]) {
        //播放源状态
        if ([keyPath isEqualToString:@"status"]) {
            AVPlayerStatus status = [[change objectForKey:NSKeyValueChangeNewKey] integerValue];
            switch (status) {
                case AVPlayerStatusUnknown:{
                    //未知
                }
                    break;
                case AVPlayerStatusReadyToPlay:{
                    [self.player play];
                }
                    break;
                //失败时,停止
                case AVPlayerStatusFailed:{
                    NSLog(@"播放失败:%@",self.player.currentItem.error);
                    [self.player stop];
                }
                    break;
                    
                default:
                    break;
            }
        } else if ([keyPath isEqualToString:@"playbackBufferEmpty"] && self.player.currentItem.playbackBufferEmpty) {
            //缓冲区为空时,播放状态为加载中,并暂停播放器
            [self.player pause];
        } else if ([keyPath isEqualToString:@"playbackLikelyToKeepUp"]) {
            //播放源可持续播放,按需进行操作
        } else if ([keyPath isEqualToString:@"loadedTimeRanges"]) {
            //当前缓冲区间发生变化时,读取已缓冲的进度,并通知进度条更新UI
            CMTime playerDuration = [[self.player currentItem] duration];
            //判断当前播放源的整体播放区间是否合法
            if (!CMTIME_IS_INVALID(playerDuration)) {
                //获取已缓存的时间
                NSTimeInterval *bufferTime = (NSTimeInterval)[self availableDuration];
                //发送通知给UI层更新缓冲进度。
            }
        }
    }
}

// 返回当前已缓存的时长
- (float)availableDuration
{
    NSArray *loadedTimeRanges = [[self.player currentItem] loadedTimeRanges];
    if ([loadedTimeRanges count] > 0) {
        CMTimeRange timeRange = [[loadedTimeRanges objectAtIndex:0] CMTimeRangeValue];
        float startSeconds = CMTimeGetSeconds(timeRange.start);
        float durationSeconds = CMTimeGetSeconds(timeRange.duration);
        return (startSeconds + durationSeconds);
    } else {
        return 0.0f;
    }
}

还有一些播放器需要的属性需要单独获取,并通过通知的方式通知UI层刷新UI,具体可通过demo查看

到此,一个可以边缓冲边播放的播放器就完成了。

如果还想实现缓存机制,那还需要做如下操作。

先说明一点,其实有很多第三方库可以进行边缓存边播放,并且将player本身的缓冲进度替换为缓存进度,实现的核心思想是以AVURLAsset创建播放源,设置它的resourceLoader的delegate,然后通过代练方法重写AVPlayer自己封装的缓冲机制。方法固然好,我也进行了尝试,但播放源总是报错,因为我这个项目使用的链接并非正常的链接,通过AVAssetResourceLoaderDelegate的代理方法返回的链接均不能再次使用,因此,这个办法被废弃。

之后,我便又生一计,既然AVPlayer可以自己边缓冲边播放,那我只需要单独实现一个缓存机制,然后在播放前判断本地是否有缓存文件不就得了吗。于是,AFNetworking便派上用场。

在设置播放链接之前,我先对本地是否有完整的缓存文件进行判断,如没有或文件不完整,则一遍开始播放,一遍去下载文件进行缓存。下载方式如下:

//缓存音频原始数据
- (void)requestRawAudioDataWithURL:(NSURL *)url
{
    //先取消上一次的下载任务
    if (_cacheDownloadTask && _cacheDownloadTask.state == NSURLSessionTaskStateRunning) {
        [_cacheDownloadTask cancel];
        _cacheDownloadTask = nil;
    }
    //创建任务
    _cacheDownloadTask = [self.cacheDownloadManager downloadTaskWithRequest:[NSURLRequest requestWithURL:url] progress:^(NSProgress * _Nonnull downloadProgress) {
        //下载进度,可按需求对UI层进行刷新
        NSLog(@"下载进度:%.0f%", downloadProgress.fractionCompleted * 100);
    } destination:^NSURL * _Nonnull(NSURL * _Nonnull targetPath, NSURLResponse * _Nonnull response) {
        //保存完整文件的长度
        if (response.expectedContentLength > 0) {
            //TODO: 需要自己创建本地缓存,记录对应音频文件的总长度,已用于判断文件是否完整。
        }
        NSURL *path = //TODO: 需要自己定义本地存储路径
        return path;
    } completionHandler:^(NSURLResponse * _Nonnull response, NSURL * _Nullable filePath, NSError * _Nullable error) {
        NSLog(@"下载完成");
    }];
    
    [_cacheDownloadTask resume];
}

对应下载路径的操作,具体可以参考demo。

缓存之前先取消上次的缓存是为了节约内存资源,否则一旦用户快速切歌,就会导致所有的音频都要进行缓存。

当然,有缓存,就得有清空缓存,否则你的app会越来越大,最终被用户放弃。

先这样吧。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值