AVPlayer使用总结

鉴于最近视频社交这么火爆,笔者深深感觉作为一个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的请求,这样就可以把视频数据劫持并保存下来。

AVPlayer使用总结_第1张图片
网络劫持流程图.png

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就先介绍到这里。有解释不清楚的地方欢迎留言咨询,如果有哪里说错了,也请随时指出。

你可能感兴趣的:(AVPlayer使用总结)