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的请求,这样就可以把视频数据劫持并保存下来。

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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 195,980评论 5 462
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 82,422评论 2 373
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 143,130评论 0 325
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,553评论 1 267
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,408评论 5 358
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,326评论 1 273
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,720评论 3 386
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,373评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,678评论 1 294
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,722评论 2 312
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,486评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,335评论 3 313
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,738评论 3 299
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,009评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,283评论 1 251
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,692评论 2 342
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,893评论 2 335

推荐阅读更多精彩内容