iOS视频播放详解2-封装边下边播的播放器
引言
最近项目中需要一个播放器,并且要对视频进行缓存,那么最好的方式就是边下边播,播完之后如果数据完整就把视频数据保存到硬盘(沙盒中 ),显然用这些方法就不能满足要求了(总不能播完之后再去下载吧,呵呵), 所以就深入研究了一下AVFoundation框架(其实AVFoundation就是一个对多媒体操作的库),这个框架中的东东还是很多很复杂的,因为是相对较为底层的一些东西嘛。下面首先来看看跟我们要实现的播放器有关系的AVFoundation框架中的一些类
AVAsset
AVAsset是一个抽象的基类。由多种AVAssetTrack(音频轨道、字幕轨道、视频轨道等)集合组成,描述了视频内容的静态内容,例如时长创建日期等,不能直接使用,一般我们会使用它的子类AVURLAsset,AVURLAsset实现了AVAsset中的一些方法可以创建对象,一般我们用url对它进行初始化。 在音视频播放中AVURLAsset主要负责链接相关的服务器,从URL中请求音视频数据,下面来看看AVAsset中的一些熟悉:
// 媒体的时长。
@property (nonatomic, readonly) CMTime duration;
// 媒体的默认播放速度。这个值绝大部分时间为1.0。
@property (nonatomic, readonly) float preferredRate;
// 媒体的默认播放音量。这个值绝大部分时间为1.0。
@property (nonatomic, readonly) float preferredVolume;
// 媒体的旋转,缩放,平移量。 The identity transform: [ 1 0 0 1 0 0 ]。
@property (nonatomic, readonly) CGAffineTransform preferredTransform;
// 此资源中包含的所有的AVAssetTrack , AVAsset 可以通过标识符,媒体类型或媒体特征等信息找到相应的track。
@property (nonatomic, readonly) NSArray<AVAssetTrack *> *tracks;
// 包含着当前视频常见格式类型的元数据。
@property (nonatomic, readonly) NSArray<AVMetadataItem *> *commonMetadata;
// 包含当前视频所有格式类型的元数据。
@property (nonatomic, readonly) NSArray<AVMetadataItem *> *metadata;
// 包含当前视频所有可用元数据的格式类型。
@property (nonatomic, readonly) NSArray<NSString *> *availableMetadataFormats;
AVPlayerItem
AVPlayerItem用于统筹数据,用于管理视频的动态内容和在播放资源的呈现状态(即:视频播放着的各种状态如:播放器是否准备好要去播放,数据请求是否失败,本地数据是否已经播放完了等,后面我们即将详细讲解)。用AVURLAsset对它进行初始化, 它就如同MVC中的model。
如果把AVPlayer,AVPlayerLayer,AVPlayerItem按照MVC架构划分的话,我认为,AVPlayer是C,AVPlayerLayer是V,AVPlayerItem是M
下面我们来看看它的一些属性。
//视频的播放状态
@property (nonatomic, readonly) AVPlayerItemStatus status;
//视频的播放时长
@property (nonatomic, readonly) CMTime duration;
//视频缓存区域大小
@property (nonatomic, readonly) NSArray<NSValue *> *loadedTimeRanges;
//callback,缓冲区有足够数据可以播放
@property (nonatomic, readonly, getter=isPlaybackLikelyToKeepUp) BOOL playbackLikelyToKeepUp;
//callback,缓冲区满了
@property (nonatomic, readonly, getter=isPlaybackBufferFull) BOOL playbackBufferFull;
//callback,缓冲区空了,需要等待数据
@property (nonatomic, readonly, getter=isPlaybackBufferEmpty) BOOL playbackBufferEmpty;
以上控制属性在视频播放中很常用,我们通常使用 KVO 来监控视频播放状态和缓冲区的状态
AVPlayer
AVPlayer 是一个不可见组件(不能显示视频画面),其实最主要的功能是对视频进行解码,并对视频播放进行控制,提供了play,pause以及跳动某个时间点开始播放等功能。
AVPlayerLayer
AVPlayerLayer 是构建于 Core Animation 框架之上(注1)的图层类型,扩展了 Core Animation 的 CALayer 类,为 iOS 的视频渲染提供支持,其实它在视频播放中的功能最主要是显示视频数据。
下面我们开始播放器的详解
首先我们的需求是:
-
支持正常播放器的一切功能,包括暂停、播放和拖拽
-
如果视频加载完成且完整,将视频文件保存到本地cache,下一次播放本地cache中的视频,不再请求网络数据
-
如果视频没有加载完(半路关闭或者拖拽)就不用保存到本地cache,因为数据不完整嘛
根据需求我们的需要实现的功能是:
-
有开始暂停按钮
-
显示播放进度及总时长
-
可以通过拖拽从任意位置开始播放视频
-
视频加载中的过程和加载失败需要有相应的提示
先看几张图:
正常使用AVPlayer`只播放视频`的流程是这样:
正常播放流程
所以我们的方案是
但是这样我们得不到播放中缓存的视频数据,所以我们需要这样做:
边下边播,播完将数据写的本地的播放流程
我们的播放器代码流程是这样:
自定义播放器流程
即:
-
当开始播放视频时,通过视频url判断本地cache中是否已经缓存当前视频,如果有,则直接播放本地cache中视频
-
如果本地cache中没有视频,则视频播放器向代理请求数据
-
加载视频时展示正在加载的提示(菊花转)
-
如果可以正常播放视频,则去掉加载提示,播放视频,如果加载失败,去掉加载提示并显示失败提示
-
在播放过程中如果由于网络过慢或拖拽原因导致没有播放数据时,要展示加载提示,跳转到第4步
代理对象处理流程
-
当视频播放器向代理请求dataRequest时,判断代理是否已经向服务器发起了请求,如果没有,则发起下载整个视频文件的请求
-
如果代理已经和服务器建立链接,则判断当前的dataRequest请求的offset是否大于当前已经缓存的文件的offset,如果大于则取消当前与服务器的请求,并从offset开始到文件尾向服务器发起请求(此时应该是由于播放器向后拖拽,并且超过了已缓存的数据时才会出现)
-
如果当前的dataRequest请求的offset小于已经缓存的文件的offset,同时大于代理向服务器请求的range的offset,说明有一部分已经缓存的数据可以传给播放器,则将这部分数据返回给播放器(此时应该是由于播放器向前拖拽,请求的数据已经缓存过才会出现)
-
如果当前的dataRequest请求的offset小于代理向服务器请求的range的offset,则取消当前与服务器的请求,并从offset开始到文件尾向服务器发起请求(此时应该是由于播放器向前拖拽,并且超过了已缓存的数据时才会出现)
-
只要代理重新向服务器发起请求,就会导致缓存的数据不连续,则加载结束后不用将缓存的数据放入本地cache
-
如果代理和服务器的链接超时,重试一次,如果还是错误则通知播放器网络错误
-
如果服务器返回其他错误,则代理通知播放器网络错误
自此实现原理及其业务逻辑已经讲完,下面我们开始代码实现:
首先我们新建四个类如图:
播放器类注:SPPlayer类主要处理一些界面上的东西,如播放,暂停,进度显示,监听播放器的各种状态。
SPPlayerProxyServer类就是我们上面所讲的播放器的代理类,它遵守AVAssetResourceLoaderDelegate协议,主要处理来自播放器的数据请求,并将已经请求到的数据实时传给播放器。
SPPlayerRequestTask类主要完成SPPlayerProxyServer类交给它的请求数据指令,完成数据请求,并实时将已经请求到的数据写入缓存并通知给SPPlayerProxyServer类,SPPlayerProxyServer类从缓存中读到数据后给SPPlayer。SPPlayerTool是个单例类主要完成创建文件目录,获取缓存数据文件目录等功能。
具体如下:
- 1 根据URL创建播放器对象,设置AVAsset的数据请求代理并添加相应的监听 ,并处理监听
self.resouerLoader = [[SPPlayerProxyServer alloc] init];
NSURL *playUrl = [_resouerLoader getSchemeVideoURL:[NSURL URLWithString:urlStr]];
self.videoUrlAsset = [AVURLAsset URLAssetWithURL:playUrl options:nil];
[_videoUrlAsset.resourceLoader setDelegate:_resouerLoader queue:dispatch_get_main_queue()];
self.playerItem = [AVPlayerItem playerItemWithAsset:_videoUrlAsset];
if (!self.player) {
self.player = [AVPlayer playerWithPlayerItem:self.playerItem];
} else {
[self.player replaceCurrentItemWithPlayerItem:self.playerItem];
}
//播放状态通知
[self.playerItem addObserver:self forKeyPath:@"status" options:NSKeyValueObservingOptionNew context:nil];
//监听播放器的下载进度
[self.playerItem addObserver:self forKeyPath:@"loadedTimeRanges" options:NSKeyValueObservingOptionNew context:nil];
//播放数据为空 此时应该去请求数据
[self.playerItem addObserver:self forKeyPath:@"playbackBufferEmpty" options:NSKeyValueObservingOptionNew context:nil];
//缓冲区有足够数据可以播放
[self.playerItem addObserver:self forKeyPath:@"playbackLikelyToKeepUp" options:NSKeyValueObservingOptionNew context:nil];
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context
{
AVPlayerItem *playerItem = (AVPlayerItem *)object;
if ([keyPath isEqualToString:@"status"]) {
if ([playerItem status] == AVPlayerStatusReadyToPlay) {
[self showTime];
[self.player play];
[self progressTimer];
} else if ([playerItem status] == AVPlayerStatusFailed || [playerItem status] == AVPlayerStatusUnknown) {
self.toolView.alpha = 1;
self.backButton.alpha = 1;
[self removeShowTime];
[self.player pause];
[self removeProgressTimer];
}
} else if ([keyPath isEqualToString:@"loadedTimeRanges"]) { //监听播放器的下载进度
// [self calculateDownloadProgress:playerItem];
} else if ([keyPath isEqualToString:@"playbackBufferEmpty"]) { //监听播放器在缓冲数据的状态
NSLog(@"缓存数据已经播放完毕,开始下载数据");
if (playerItem.isPlaybackBufferEmpty) {
self.state = SPPlayerStateBuffering;
// [self bufferingSomeSecond];
}
}else if ([keyPath isEqualToString:@"playbackLikelyToKeepUp"]) { //监听播放器的下载进度 即将要播放
}
}
- 2 通过NSURLComponents修改URL,因为只有这样播放器请求数据的时候才会调用我们给它设置的代理,否则,播放器不会调用我们的代理。
- (NSURL *)getSchemeVideoURL:(NSURL *)url{
// NSURLComponents用来替代NSMutableURL,可以readwrite修改URL
// AVAssetResourceLoader通过你提供的委托对象去调节AVURLAsset所需要的加载资源。
// 而很重要的一点是,AVAssetResourceLoader仅在AVURLAsset不知道如何去加载这个URL资源时才会被调用
// 就是说你提供的委托对象在AVURLAsset不知道如何加载资源时才会得到调用。
// 所以我们又要通过一些方法来曲线解决这个问题,把我们目标视频URL地址的scheme替换为系统不能识别的scheme
NSURLComponents *components = [[NSURLComponents alloc] initWithURL:url resolvingAgainstBaseURL:NO];
components.scheme = @"systemCannotRecognition";
return [components URL];
}
- 3 在SPPlayerProxyServer实现AVAssetResourceLoaderDelegate代理中的某些方法
#pragma mark - AVAssetResourceLoaderDelegate
- (BOOL)resourceLoader:(AVAssetResourceLoader *)resourceLoader shouldWaitForLoadingOfRequestedResource:(AVAssetResourceLoadingRequest *)loadingRequest{
[self.pendingRequests addObject:loadingRequest];
[self dealWithLoadingRequest:loadingRequest];
return YES;
}
- (void)resourceLoader:(AVAssetResourceLoader *)resourceLoader didCancelLoadingRequest:(AVAssetResourceLoadingRequest *)loadingRequest{
[self.pendingRequests removeObject:loadingRequest];
}
- 4通过以上三步我们就可以得到播放器每次请求数据的AVAssetResourceLoadingRequest,然后我们可以通过SPPlayerRequestTask来请求数据,请求到数据后写入缓存并传给SPPlayerProxyServer类
//处理每一次的播放器数据请求
- (void)dealWithLoadingRequest:(AVAssetResourceLoadingRequest *) loadingRequest{
NSURL *interceptedURL = [loadingRequest.request URL];
NSRange range = NSMakeRange((NSUInteger)loadingRequest.dataRequest.currentOffset, NSUIntegerMax);
if (self.task.downLoadingOffset > 0) {
[self processPendingRequests];
}
if (!self.task) {
self.task = [[SPPlayerRequestTask alloc] init];
self.task.delegate = self;
[self.task setUrl:interceptedURL offset:0];
} else {
// 如果新的rang的起始位置比当前缓存的位置还大300k,则重新按照range请求数据
if (self.task.offset + self.task.downLoadingOffset + 1024 * 300 < range.location ||
// 如果往回拖也重新请求
range.location < self.task.offset) {
[self.task setUrl:interceptedURL offset:range.location];
}
}
}
- (void)setUrl:(NSURL *)url offset:(NSUInteger)offset{
_url = url;
_offset = offset;
[[SPPlayerTool sharedInstance] createFileCachePath];
// 替代NSMutableURL, 可以动态修改scheme
NSURLComponents *actualURLComponents = [[NSURLComponents alloc] initWithURL:url resolvingAgainstBaseURL:NO];
actualURLComponents.scheme = @"http";
// 创建请求
NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:[actualURLComponents URL] cachePolicy:NSURLRequestReloadIgnoringCacheData timeoutInterval:20.0];
_downLoadingOffset = 0;
// fix offset of request(第二次及其以上发起请求时需要修改range哦)
if (offset > 0 && self.videoLength > 0) {
[request addValue:[NSString stringWithFormat:@"bytes=%ld-%ld",(unsigned long)offset, (unsigned long)self.videoLength - 1] forHTTPHeaderField:@"Range"];
}
// 重置(取消上次请求)
[self.session invalidateAndCancel];
// 创建Session,并设置代理
self.session = [NSURLSession sessionWithConfiguration:[NSURLSessionConfiguration defaultSessionConfiguration] delegate:self delegateQueue:[NSOperationQueue mainQueue]];
// 创建会话对象
NSURLSessionDataTask *dataTask = [self.session dataTaskWithRequest:request];
// 开始下载
[dataTask resume];
}
// ReceiveData
// 接收到服务器返回数据的时候调用,会调用多次
-(void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask didReceiveData:(NSData *)data{
if (data.length>0) {
_downLoadingOffset += data.length;
// [self.outputStream write:data.bytes maxLength:data.length];
[self.fileHandle seekToEndOfFile];
[self.fileHandle writeData:data];
// For Test
NSLog(@"loading ... 正在下载");
if ([self.delegate respondsToSelector:@selector(requestTask:didReceiveData:downloadOffset:tempFilePath:)]) {
[self.delegate requestTask:self didReceiveData:data downloadOffset:_downLoadingOffset tempFilePath:[[SPPlayerTool sharedInstance] getFileCachePath]];
}
}
}
- 5 SPPlayerProxyServer类得到数据后传给SPPlayer,此时SPPlayer类中会监听到数据已经准备就绪,他就可以开始播放,
#pragma mark - SPPlayerRequestTaskDelegate
// 正在下载(传递获取到的数据和下载的偏移量以及临时文件存储路径)
-(void)requestTask:(SPPlayerRequestTask *)requesttask didReceiveData:(NSData *)data downloadOffset:(NSInteger)offset tempFilePath:(NSString *)filePath{
[self processPendingRequests];
}
- (void)processPendingRequests{
// 遍历所有的请求, 为每个请求加上请求的数据长度和文件类型等信息.
// 在判断当前下载完的数据长度中有没有要请求的数据, 如果有,就把这段数据取出来,并且把这段数据填充给请求, 然后关闭这个请求
// 如果没有, 继续等待下载完成.
NSMutableArray *requestsCompleted = [NSMutableArray array];
for (AVAssetResourceLoadingRequest *loadingRequest in self.pendingRequests) {
[self fillInContentInformation:loadingRequest.contentInformationRequest];
BOOL didRespondCompletely = [self respondWithDataForRequest:loadingRequest.dataRequest];
if (didRespondCompletely) {
[requestsCompleted addObject:loadingRequest];
[loadingRequest finishLoading];
}
}
[self.pendingRequests removeObjectsInArray:[requestsCompleted copy]];
}
//将此次下载到的数据传给此次的请求
- (BOOL)respondWithDataForRequest:(AVAssetResourceLoadingDataRequest *)dataRequest{
long long startOffset = dataRequest.requestedOffset;
if (dataRequest.currentOffset != 0)
startOffset = dataRequest.currentOffset;
NSData *fileData = [NSData dataWithContentsOfFile:[[SPPlayerTool sharedInstance] getFileCachePath] options:NSDataReadingMappedIfSafe error:nil];
NSInteger unreadBytes = self.task.downLoadingOffset - self.task.offset - (NSInteger)startOffset;
NSUInteger numberOfBytesToRespondWith = MIN((NSUInteger)dataRequest.requestedLength, unreadBytes);
if (fileData.length != 0) {
[dataRequest respondWithData:[fileData subdataWithRange:NSMakeRange((NSUInteger)startOffset- self.task.offset, (NSUInteger)numberOfBytesToRespondWith)]];
}
long long endOffset = startOffset + dataRequest.requestedLength;
BOOL didRespondFully = (self.task.offset + self.task.downLoadingOffset) >= endOffset;
return didRespondFully;
}
//此时监听到playerItem的状态为AVPlayerStatusReadyToPlay,开始播放。
if ([keyPath isEqualToString:@"status"]) {
if ([playerItem status] == AVPlayerStatusReadyToPlay) {
[self showTime];
[self.player play];
[self progressTimer];
- 6 数据下载完毕如果数据完整,就写入沙盒
-(void)downloadSuccessWithURLSession:(NSURLSession *)session task:(NSURLSessionTask *)task{
// If download success, then move the complete file from temporary path to cache path
// 如果下载完成, 就把文件移到缓存文件夹
if (self.taskArray.count < 2) { //注:如果中间有快进或者快退 则表示下载数据并不完整 就不保存
NSFileManager *fileManager = [NSFileManager defaultManager];
if ([fileManager fileExistsAtPath:[[SPPlayerTool sharedInstance] getFileSavePath]]) {
dispatch_async(dispatch_get_global_queue(0, 0), ^{
[fileManager moveItemAtPath:[[SPPlayerTool sharedInstance] getFileCachePath] toPath:[[SPPlayerTool sharedInstance] getFileSavePath] error:nil];
dispatch_async(dispatch_get_main_queue(), ^{
if ([self.delegate respondsToSelector:@selector(didFinishLoadingWithManager:fileSavePath:)]) {
[self.delegate didFinishLoadingWithManager:self fileSavePath:[[SPPlayerTool sharedInstance] getFileSavePath]];
}
});
});
}
至此,主要代码已经完成,滑动进度条从某个时间点开始播放,也是通过3--5步骤完成数据请求的,注意还有一点,视频数据请求需要给request设置一个偏移量和长度,所以从第二次请求开始,这个range得计算好。
代码整理一下随后上传