鉴于最近视频社交这么火爆,笔者深深感觉作为一个iOS开发如果不跟上这股潮流,实在是说不过去。于是决定总结一下最近使用AVPlayer的一些经验,希望能帮到有需要的人。
AVPlayer是AVFoundation中的核心类之一,主要用来控制媒体文件的播放,支持大部分常用的视频和音频文件格式,包括H.264,MPEG-4等等常用编码,非常稳定,效果也非常好。
这篇文章主要想介绍一下AVPlayer的一些简单用法,以及如何实现用AVPlayer加载播放云端视频文件,和如何劫持AVPlayer的网络请求以把加载好的视频文件保存成本地文件缓存。
1. AVPlayer介绍
AVPlayer是AVFoundation中用于控制单个视音频文件播放的类。注意,这里强调一下单个,是因为它并不支持多个视频文件(多个视频文件由它的一个子类AVQueuePlayer
来实现,具体已经超出了本篇文章讨论的范畴)。正如上文所说,AVPlayer只是播放这个视频的控制器,并不是一个可视化的部件。因此,使用AVPlayer本身并不能在UIView上渲染出视图。但是,它暴露了一个AVPlayerLayer
,是CALayer
的子类,视频正是在这个AVPlayerLayer
上进行渲染。只需要把它添加到已有UIView的sublayer下,就能进行视频的渲染。
AVPlayer最简单的使用,就是把它当做一个黑盒子,输入一个AVPlayerItem
(代表了要播放的视频文件),输出一个AVPlayerLayer
,可以被添加到视图上进行渲染。除了AVPlayerLayer
,同时输出的还有播放的进度,缓存进度,播放速率等状态。开发可以监听这些状态的变化来对用户界面进行相应的调整,比如显示当前播放进度等等。举个栗子,比如说我们想监听当前视频文件的加载进度,就可以用以下KVO实现:
[player.currentItem addObserver:self
forKeyPath:@"loadedTimeRanges"
options:NSKeyValueObservingOptionNew
context:nil];
这里loadedTimeRanges
是AVPlayer的一个属性,代表着AVPlayer当前的加载进度。
当然,有些本身就在持续改变的属性,例如当前播放进度,是不适合用这种KVO的方式进行监听的,应用层也没有必要知道每次变化。所以AVPlayer也提供了一个周期性监听变化的接口:
- (id)addPeriodicTimeObserverForInterval:queue:usingBlock:;
当然,如果需要对视频进行暂停,恢复播放,跳跃播放等操作,也是通过AVPlayer
来实现的。具体用法可以参见苹果官方文档,这里不作赘述。
2. 加载播放云端视频文件
播放云端视频文件,一般来说有以下方案:
- 对于小的视频文件(< 2M)来说,大可以完整地把视频文件完全下载到本地,然后用
AVPlayer
进行对本地文件的播放。 - 那么对于大的视频文件来说,边下载边播放就是一定要实现的了。幸好AVPlayer对这种实现已经有了很好的原生支持。具体实现如下:
AVURLAsset *asset = [AVURLAsset URLAssetWithURL:assetURL options:nil];
AVPlayerItem *playerItem = [AVPlayerItem playerItemWithAsset:asset automaticallyLoadedAssetKeys:@[@"duration"]];
self.player = [AVPlayer playerWithPlayerItem:playerItem];
对视频编码熟悉的读者,应该会发现如果要实现边加载边播放的效果,还需要对视频文件本身作一定的调整。这个调整的关键在于一个叫做moov atom
的部分。不熟悉的读者可以把它理解为视频文件相关的信息(metadata),包括视频长度(duration),时间尺度(time scale)等等,具体可以参考这篇博客(英文版)。一般来说,编码视频的时候这个moov atom
是放置在视频文件的最后的。因此如果不把它挪到视频的前面来的话,即使AVPlayer已经下载了一部分资源,视频文件由于缺少解码的关键信息,还是无法正常进行播放,直到把这个moov atom文件下载下来为止。
3. 如何保存云端视频文件
上一部分说到了播放云端视频文件的两个选择。要么先把视频文件下载到本地,再进行播放,要么一边加载一边播放。这里要解决的问题是,如果采用的是后者,原生的AVPlayer
是无法支持把视频文件保存到文件系统的。然而,笔者所在的项目刚好就有这个需求。于是笔者查了一下,发现了这个实现。这里对这位大神的解法解释一下。
这个实现主要的思想,是通过对AVURLAsset发送的网络请求进行中途劫持,然后手动发送网络请求,再把请求回来的数据分配回去给AVURLAsset的请求,这样就可以把视频数据劫持并保存下来。
3.1 劫持AVURLAsset的网络请求
这个比较简单,在创建AVURLAsset实例时,把原来的链接的scheme改掉。比如说如果原来是
AVURLAsset *asset = [AVURLAsset assetWithURL: @"https://www.xx.com/video/6733993303.mp4" options:nil];
那么我们就把它改为
AVURLAsset *asset = [AVURLAsset assetWithURL: @"intercept://www.xx.com/video/6733993303.mp4" options:nil];
这样就可以在AVURLAsset的delegate那里识别相应的请求并且进行劫持,如下:
- (BOOL)resourceLoader:(AVAssetResourceLoader *)resourceLoader shouldWaitForLoadingOfRequestedResource:(AVAssetResourceLoadingRequest *)loadingRequest
{
NSURL *interceptedURL = [loadingRequest.request URL];
if ([interceptedURL.scheme isEqualToString:@"intercept"]) {
// 识别并进行劫持
}
}
3.2 自行发送网络请求
在识别了AVURLAsset
的网络请求之后,下一步就是自行发送网络请求下载相关资源。
NSURLComponents *actualURLComponents = [[NSURLComponents alloc] initWithURL:interceptedURL resolvingAgainstBaseURL:NO];
actualURLComponents.scheme = @"https";
NSURLRequest *request = [NSURLRequest requestWithURL:[actualURLComponents URL]];
self.connection = [[NSURLConnection alloc] initWithRequest:request delegate:self startImmediately:NO];
[self.connection setDelegateQueue:[NSOperationQueue mainQueue]];
[self.connection start];
然后监听下载进度,并且把相关的已下载的数据保存起来。
- (void)connection:(NSURLConnection *)connection didReceiveResponse:(NSURLResponse *)response
{
self.videoData = [NSMutableData data];
self.response = (NSHTTPURLResponse *)response;
}
- (void)connection:(NSURLConnection *)connection didReceiveData:(NSData *)data
{
[self.videoData appendData:data];
}
- (void)connectionDidFinishLoading:(NSURLConnection *)connection
{
NSString *cachedFilePath = [NSTemporaryDirectory() stringByAppendingPathComponent:@"cached.mp4"];
[self.videoData writeToFile:cachedFilePath atomically:YES];
}
3.3 将已下载的数据返回给AVURLAsset的请求
这一步我们需要将已下载的数据返回给AVURLAsset
,实现如下:
- 在之前3.1监听到请求的时候,把
loadingRequest
添加到一个数组里,以方便返回数据:
[self.pendingRequests addObject:loadingRequest];
- 每次收到数据时,看看是否能满足
self.pendingRequests
里的哪个请求。如果可以,就马上返回给播放控制器渲染视频。
-(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];
}
-(void)fillInContentInformation:(AVAssetResourceLoadingContentInformationRequest *)contentInformationRequest
{
if (contentInformationRequest == nil || self.response == nil)
{
return;
}
NSString *mimeType = [self.response MIMEType];
CFStringRef contentType = UTTypeCreatePreferredIdentifierForTag(kUTTagClassMIMEType, (__bridge CFStringRef)(mimeType), NULL);
contentInformationRequest.byteRangeAccessSupported = YES;
contentInformationRequest.contentType = CFBridgingRelease(contentType);
contentInformationRequest.contentLength = [self.response expectedContentLength];
}
-(BOOL)respondWithDataForRequest:(AVAssetResourceLoadingDataRequest *)dataRequest
{
long long startOffset = dataRequest.requestedOffset;
if (dataRequest.currentOffset != 0)
{
startOffset = dataRequest.currentOffset;
}
// Don't have any data at all for this request
if (self.videoData.length < startOffset)
{
return NO;
}
// This is the total data we have from startOffset to whatever has been downloaded so far
NSUInteger unreadBytes = self.videoData.length - (NSUInteger)startOffset;
// Respond with whatever is available if we can't satisfy the request fully yet
NSUInteger numberOfBytesToRespondWith = MIN((NSUInteger)dataRequest.requestedLength, unreadBytes);
[dataRequest respondWithData:[self.videoData subdataWithRange:NSMakeRange((NSUInteger)startOffset, numberOfBytesToRespondWith)]];
long long endOffset = startOffset + dataRequest.requestedLength;
BOOL didRespondFully = self.videoData.length >= endOffset;
return didRespondFully;
}
搞定!
整体的思路就是这样。不得不说,这个实现非常的聪明。
4. 总结
AVPlayer就先介绍到这里。有解释不清楚的地方欢迎留言咨询,如果有哪里说错了,也请随时指出。