iOS音视频实现边下载边播放

近段时间制作视频播放社区的功能,期间查找了不少资料,做过很多尝试,现在来整理一下其中遇到的一些坑.由于考虑到AVPlayer对视频有更高自由度的控制,而且能够使用它自定义视频播放界面,iOS中所使用的视频播放控件为AVPlayer,而抛弃了高层次的MediaPlayer框架,现在想想挺庆幸当初使用了AVPlayer。

AVPlayer的基本知识

AVPlayer本身并不能显示视频,而且它也不像MPMoviePlayerController有一个view属性。如果AVPlayer要显示必须创建一个播放器层AVPlayerLayer用于展示,播放器层继承于CALayer,有了AVPlayerLayer之添加到控制器视图的layer中即可。要使用AVPlayer首先了解一下几个常用的类:

AVAsset:主要用于获取多媒体信息,是一个抽象类,不能直接使用。

AVURLAsset:AVAsset的子类,可以根据一个URL路径创建一个包含媒体信息的AVURLAsset对象。

AVPlayerItem:一个媒体资源管理对象,管理者视频的一些基本信息和状态,一个AVPlayerItem对应着一个视频资源。

iOS视频实现边下载边播放的几种实现

1.本地实现http server

在iOS本地开启Local Server服务,然后使用播放控件请求本地Local Server服务,本地的服务再不断请求视频地址获取视频流,本地服务请求的过程中把视频缓存到本地,这种方法在网上有很多例子,有兴趣了解的人可自己下载例子查看。

2.使用AVPlayer的方法开启下载服务

1
2
3
4
1.AVURLAsset *urlAsset = [[AVURLAsset alloc]initWithURL:url options:nil];
2.AVPlayerItem *item = [AVPlayerItem playerItemWithAsset:urlAsset];
3.[self.avPlayer replaceCurrentItemWithPlayerItem:item];
4.[self addObserverToPlayerItem:item];

但由于AVPlayer是没有提供方法给我们直接获取它下载下来的数据,所以我们只能在视频下载完之后自己去寻找缓存视频数据的办法,AVFoundation框架中有一种从多媒体信息类AVAsset中提取视频数据的类AVMutableComposition和AVAssetExportSession。
其中AVMutableComposition的作用是能够从现有的asset实例中创建出一个新的AVComposition(它也是AVAsset的字类),使用者能够从别的asset中提取他们的音频轨道或视频轨道,并且把它们添加到新建的Composition中。
AVAssetExportSession的作用是把现有的自己创建的asset输出到本地文件中。
为什么需要把原先的AVAsset(AVURLAsset)实现的数据提取出来后拼接成另一个AVAsset(AVComposition)的数据后输出呢,由于通过网络url下载下来的视频没有保存视频的原始数据(或者苹果没有暴露接口给我们获取),下载后播放的avasset不能使用AVAssetExportSession输出到本地文件,要曲线地把下载下来的视频通过重构成另外一个AVAsset实例才能输出。代码例子如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
NSString *documentDirectory = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES)[0];
NSString *myPathDocument = [documentDirectory stringByAppendingPathComponent:[NSString stringWithFormat:@"%@.mp4",[_source.videoUrl MD5]]];


NSURL *fileUrl = [NSURL fileURLWithPath:myPathDocument];

if (asset != nil) {
AVMutableComposition *mixComposition = [[AVMutableComposition alloc]init];
AVMutableCompositionTrack *firstTrack = [mixComposition addMutableTrackWithMediaType:AVMediaTypeVideo preferredTrackID:kCMPersistentTrackID_Invalid];
[firstTrack insertTimeRange:CMTimeRangeMake(kCMTimeZero, asset.duration) ofTrack:[[asset tracksWithMediaType:AVMediaTypeVideo]objectAtIndex:0] atTime:kCMTimeZero error:nil];

AVMutableCompositionTrack *audioTrack = [mixComposition addMutableTrackWithMediaType:AVMediaTypeAudio preferredTrackID:kCMPersistentTrackID_Invalid];
[audioTrack insertTimeRange:CMTimeRangeMake(kCMTimeZero, asset.duration) ofTrack:[[asset tracksWithMediaType:AVMediaTypeAudio]objectAtIndex:0] atTime:kCMTimeZero error:nil];

AVAssetExportSession *exporter = [[AVAssetExportSession alloc]initWithAsset:mixComposition presetName:AVAssetExportPresetHighestQuality];
exporter.outputURL = fileUrl;
if (exporter.supportedFileTypes) {
exporter.outputFileType = [exporter.supportedFileTypes objectAtIndex:0] ;
exporter.shouldOptimizeForNetworkUse = YES;
[exporter exportAsynchronouslyWithCompletionHandler:^{

}];

}
}

3.使用AVAssetResourceLoader回调下载,也是最终决定使用的技术

AVAssetResourceLoader通过你提供的委托对象去调节AVURLAsset所需要的加载资源。而很重要的一点是,AVAssetResourceLoader仅在AVURLAsset不知道如何去加载这个URL资源时才会被调用,就是说你提供的委托对象在AVURLAsset不知道如何加载资源时才会得到调用。所以我们又要通过一些方法来曲线解决这个问题,把我们目标视频URL地址的scheme替换为系统不能识别的scheme,然后在我们调用网络请求去处理这个URL时把scheme切换为原来的scheme。

实现边下边播功能AVResourceLoader的委托对象必须要实现AVAssetResourceLoaderDelegate下五个协议的其中两个:

1
2
3
4
1//在系统不知道如何处理URLAsset资源时回调
- (BOOL)resourceLoader:(AVAssetResourceLoader *)resourceLoader shouldWaitForLoadingOfRequestedResource:(AVAssetResourceLoadingRequest *)loadingRequest NS_AVAILABLE(10_9, 6_0);
2//在取消加载资源后回调
- (void)resourceLoader:(AVAssetResourceLoader *)resourceLoader didCancelLoadingRequest:(AVAssetResourceLoadingRequest *)loadingRequest NS_AVAILABLE(10_9, 7_0);

以下来说说具体要怎么做处理

第一步,创建一个AVURLAsset,并且用它来初始化一个AVPlayerItem

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#define kCustomVideoScheme @"yourScheme"
NSURL *currentURL = [NSURL URLWithString:@"http://***.***.***"];
NSURLComponents *components = [[NSURLComponents alloc]initWithURL:currentURL resolvingAgainstBaseURL:NO];
1////注意,不加这一句不能执行到回调操作
components.scheme = kCustomVideoScheme;
AVURLAsset *urlAsset = [AVURLAsset URLAssetWithURL:components.URL
options:nil];
2//_resourceManager在接下来讲述
[urlAsset.resourceLoader setDelegate:_resourceManager queue:dispatch_get_main_queue()];
AVPlayerItem *item = [AVPlayerItem playerItemWithAsset:urlAsset];
_playerItem = item;

if (IOS9_OR_LATER) {
item.canUseNetworkResourcesForLiveStreamingWhilePaused = YES;
}
[self.avPlayer replaceCurrentItemWithPlayerItem:item];
self.playerLayer.player = self.avPlayer;
[self addObserverToPlayerItem:item];**

第二步,创建AVResourceManager实现AVResourceLoader协议

1
@interface AVAResourceLoaderManager : NSObject < AVAssetResourceLoaderDelegate >

第三步,实现两个必须的回调协议,实现中有几件需要做的事情

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
- (BOOL)resourceLoader:(AVAssetResourceLoader *)resourceLoader shouldWaitForLoadingOfRequestedResource:(AVAssetResourceLoadingRequest *)loadingRequest
{
1//获取系统中不能处理的URL
NSURL *resourceURL = [loadingRequest.request URL];
2//判断这个URL是否遵守URL规范和其是否是我们所设定的URL
if ([self checkIsLegalURL:resourceURL] && [resourceURL.scheme isEqualToString:kCustomVideoScheme]){
3//判断当前的URL网络请求是否已经被加载过了,如果缓存中里面有URL对应的网络加载器(自己封装,也可以直接使用NSURLRequest),则取出来添加请求,每一个URL对应一个网络加载器,loader的实现接下来会说明
AVResourceLoaderForASI *loader = [self asiresourceLoaderForRequest:loadingRequest];
if (loader == nil){
loader = [[AVResourceLoaderForASI alloc] initWithResourceURL:resourceURL];
loader.delegate = self;
4//缓存网络加载器
[self.resourceLoaders setObject:loader forKey:[self keyForResourceLoaderWithURL:resourceURL]];
}
5//加载器添加请求
[loader addRequest:loadingRequest];
6//返回YES则表明使用我们的代码对AVAsset中请求网络资源做处理
return YES;
}else{
return NO;
}

}
1
2
3
4
5
6
7
8
- (void)resourceLoader:(AVAssetResourceLoader *)resourceLoader didCancelLoadingRequest:(AVAssetResourceLoadingRequest *)loadingRequest
{
//如果用户在下载的过程中调用者取消了获取视频,则从缓存中取消这个请求
NSURL *resourceURL = [loadingRequest.request URL];
NSString *actualURLString = [self actualURLStringWithURL:resourceURL];
AVResourceLoaderForASI *loader = [_resourceLoaders objectForKey:actualURLString];
[loader removeRequest:loadingRequest];
}

第四步,判断缓存中是否已下载完视频

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
- (void)addRequest:(AVAssetResourceLoadingRequest *)loadingRequest
{
//1判断自身是否已经取消加载
if(self.isCancelled==NO){
//2判断本地中是否已经有文件的缓存,如果有,则直接从缓存中读取数据,文件保存和读取这里不做详述,使用者可根据自身情况创建文件系统
AVAResourceFile *resourceFile = [self.resourceFileManager resourceFileWithURL:self.resourceURL];
if (resourceFile) {
//3若本地文件存在,则从文件中获取以下属性
loadingRequest.contentInformationRequest.byteRangeAccessSupported = YES;
//3.1contentType
loadingRequest.contentInformationRequest.contentType = resourceFile.contentType;
//3.2数据长度
loadingRequest.contentInformationRequest.contentLength = resourceFile.contentLength;
//3.3请求的偏移量
long long requestedOffset = loadingRequest.dataRequest.requestedOffset;
//3.4请求总长度
NSInteger requestedLength = loadingRequest.dataRequest.requestedLength;
//3.5取出本地文件中从偏移量到请求长度的数据
NSData *subData = [resourceFile.data subdataWithRange:NSMakeRange(@(requestedOffset).unsignedIntegerValue, requestedLength)];
//3.6返回数据给请求
[loadingRequest.dataRequest respondWithData:subData];
[loadingRequest finishLoading];
}else{
//4如果没有本地文件,则开启网络请求,从网络中获取 ,见第五步
[self startWithRequest:loadingRequest];
}
}
else{
//5如果已经取消请求,并且请求没有完成,则封装错误给请求,可自己实现
if(loadingRequest.isFinished==NO){
[loadingRequest finishLoadingWithError:[self loaderCancelledError]];
}
}
}

第五步,添加loadingRequest到网络文件加载器,这部分的操作比较长

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
- (void)startWithRequest:(AVAssetResourceLoadingRequest *)loadingRequest
{
1//判断当前请求是否已经开启,由于苹果系统原因,会有两次回调到AVResourceLoaderDelegate,我们对其进行判断,只开启一次请求
if (self.dataTask == nil){
2//根据loadingRequest中的URL创建NSURLRequest,注意在此把URL中的scheme修改为原先的scheme
NSURLRequest *request = [self requestWithLoadingRequest:loadingRequest];
__weak __typeof(self)weakSelf = self;
3//获取url的绝对路径,并使用ASIHttpRequest进行网络请求,下面的请求方法经过封装,就不详说如何对ASI进行封装了,但是每一步需要做的事情能以block的形式更好说明
NSString *urlString = request.URL.absoluteString;
self.dataTask = [self GET:urlString requestBlock:^(Request *req) {
NSLog(@"### %s %@ ###", __func__, req);
4//在接受到请求头部信息时,说明链接成功,数据开始传输
if (req.recvingHeader//意思是请求接受到头部信息状态){
NSLog(@"### %s recvingHeader ###", __func__);
__strong __typeof(weakSelf)strongSelf = weakSelf;
if ([urlString isEqualToString:req.originalURL.absoluteString]) {
4.1//,创建临时数据保存网络下载下来的视频信息
strongSelf.tempData = [NSMutableData data];
}
4.2//把头部信息内容写入到AVAssetResourceLoadingRequest,即loadingRequest中
[strongSelf processPendingRequests];
}
else if (req.recving//请求接受中状态){
NSLog(@"### %s recving ###", __func__);
__strong __typeof(weakSelf)strongSelf = weakSelf;
5//此处需多次调用把请求的信息写入到loadingRequest的步骤,实现下载的过程中数据能输出到loadingRequest播放
if (urlString == req.originalURL.absoluteString) {
5.1//这个处理是判断此时返回的头部信息是重定向还是实际视频的头部信息,如果是重定向信息,则不作处理
if (!_contentInformation && req.responseHeaders) {
if ([req.responseHeaders objectForKey:@"Location"] ) {
NSLog(@" ### %s redirection URL ###", __func__);
}else{
//5.2如果不是重定向信息,则把需要用到的信息提取出来
_contentInformation = [[RLContentInformationForASI alloc]init];
long long numer = [[req.responseHeaders objectForKey:@"Content-Length"]longLongValue];
_contentInformation.contentLength = numer;
_contentInformation.byteRangeAccessSupported = YES;
_contentInformation.contentType = [req.responseHeaders objectForKey:@"Content-type"];
}
}

//5.3开始从请求中获取返回数据
NSLog(@"### %s before tempData length = %lu ###", __FUNCTION__, (unsigned long)self.tempData.length);
strongSelf.tempData = [NSMutableData dataWithData:req.rawResponseData];
NSLog(@"### %s after tempData length = %lu ###",__FUNCTION__, (unsigned long)self.tempData.length);
//5.4把返回数据输出到loadingRequest中
[strongSelf processPendingRequests];
}
}else if (req.succeed){
6//请求返回成功,在这里做最后一次把数据输出到loadingRequest,且做一些成功后的事情
NSLog(@"### %s succeed ###", __func__);
NSLog(@"### %s tempData length = %lu ###", __func__, (unsigned long)self.tempData.length);
__strong __typeof(weakSelf)strongSelf = weakSelf;
if (strongSelf) {
[strongSelf processPendingRequests];

7//保存缓存文件,我在保存文件这里做了一次偷懒,如果有人参考我写的文件可对保存文件作改进,在每次返回数据时把数据追加写到文件,而不是下载成功之后才保存,这请求时也可以使用这个来实现断点重输的功能
AVAResourceFile *resourceFile = [[AVAResourceFile alloc]initWithContentType:strongSelf.contentInformation.contentType date:strongSelf.tempData];
[strongSelf.resourceFileManager saveResourceFile:resourceFile withURL:self.resourceURL];
8//在此做一些清理缓存、释放对象和回调到上层的操作
[strongSelf complete];
if (strongSelf.delegate && [strongSelf.delegate respondsToSelector:@selector(resourceLoader:didLoadResource:)]) {
[strongSelf.delegate resourceLoader:strongSelf didLoadResource:strongSelf.resourceURL];
}
}
}else if (req.failed){
//9如果请求返回失败,则向上层抛出错误,且清理缓存等操作
NSLog(@"### %s failed ###" , __func__);
[self completeWithError:req.error];
}
}];
}
[self.pendingRequests addObject:loadingRequest];
}

第六步,把请求返回数据输出到loadingRequest的操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
- (void)processPendingRequests
{
__weak __typeof(self)weakSelf = self;
dispatch_async(dispatch_get_main_queue(), ^{
__strong __typeof(weakSelf)strongSelf = weakSelf;
NSMutableArray *requestsCompleted = [NSMutableArray array];
1//从缓存信息中找出当前正在请求中的loadingRequest
for (AVAssetResourceLoadingRequest *loadingRequest in strongSelf.pendingRequests){
2//把头部信息输出到loadingRequest中
[strongSelf fillInContentInformation:loadingRequest.contentInformationRequest];
3//把视频数据输出到loadingRequest中
BOOL didRespondCompletely = [strongSelf respondWithDataForRequest:loadingRequest.dataRequest];
4//在success状态中做最后一次调用的时候,检测到请求已经完成,则从缓存信息中清除loadingRequest,并且把loadingRequest标志为完成处理状态
if (didRespondCompletely){
[requestsCompleted addObject:loadingRequest];
[loadingRequest finishLoading];
}
}
5//清理缓存
[strongSelf.pendingRequests removeObjectsInArray:requestsCompleted];
});
}


//把提取出来的头部信息输出到loadingRequest中,可以优化
- (void)fillInContentInformation:(AVAssetResourceLoadingContentInformationRequest *)contentInformationRequest
{
if (contentInformationRequest == nil || self.contentInformation == nil){
return;
}
contentInformationRequest.byteRangeAccessSupported = self.contentInformation.byteRangeAccessSupported;
contentInformationRequest.contentType = self.contentInformation.contentType;
contentInformationRequest.contentLength = self.contentInformation.contentLength;
}

//把缓存数据输出到loadingRequest中
- (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.tempData.length < startOffset){
return NO;
}

// This is the total data we have from startOffset to whatever has been downloaded so far
NSUInteger unreadBytes = self.tempData.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.tempData subdataWithRange:NSMakeRange((NSUInteger)startOffset, numberOfBytesToRespondWith)]];

long long endOffset = startOffset + dataRequest.requestedLength;
BOOL didRespondFully = self.tempData.length >= endOffset;

return didRespondFully;
}

视频边下边播的流程大致上已经描述完毕,本博文中没有说到的代码有错误处理方式、缓存文件的读写和保存格式、部分内存缓存使用说明、

参考链接:
http://www.codeproject.com/Articles/875105/Audio-streaming-and-caching-in-iOS-using
http://www.cnblogs.com/kenshincui/p/4186022.html#mpMoviePlayerController

补充:
在开发过程中遇到的一些坑在这里补充一下
1.在iOS9后,AVPlayer的replaceCurrentItemWithPlayerItem方法在切换视频时底层会调用信号量等待然后导致当前线程卡顿,如果在UITableViewCell中切换视频播放使用这个方法,会导致当前线程冻结几秒钟。遇到这个坑还真不好在系统层面对它做什么,后来找到的解决方法是在每次需要切换视频时,需重新创建AVPlayer和AVPlayerItem。
2.iOS9后,AVFoundation框架还做了几点修改,如果需要切换视频播放的时间,或需要控制视频从头播放调用seekToDate方法,需要保持视频的播放rate大于0才能修改,还有canUseNetworkResourcesForLiveStreamingWhilePaused这个属性,在iOS9前默认为YES,之后默认为NO。
3.AVPlayer的replaceCurrentItemWithPlayerItem方法正常是会引用住参数AVPlayerItem的,但在某些情况下导致视频播放失败,它会马上释放对这个对象的持有,假如你对AVPlayerItem的实例对象添加了监听,但是自己没有对item的计数进行管理,不知道什么时候释放这个监听,则会导致程序崩溃。
4.为什么我选择第三种方法实现边下边播,第一种方法需要程序引入LocalServer库,需增加大量app包大小,且需要开启本地服务,从性能方面考虑也是不合适。第二种方式存在的缺陷很多,一来只能播放网络上返回格式contentType为public/mpeg4等视频格式的url视频地址,若保存下来之后,文件的格式也需要保存为.mp4或.mov等格式的本地文件才能从本地中读取,三来使用AVMutableComposition对视频进行重构后保存,经过检验会对视频源数据产生变化,对于程序开发人员来说,需要保证各端存在的视频数据一致。第三种边下边播的方法其实是对第二种方法的扩展,能够解决上面所说的三种问题,可操控的自由度更高。