目录
一、m3u8缓存播放的整个流程
二、控制媒体下载的并发数
三、控制单个媒体的切片下载并发数
四、下载的中断和恢复
五、注意的问题与思路延伸
更新(相关demo会继续完善,使用operation实现了并发控制的版本)
一、m3u8缓存播放的整个流程
1.下载m3u8文件
2.解析m3u8文件获取视频切片单元的信息。
3.根据2.获取的视频切片信息中的切片链接下载切片并保持到本地。
3.根据获取的切片信息与本地服务器的配置信息,拼接出切片的本地地址、生成新的m3u8文件并保存到本地。
4.开启本地服务器,使用本地url播放本地m3u8文件。
附上:时序图,具体可看demo
二、控制媒体下载的并发数
这里使用信号量来控制并发数
- (void)downloadVideoWithUrlString:(NSString *)urlStr downloadProgressHandler:(ZBLM3u8ManagerDownloadProgressHandler)downloadProgressHandler downloadSuccessBlock:(ZBLM3u8ManagerDownloadSuccessBlock) downloadSuccessBlock
{
dispatch_async(_downloadQueue, ^{
dispatch_semaphore_wait(_movieSemaphore, DISPATCH_TIME_FOREVER);
__weak __typeof(self) weakself = self;
[[self downloadContainerWithUrlString:urlStr] startDownloadWithUrlString:urlStr downloadProgressHandler:^(float progress) {
downloadProgressHandler(progress);
} completaionHandler:^(NSString *locaLUrl, NSError *error) {
if (!error) {
[weakself.downloadContainerDictionary removeObjectForKey:[ZBLM3u8Setting uuidWithUrl:urlStr]];
NSLog(@"下载完成:%@",urlStr);
downloadSuccessBlock(locaLUrl);
}
else
{
NSLog(@"下载失败:%@",error);
[self resumeDownload];
}
NSLog(@"%@",weakself.downloadContainerDictionary.allKeys);
dispatch_semaphore_signal(_movieSemaphore);
}];
});
}
这里可以设置_movieSemaphore的的初始值为具体的可同时下载数。
Example:_movieSemaphore = dispatch_semaphore_create(1),意味着同一时间只允许下载一个视频,等同于视频的串行下载。
三、控制单个媒体的切片下载并发数
开始的时候,考虑使用AFURLSessionManager中的operationQueue.maxConcurrentOperationCount来控制并发。但这是行不通的。因为这个queue是用于回调而不是用于下载队列。
/**
The operation queue on which delegate callbacks are run.
*/
@property (readonly, nonatomic, strong) NSOperationQueue *operationQueue;
再看AF中初始化
- (instancetype)initWithSessionConfiguration:(NSURLSessionConfiguration *)configuration {
self = [super init];
if (!self) {
return nil;
}
if (!configuration) {
configuration = [NSURLSessionConfiguration defaultSessionConfiguration];
}
self.sessionConfiguration = configuration;
self.operationQueue = [[NSOperationQueue alloc] init];
self.operationQueue.maxConcurrentOperationCount = 1;
self.session = [NSURLSession sessionWithConfiguration:self.sessionConfiguration delegate:self delegateQueue:self.operationQueue];
...
这个queue确实是用于回调,而我是需要控制下载并发。这似乎不满足。而且实测中也发现确实不行。那么,只能在任务发起哪里做并发控制,同样,这里还是采用信号量。这里的控制相对复杂一点、因为后面的任务恢复、失败任务重新创建也要做控制。
- (void)startDownload
{
//因为这是外部调用的方法,操作的执行要放到异步线程中。避免因为并发控制中的等待而堵塞外部线程
dispatch_async(self.downloadQueue, ^{
if (!_fileDownloadInfos.count) {
_completaionHandler(nil);
return;
}
NSLog(@"downloadInfoCount:%ld",(long)_fileDownloadInfos.count);
[_fileDownloadInfos enumerateObjectsUsingBlock:^(ZBLM3u8FileDownloadInfo * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
//控制切片下载并发
dispatch_semaphore_wait(self.tsSemaphore, DISPATCH_TIME_FOREVER);
if ([ZBLM3u8FileManager exitItemWithPath:obj.filePath]) {
obj.success = YES;
[self verifyDownloadCountAndCallbackByDownloadSuccess:YES];
}
else
{
//如果收到中断信号,中断下载流程释放信号量并返回
if (self.suspend) {
obj.beStopCreateTask = YES;
dispatch_semaphore_signal(self.tsSemaphore);
NSLog(@"suspend and return! don not createDownloadTask!");
return ;
}
else
{
//真正的创建下载任务
[self createDownloadTaskWithIndex:idx];
}
}
}];
});
}
//信号量在每个任务的回调后都会释放一次
- (void)verifyDownloadCountAndCallbackByDownloadSuccess:(BOOL) isSuccess
{
dispatch_semaphore_signal(self.tsSemaphore);
...
这里也看到信号量的控制问题,必须理清信号量的获得和释放时机,一次获得必须有一次释放。不释放或者重复释放,都会导致并发的控制不准。如果这样,那这里的并发控制就没有意义了。
要做到准确获取和释放,重点在于理清程序的执行路径。在每一条执行路径中都必须释放信号量。这个跟锁的使用也是一样的。
调试现象:如果程序不像预料中运行,又没有什么错误,那很有可能就是堵塞了。锁没有释放或者信号量的处理有问题。
处理步骤:点击xcode调试栏的暂停按钮,查看程序的调用栈,分析每个线程的运行情况,找到堵塞具体执行代码。根据具体的逻辑修正问题。
四、下载的中断和恢复
这里有几个小问题:根据判断NSURLSessionTask 提供的3个方法可以做一些中断和恢复处理
- (void)suspend;
挂起任务,但只能挂起执行中的任务。对于已经创建而且执行resum方法但并没真正执行的任务无效(这里非常坑)。
通常我们使用这个方法的时候会判断下任务的具体状态,如果是task.state == NSURLSessionTaskStateRunning采取执行 [task suspend]。但这个判断是不准确的。如果一个任务创建并执行resume但并没真正执行,它的状态也是为NSURLSessionTaskStateRunning。如果这个时候程序收到中断消息,对状态为NSURLSessionTaskStateRunning 的任务全部执行suspend操作,你会发现有些任务不听话,继续执行。到底什么搞鬼...
我的理解是这样的,这些不听话的任务正是那些添加到下载队列中等待执行的任务,而在等待状态下收到suspend消息是不管用的。但它接收cannel消息是管用的。那么问题的解决就是找出这些等待的任务。
处理办法:通过判断接收字节数来区分状态。现在我只面向你接收的字节数,而不管你真开启还是假开启了。
switch (obj.downloadTask.state) {
case NSURLSessionTaskStateRunning:
{
//等待中,假开启状态,
if (obj.downloadTask.countOfBytesReceived <= 0) {
[obj.downloadTask cancel];
}
else
{
//正在下载,真开启状态,
[obj.downloadTask suspend];
}
}
break;
- (void)resume;
官方文档是这么说明的:Resumes the task, if it is suspended.意思是指只能发起被挂起的任务。
存在两种情况:
1.新创建的任务并没有执行resume,此时状态为:NSURLSessionTaskStateSuspended
2.执行suspend方法后被手动挂起的任务,状态同为NSURLSessionTaskStateSuspended。
同时这里提供了额外信息:状态为NSURLSessionTaskStateCompleted的任务是不能通过resume重新发起的。而在某些情况下我们需要对这种状态的任务重新发起,包括手动cannel的、执行失败的。这种情况下只能根据具体的情况,重新创建任务并发起。
if (obj.downloadTask.error &&
obj.downloadTask.state == NSURLSessionTaskStateCompleted)
{
//下载失败的任务重新创建
[self createDownloadTaskWithIndex:idx];
}
- (void)cancel;
官方文档说明:
This method returns immediately, marking the task as being canceled. Once a task is marked as being canceled, URLSession:task:didCompleteWithError: will be sent to the task delegate, passing an error in the domain NSURLErrorDomain with the code NSURLErrorCancelled. A task may, under some circumstances, send messages to its delegate before the cancelation is acknowledged.
This method may be called on a task that is suspended.
简而言之:调用这个方法可以cannel任意状态的任务包括挂起的任务。并执行didCompleteWithError回调(AF中会执行回调并返回错误NSURLErrorCancelled),状态被标记为NSURLSessionTaskStateCompleted。故上面恢复失败任务的时候,通过task.state和task.error共同判断。
总结下任务生命周期中的任务状态变化:
1.创建成功:...Suspended
2.执行resume:...Running(可以通过判断countOfBytesReceived来区分任务处于等待还是下载中)
3.执行suspend:...Suspended
4.执行cannel:(中间状态...Canceling)->...Completed(可以结合task.error判断任务执行结果)
回归正题:
视频单元的中断和恢复,中断就是调用suspend方法挂起正在执行的任务,cannel掉等待的任务,代码跟上面说明方法的时候非常雷同。这里着重说明下恢复。如果单单恢复其实很简单,恢复挂起的任务和重新创建错误的任务。这只是从程序的角度看待,要使一个程序有更高的可用性,应在功能实现的同时做的更加的合理。应优先恢复挂起的任务、然后重新创建错误的任务。这样做都是为了承前启后更快的把一个下载任务完成。而且这个视频切片是讲究有序的,所以我们恢复的时候也要遵从FIFO的原则。
五、注意的问题与思路延伸
1.解析m3u8注意的问题
meu8的解析格式太多,很容易出现问题,应使用try/catch来保证程序的健壮性。
2.根据url获取原始m3u8文件信息,这个操作太耗时了。为了提高程序的效率,获取到原始m3u8文件后应做本地持久化并用于二次下载。如果整个流程下载成功,可以选择删除该文件,或者不操作。由于是纯文本文件,少量的文件冗余是允许的。
3.文件的操作通过开启一个同步队列来处。可以设定low优先级避免占用太高的cpu资源。其实高cpu占用会伴随着另外一个问题,手机的发热量。
4.key的处理问题
如果存在key的下载,需要把key下载到本地,约定好key的名称和新建m3u8文件中的key链接。这样本地播放就能正常加解密。
例如下载到本地的key保存为.../key。那么链接应该是http:localhost:port/.../key
5.中断的优先级
程序的中断操作拥有最高优先级的,因为要任何状态下都能中断下载。无论是为了程序的流畅性、网络变为移动信号避免使用用户的移动流量等发出中断命令,都必须立即响应。
6.切片数量的全局分配
多个视频同时下载,多个切片同时并发。如果要做到控制全局的切片并发数而不是单个视频的切片并发数。这就要设计一个算法在全局Manger哪里做分配和回收。
7.保证app流程,监控网速开启和中断下载
下载视频的功能应该要保证app本身网络请求的正常运行。
app如何获取到网络的带宽,好像只能通过下载文件方式来推算。可在应用请求空闲时通过短时间下载一个可用源来计算带宽,同时监控app 实时网络吞吐,适当的开关下载。虽然很难做到实时,但是在切换网络的时候进行带宽重测、又或者地理位置变化一定距离后进行带宽重测、又或者定时作带宽重测。还有就是考虑wifi状态下才进行下载。
8.切换网络后请求失去连接,恢复下载的问题
因为网络切换本地ip变化,发起的请求会失去连接。如果通过downloadTask是没办法做到恢复下载的。虽然可以用resumeData来恢复下载,但是这个只能在cannel操作的时候获取,至于失去连接的情况下是没办法获取到的(系统提供的api中没有在失败回调哪里返回resumeData的)。(思路是这样,不一定能实现)这个时候需要自己创建文件句柄,使用dataTask做到文件续下。初始化dataTask的时候设定请求头'Accept-Ranges'参数为文件的已下载字节数(需要服务器支持),就可以获取到未下载的部分数据。
9.并发中锁的处理
要理清那些代码可能存在并发,那些操作要保证原子性。难就难在一个方法中会存在部分代码块是并发执行的,这有利于效率的提高;部分些代码要原子操作。最优的做法就是对原子操作用锁来保证,没有任何多余的代码加入到同步操作中,这样也是效率最高的。而拿捏不准的情况下,可以锁定更多的代码,至少这样不会因为并发而导致问题,但这样就牺牲了效率和及时性。当一个简单的系统,要做到最优好像并不难,但是一个复杂的系统做到最优就非常难了或者是要花费非常大的精力。基于这个demo的实现多线程流了不少坑,总结下多线程还是复杂。开发中优先考虑线程安全,再提高性能吧。
10.是否需要全部切片下载完成才能播放
其实并不需要全部下载完成就能播放的。保证key 先下载下来,而且要保证有序下载,然后下载一定量的切片文件,这个时候就可以组装m3u8文件到本地,发起播放。只要后面下载的切片能满足播放器的播放,就不会出现问题。但如果供应不足视频就会停了,播放不了,尽管后面文件下载下来了,还是不能自动恢复,仿佛失去了缓冲功能。这里就是跟直接请求服务器的差别了,直接请求服务器,因为文件本身是存在的,发起的请求是存在的,如果网速慢,播放器的反应是缓冲;而本地服务播放就不同了,如果文件在播放前没有下载下来,发起的请求立马就挂了,这个请求不存在,当然就不存在缓冲。
11.线程多开占用资源,每个线程占用512K到1M空间。建议使用单线程下载,且稳定性高。
dome虽然实现了多线程下载,偶发死锁的问题会存在,就是有坑!!!。但m3u8文本文件跟数据解析部分处理是稳定的。(已更新修正部分问题。)
链接:https://github.com/zmubai/ZBLM3U8DownLoadTest
更新:
- 之前的demo问题不少,就抽空改了下,问题有所改善,但还不能达到稳定。就又弄了一个简单的demo,把主要功能实现,做到简单点稳定点。
地址:https://github.com/zmubai/m3u8DownloadSimpleDemo[2019-4-7] - 要实现并发控制,使用信号量的方式是可以实现的,但有一个很大的缺点,就是暂停控制变得麻烦。假设并发控制数为2,同时发起10个,那么有8个在等待,如果执行暂停,那么已经执行的2个可以暂停,但等待的8个取消不了,需要让他们发起,然后再取消,操作变得很麻烦。衡量了一下,可以使用NSOperationQueue去控制并发,继承NSOperation,创建子类,并通过设置finish变量为true,让任务完成。只有当finish为true,任务才会在队列中移除,这样就能控制并发,并且更易于执行cannel等操作。[2019-4-7]
3.由于之前版本的实现不是特别满意,就使用operation实现的版本。(支持使用cocoaPods 安装)
支持媒体并发控制,支持单个媒体文件并发控制。支持任务取消,支持任务挂起和恢复。
地址:https://github.com/zmubai/BNM3u8Cache[2019-12]