关于边下边播功能目前流传的版本大体相同,本篇文章主要介绍另一种可行的实现方式。
关于AVPlayer在这里我们不做详细解释,如果你是刚刚开始接触AVPlayer,不妨先看看下面两篇文章:
iOS视频播放详解1-基本使用
iOS视频播放详解2-封装边下边播的播放器
“你这篇都这么长,还让我看另外两篇,太TM蛋疼了,我能不能不看?”
嗯...头皮硬的话...你随意。
如果上面两篇还没有研究懂的话,出于人道主义关怀,建议还是不要继续往下看了,头皮真的会变硬。
讲真,从开始了解AVPlayer到写出这篇文章前前后后历时近两个月(还不是因为懒),所以想要了解AVPlayer的兄dei姐妹别急,慢慢来。
一、流程
下面这张图是iOS视频播放详解2-封装边下边播的播放器中提供的流程(下文称流程A),至于详细解释文中也已经写的非常清晰,这里之所以要贴出来就是为了让大家回忆一下,因为本文要介绍的流程正是在这个流程的基础上变种而来。(代码基因突变你见过吗?)
接下来,我想确认一下,应该没有人叫黑板的吧。
我要敲黑板了,咳咳...
从流程A来看,我们所需要解决的问题只有两个
如何提供数据给播放器?
如果缓存中有数据使用缓存播放,如果没有则开启下载使用网络数据。何时从网络下载数据,怎么下载以及数据缓存策略?
当本地没有缓存文件或者当前请求offset大于或者小于已经下载的offset时从offset开始到文件尾向服务器发起请求,只要代理重新向服务器发起请求,就会导致缓存的数据不连续,则加载结束后不用将缓存的数据放入本地cache
从这两个问题出发,我们可以将为播放器提供数据与下载数据拆分为两个单独的部分。而播放的过程中一定是有缓存文件的,只不过文件不一定完整,如果无论本地是否有完整的缓存文件存在,播放器的数据全部来自缓存而不直接来自网络会怎么样呢?且看下图:
resourceLoaderDelegate:向dataManager请求数据,并且使用dataManager返回的数据填充loadingRequest。
dataManager:从本地缓存中提供数据,如果遇到缓存中没有的数据则计算缓存中从指定offset开始缺失的数据范围并通知downloader下载对应范围数据,并且将downloader下载的数据存入缓存等待推送。(注意,dataManager并不会直接使用从downloader返回的数据,downloader下载的数据只会被存入缓存文件。)
downloader:下载指定范围数据。
基于上面的流程,我们再来回答一下这两个问题:
如何提供数据给播放器?
resourceLoaderDelegate接收到loadingRequest之后通过计算得出所需数据起始位置,并且将起始位置偏移量传递给dataManager。dataManager接收到偏移量之后查找到对应的缓存文件,并且开始定速定量推送数据给resourceLoaderDelegate,循环往复,直至数据末尾。(注:一段完整的视频可能分为多个文件存储。)
注: 在此过程中没有直接使用任何网络数据,全部数据从缓存中获得。
何时从网络下载数据,怎么下载以及数据缓存策略?
dataManager在推送数据的过程中,如果发现缓存中没有需要的数据片段则计算出从offset开始缓存中缺失的最短数据范围并且通知downloader下载,downloader下载好指定范围数据返回给dataManager,dataManager接收到下载的数据存入缓存等待下一次数据推送读取。取决于下载起始偏移量的值downloader下载的数据可能有两种存储方式即拼接在已有文件之后(下载起始偏移量与已有文件结束位置相同)或者新建文件存储(下载起始偏移量与已有文件结束位置不同)。
看着很简单是不是,简单不简单代码说了算hh...
其实在码代码的整个过程中I have questioned the feasibility of this method more than once. 坑太TM多了!!!
- 在整个播放过程中,可能不止一个loadingRequest,resourceLoaderDelegate如何得出下一次所需的数据起始位置?
- 如何解决读取大文件时的内存暴涨问题?
- dataManager既然是推送数据给resourceLoaderDelegate,以什么样的方式推送合适?
- 如何避免阻塞主线程?
- 如何避免downloader重复下载?
- 由于数据可能是分段下载,如何保证数据完整性以及正确性?
...
在开始之前首先展示一下成果,坚定一下各位继续往下读的信心(信心爆棚的同学请看屏幕右侧滚动条)。
请忽略上图中的正在等待下载,说多了都是泪。
What are you 弄啥嘞 (╯‵□′)╯︵┻━┻
这样做有什么好处呢?
首先类的分工更加明确,有利于写代码。
其次由于我们是分多个文件存储视频,所以在播放时只需要下载缓存中没有的数据即可,避免了数据的重复下载,节省流量。
这两点应该是能看得到的最大的好处了吧。
啰啰嗦嗦这么多,接下来我们就开始愉快的撸代码吧。
二、实现
创建assets以及resourceLoaderDelegate
我们要通过AVURLAssets创建AVPlayer,这样开始播放视频时AVPlayer会向AVURLAssets请求数据,而AVURLAssets就会根据创建时我们提供的URL请求数据并提供给AVPlayer播放。
到这里为止,好像我们并没有我们操作的空间,而我们需要能够在AVPlayer播放的过程中操纵视频数据,这时我们就需要借助AVURLAssets的resourceLoaderDelegate帮忙。
resourceLoaderDelegate对于AVURLAssets来说有什么用呢?
如果我们提供给AVURLAssets的URL是它能够识别的URL,那么AVURLAssets就会自己开始播放,而当我们提供一个AVURLAssets不能识别的URL时,为了能够正常播放,AVURLAssets就会询问resourceLoaderDelegate:
AVURLAssets:兄弟,这个地址我播不了,你能不能播?
resourceLoaderDelegate:哥,你这话问的,那必须能啊!
AVURLAssets:好的,我需要xxx字节-xxx字节的数据,谢谢!
resourceLoaderDelegate:ojbk!(说出来有点伤感,害你播不了的地址就是我偷偷换的)
...
为了我们的resourceLoaderDelegate能够成功上位,我们需要对URL做一些手脚,把原始地址的scheme进行更改,至于改什么那就看你的个人喜好了,比如gblw什么的,随心所欲,你开心就好。
首先,我们需要创建SEEResourceLoaderDelegate,基于高内聚低耦合原则(我瞎说的)我们为SEEResourceLoaderDelegate添加一个对象方法,用于返回一个绑定当前对象为resourceLoaderDelegate的AVURLAssets对象。
//SEEResourceLoaderDelegate.h
#import
@class AVURLAsset;
NS_ASSUME_NONNULL_BEGIN
@interface SEEResourceLoaderDelegate : NSObject
- (AVURLAsset *)assetWithURL:(NSURL *)url;
@end
NS_ASSUME_NONNULL_END
//SEEResourceLoaderDelegate.m
- (AVURLAsset *)assetWithURL:(NSURL *)url {
[self.loadingRequests removeAllObjects];
_url = url;
NSURLComponents * components = [NSURLComponents componentsWithURL:url resolvingAgainstBaseURL:YES];
//替换scheme
components.scheme = @"seeplayer";
NSURL * target = [components URL];
//使用替换后的url创建AVURLAssets
AVURLAsset * asset = [AVURLAsset assetWithURL:target];
//为了防止阻塞主线程,我们新建一个串行队列来接收代理回调
[asset.resourceLoader setDelegate:self queue:self.queue];
return asset;
}
/**
这个队列用于执行接收播放器发出的resourceLoader、下载器接收回调、文件管理器推送数据
@return 串行队列
*/
- (dispatch_queue_t)queue {
if (_queue) {
return _queue;
}
_queue = dispatch_queue_create("player", DISPATCH_QUEUE_SERIAL);
return _queue;
}
根据我多年的经验,很少有人能够把文章里面贴的代码完整读完的,别问我为什么知道,我就是大部分人里面光荣的一员。
幸会幸会...
所以每当贴一段代码我都会紧跟着对贴的代码做一个简单的解释,简直不要太贴心。
上面的代码中我们将原始URL进行了记录,并且使用处理后的URL创建了AVURLAssets对象并且将自己作为AVURLAssets对象的resourceLoaderDelegate,为了防止阻塞主队列,我们创建了一个新的串行队列来作为回调队列。
创建player
//2 重新设置item
AVPlayerItem * newItem = [AVPlayerItem playerItemWithAsset:[_resourceLoaderDelegate assetWithURL:url] automaticallyLoadedAssetKeys:@[@"duration",@"preferredRate",@"preferredVolume",@"preferredTransform"]];
//3 播放器播放新url之前清除上一次的监听等
[self see_clearPlayer];
//4 设置新的item
[_player replaceCurrentItemWithPlayerItem:newItem];
//5 添加对播放器的监听
[self see_preparePlayer];
以上代码我们通过AVURLAssets创建AVPlayerItem,并且将player的item进行替换。
接下来,我们需要实现AVAssetResourceLoaderDelegate中定义的代理方法来接收loadingRequest。
实现AVAssetResourceLoaderDelegate方法
每当assets发出一个loadingRequest我们就会在
- (BOOL)resourceLoader:(AVAssetResourceLoader *)resourceLoader shouldWaitForLoadingOfRequestedResource:(AVAssetResourceLoadingRequest *)loadingRequest
中接收到,如果我们可以加载loadingRequest中指定的数据片段返回YES,不能则返回NO。
这还用说?当然返回YES,否则我们在做什么。
- (BOOL)resourceLoader:(AVAssetResourceLoader *)resourceLoader shouldWaitForLoadingOfRequestedResource:(AVAssetResourceLoadingRequest *)loadingRequest {
//将request添加进数组记录,得到数据后进行填充
[self.loadingRequests addObject:loadingRequest];
return YES;
}
每当得到一个loadingRequest之后,我们需要对其中需要的数据进行填充,而我们得到这个loadingRequest中所需数据的时间不确定,因此我们需要将其存储起来,等待得到数据之后再向其中填充数据。
- (void)resourceLoader:(AVAssetResourceLoader *)resourceLoader didCancelLoadingRequest:(AVAssetResourceLoadingRequest *)loadingRequest {
//移除被取消的loadingRequest
[self.loadingRequests removeObject:loadingRequest];
}
当一个loadingRequest被取消后,我们再对其进行数据填充是无意义的,所以我们需要将存储的loadingRequest从数组中移除不再进行数据填充。
通过上面一波骚气的操作,我们已经成功的得到了播放数据的掌控权,接下来我们需要考虑的就是如何得从我们得到的一堆loadingRequest中找到播放需要的数据的起始位置。
数据起始位置查找
正常播放状态下,当我们每完成一个loadingRequest,播放器会重新发出一个或者多个loadingRequest来请求后续的数据。
如果用户拖动进度条前进或者后退,播放器同样会发出新的loadingRequest,而此时我们之前的loadingRequest并没有填充完成。
基于上述两点,我们发现播放器最新发出的loadingRequest请求的一定是接下来播放所需要的数据片段。
所以我们只需要找到loadingRequest数组中最后一个元素也就是最新发出的loadingRequest,并且以它的currentOffset为基准通知dataManager,当dataManager返回数据之后进行填充即可。
- (long long)see_expectOffset {
//获取最新的loadingRequest的currentOffset即可
if (self.loadingRequests.count != 0) {
return ((AVAssetResourceLoadingRequest *)self.loadingRequests.lastObject).dataRequest.currentOffset;
}
return 0;
}
说实话,为了这几行代码呕心沥血好几天...
需要的数据的起始位置我们已经得到了,接下来就是dataManager获取数据、推送数据。
创建dataManager
既然我们会将视频缓存到本地,那么我们用什么来判断当前正在播放的视频有没有缓存呢?
在播放一个视频之前,我们唯一知道的信息就是播放地址,而视频的播放地址又具有唯一性,这样看来URL是我们用来判断是否拥有缓存的不二选择。因此我们需要利用URL来初始化dataManager,而dataManager会将数据推送出去,因此我们还需要一个代理作为数据的接收者。
- (instancetype)initWithURL:(NSURL *)url delegate:(id)delegate {
if (self = [super init]) {
_prepareData = [SEEData mutableData];
_url = url;
_cacheBasePath = NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES).lastObject;
_fileInfo = [[SEEFileInfo alloc]initWithURL:url];
_totalBytes = _fileInfo.fileAttribute.totalBytes;
_MIMEType = _fileInfo.fileAttribute.MIMEType;
_inputStream = [[SEEInputStream alloc]init];
_inputFile = [_fileInfo fileForOffset:0];
if (_inputFile)[_inputStream setFileAtPath:[_cacheBasePath stringByAppendingPathComponent:_inputFile.path]];
_downloader = [[SEEDownloader alloc]initWithURL:url delegate:self];
_cacheRanges = [NSMutableArray array];
if (_fileInfo.files.count) {
[_fileInfo.files enumerateObjectsUsingBlock:^(SEEFile * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
NSRange range = NSMakeRange((NSUInteger)obj.startOffset,(NSUInteger)obj.endOffset);
[self->_cacheRanges addObject:[NSValue valueWithRange:range]];
}];
}
[self see_postRanges];
[self setDelegate:delegate];
}
return self;
}
ヾ(。`Д´。)我擦,毛啊,怎么一下就初始化了这么多东西,这TM什么鬼?
别急,我慢慢解释给大家听。
_url : 播放地址,用来查找本地缓存以及下载数据。
_cacheBasePath: 缓存目录,所有的缓存文件全部放在这个目录下。
_fileInfo: 缓存文件信息,包括当前视频信息以及每一个缓存文件信息。
_inputStream: 输入流,从缓存文件读取数据,防止大文件读取时的内存保障。
_inputFile: 当前正在输入的文件信息。
_downloader: 下载器。
_cacheRanges: 已经缓存的数据的范围。
_prepareData: 推送过程中的数据缓存,用于存放正在推送的数据。
以上的所有成员变量的作用在后文中对应部分会进行详细讲解,这里我们先简单了解一下即可。
这样我们在resourceLoaderDelegate的- (AVURLAsset *)assetWithURL:(NSURL *)url方法中将dataManager一并创建即可。
读取大文件
在我们的设计中,dataManager只能从本地缓存中读取数据,那么问题来了,假设本地已经有了数据,我们该怎么读取才能既保证效率又避免内存暴涨呢?
NSData:如果直接使用NSData进行数据读取的话,在整个播放过程中,每次获取数据都要将全部文件读入内存,然后找到对应的数据返回。总感觉这种代码下不了手。pass!
NSFileHandle:传说中能够解决读入大文件时的内存暴涨问题(在Stack Overflow上看到有人说并不能),但是,对不起,每次都要自己算偏移量有点恶心。pass!
NSInputStream: 单向流,不用操心偏移量,可以控制每次读取数据量大小,但是读取过的数据不能再次读取。嗯...最后一点好像有点恶心,用户拖拽播放器进度条之后好像需要能够从指定offset读取数据,不过我们可是程序员啊怕个锤子,改!
哥,你眼角怎么湿了...
你懂个锤子,这是姓 福 的眼泪!
以下解决方案仅供参考,智商压制,实在想不出什么更好的办法,如果你有,请告诉我,作为回报,我可以把我潜心研究多年的葵花宝典心得传授给你。
哎,憋走啊。虽然欲练此功必先自宫,但是如不自宫亦可成功的啊亲!
思路是这样的,首先每次读取数据时我们通过计算得出读取之后的offsetA,下一次读取数据时首先判断要读取的数据起始位置offsetB和当前stream读取到的位置offsetA是否一致,如果一致则继续读取指定长度返回,如果不一致关闭当前stream重新创建相同path的stream,然后一直读取数据到offsetB位置之后再读取指定长度数据返回。
又见代码:
@interface SEEInputStream: NSObject
- (void)setFileAtPath:(NSString *)path;
- (NSInteger)read:(uint8_t *)buffer maxLength:(NSUInteger)len startOffset:(long long)startOffset;
@end
@implementation SEEInputStream {
long long _currentOffset;
NSString * _path;
NSInputStream * _stream;
}
- (void)setFileAtPath:(NSString *)path {
//关闭之前打开的文件
[self close];
_currentOffset = 0;
_path = path;
_stream = [[NSInputStream alloc]initWithFileAtPath:path];
[self open];
}
//重新打开当前文件
- (void)resetStream {
[self close];
_stream = [NSInputStream inputStreamWithFileAtPath:_path];
[self open];
_currentOffset = 0;
}
- (void)open {
if (!_stream)return;
[_stream open];
}
- (void)close {
if (!_stream)return;
[_stream close];
_stream = nil;
}
//从stream中读取数据
- (NSInteger)read:(uint8_t *)buffer maxLength:(NSUInteger)len {
NSInteger readBytes = [_stream read:buffer maxLength:len];
if (readBytes == -1 || readBytes == 0) {
NSLog(@"%@",_stream.streamError);
}
_currentOffset += readBytes;
return readBytes;
}
//读取指定位置数据
- (NSInteger)read:(uint8_t *)buffer maxLength:(NSUInteger)len startOffset:(long long)startOffset {
if (startOffset < _currentOffset) {
//如果起始位置小于当前读取到的位置则重新打开stream
[self resetStream];
return [self read:buffer maxLength:len startOffset:startOffset];
}
else if (startOffset > _currentOffset) {
//如果读取到的位置小于读取的起始位置则一直读取数据直到startOffset == _currentOffset
int loopCount = (int)((startOffset - _currentOffset) / len);
for (int i = 0; i <= loopCount; i++) {
if (i == loopCount) {
NSUInteger bufferLength = (startOffset - _currentOffset) % len;
if (bufferLength == 0) break;
uint8_t buffer[bufferLength];
[self read:buffer maxLength:bufferLength];
}
else {
uint8_t buffer[len];
[self read:buffer maxLength:len];
}
}
return [self read:buffer maxLength:len startOffset:startOffset];
}
else {
//读取数据返回
return [self read:buffer maxLength:len];
}
}
@end
以上代码,我们将对于InputStream的管理放在SEEInputStream类中,外界只需要设置需要读取的文件目录,然后只管尽情的读取数据即可。
那读取大文件的问题解决了,问题是我们的文件是分段存储的,我怎么知道我该从哪个文件里面读取数据。
别急,_fileInfo告诉你。
文件信息管理
每当我们准备读取数据时只有一个offset告诉我们该读取的数据的起始位置,但是究竟这段数据有没有,有的话存储在哪里。这些我们目前还是一脸懵逼的。所以我们需要借助一个类来帮助我们管理这些琐事。这样每当dataManager得到一个offset就问这个类:“兄弟,快告诉我这段数据在哪个文件里面”。然后就去对应的文件读取数据。这也是这个类的设计初衷。
既然我们在本地缓存了数据,那总不能每次要播放时都请求网络获取MIME类型,数据大小等信息吧,否则无网络情况下就会出现本地有缓存而因为缺少这些数据导致不能播放的问题,所以我们需要存储这些数据。
每当dataManager得到一个offset时都会来询问它这些数据在那个文件里面,那首先它得知道目前的缓存文件有多少,每个文件存储的数据是哪一段的数据,多以还需要存储每个文件的文件名,数据起始位置,数据结束位置,文件长度等信息。
由此我们的类被设计成了这样
@interface SEEFileAttribute: NSObject
//MIME类型
@property (nonatomic, copy) NSString * MIMEType;
//总数据量
@property (nonatomic, assign) long long totalBytes;
//已经下载的数据总量
@property (nonatomic, assign) long long cacheBytes;
//缓存是否完成 isComplete = totalBytes == cacheBytes
@property (nonatomic, assign) BOOL isComplete;
//文件名
@property (nonatomic, copy) NSString * exceptFileName;
@end
@interface SEEFile: NSObject
//缓存文件名
@property (nonatomic, copy) NSString * path;
//缓存文件起始位置
@property (nonatomic, assign) long long startOffset;
//缓存文件结束位置
@property (nonatomic, assign) long long endOffset;
//缓存文件长度
@property (nonatomic, assign) NSUInteger length;
@end
@interface SEEFileInfo: NSObject
@property (nonatomic, strong) SEEFileAttribute * fileAttribute;
@property (nonatomic, strong) NSMutableArray * files;
- (instancetype)initWithURL:(NSURL *)url;
@end
这样,这个类就掌握了本地缓存文件的所有信息,接下来我们需要他可以根据offset提供给我们对应的文件信息:
- (SEEFile *)fileForOffset:(long long)offset {
__block SEEFile * file = nil;
self.missingEndOffset = self.fileAttribute.totalBytes;
[self.files enumerateObjectsUsingBlock:^(SEEFile * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
if (obj.startOffset <= offset && obj.endOffset > offset) {
file = obj;
*stop = YES;
}
}];
return file;
}
通过遍历缓存文件,并且使用offset与每个文件进行比较得到包含offset的文件并返回。
读取并持有数据等待推送
dataManager在接到offset之后就可以通过以下代码来读取对应的数据了。
//如果当前访问的文件中包含后续数据则在当前文件中读取
if (_inputFile && _inputFile.endOffset > startOffset && _inputFile.startOffset <= startOffset) {
[self see_prepareDataFormCache:startOffset];
return;
}
//如果当前访问的文件中不包含后续数据则重新查找对应文件
_inputFile = [_fileInfo fileForOffset:startOffset];
if (_inputFile){
[_inputStream setFileAtPath:[_cacheBasePath stringByAppendingPathComponent:_inputFile.path]];
[self see_prepareDataFormCache:startOffset];
return;
}
由于NSInputStream在读取数据时必须指定长度,因此,我们读取到的数据不一定会在一次推送中被全部接收,有可能只是接收了其中的一部分,而另一部分会在接下来的一次或多次推送中才被接收(取决于loadingRequest),因此需要将读取到的数据保存在内存中持续推送,直到数据全部被接收(通过每次推送完成resourceLoaderDelegate返回的offset来判断数据是否被接收)为止。
在此过程中,我们不仅要保证存储的数据在使用之前被释放,而且还要保证数据的正确性,并且为数据的接收者提供本段数据的起始位置、结束位置等信息,以保证接受者可以根据这些信息正确的使用这些数据。
为了满足这些需求,我们选择使用一个专门的类来管理带推送的数据。
@interface SEEData: NSObject
//数据起始位置
@property (nonatomic, assign, readonly) long long location;
//数据长度
@property (nonatomic, assign, readonly) NSUInteger length;
//数据结束位置
@property (nonatomic, assign, readonly) long long end;
//数据
@property (nonatomic, strong, readonly) __kindof NSData * data;
+ (instancetype)dataWithLocation:(long long)location lenght:(NSUInteger)length data:(__kindof NSData *)data;
/**
拼接数据
只有通过mutableData创建的对象可以使用该方法
@param data 数据
@param length 长度
*/
- (void)appendData:(const void *)data length:(NSUInteger)length;
/**
初始化指定offset之前的数据
只有通过mutableData创建的对象可以使用该方法
@param offset offset
*/
- (void)initOffset:(long long)offset;
//创建的对象data不可变
+ (instancetype)data;
//创建的对象data为可变
+ (instancetype)mutableData;
- (id)copy;
- (id)mutableCopy;
- (BOOL)isEqual:(id)object;
@end
@interface SEEData()
@property (nonatomic, assign) long long location;
@property (nonatomic, assign) NSUInteger length;
@property (nonatomic, assign) long long end;
@property (nonatomic, strong) __kindof NSData * data;
@end
@implementation SEEData
+ (instancetype)dataWithLocation:(long long)location lenght:(NSUInteger)length data:(__kindof NSData *)data {
SEEData * instance = [[SEEData alloc]init];
instance.location = location;
instance.length = length;
instance.data = data;
return instance;
}
+ (instancetype)data {
return [self dataWithLocation:0 lenght:0 data:[NSData data]];
}
+ (instancetype)mutableData {
return [self dataWithLocation:0 lenght:0 data:[NSMutableData data]];
}
- (void)appendData:(const void *)buffer length:(NSUInteger)length {
[((NSMutableData *)_data) appendBytes:buffer length:length];
self.length += length;
}
- (void)initOffset:(long long)offset {
if (offset == _location) return;
long long initLength = offset - _location;
_location = offset;
if (initLength < 0 || initLength > _length) {
//如果初始化的位置不在当前数据片段内则清除当前全部数据
[((NSMutableData *)_data) replaceBytesInRange:NSMakeRange(0, _length) withBytes:NULL length:0];
self.length = 0;
return;
}
//清除指定offset之前的数据
[((NSMutableData *)_data) replaceBytesInRange:NSMakeRange(0, initLength) withBytes:NULL length:0];
self.length -= initLength;
}
- (id)copy {
if ([self.data isMemberOfClass:[NSData class]]) {
return [SEEData dataWithLocation:self.location lenght:self.length data:self.data];
}
else {
return [SEEData dataWithLocation:self.location lenght:self.length data:[NSData dataWithData:self.data]];
}
}
- (id)mutableCopy {
if ([self.data isMemberOfClass:[NSData class]]) {
return [SEEData dataWithLocation:self.location lenght:self.length data:[NSMutableData dataWithData:self.data]];
}
else {
return [SEEData dataWithLocation:self.location lenght:self.length data:self.data];
}
}
- (BOOL)isEqual:(id)object {
if ([object isMemberOfClass:[self class]]) {
SEEData * target = object;
return self.location == target.location && self.length == target.length && [self.data isEqualToData:target.data];
}
else {
return NO;
}
}
- (void)setLength:(NSUInteger)length {
_length = length;
_end = _location + _length;
}
- (NSString *)description {
return [NSString stringWithFormat:@"location %lld length %lu end %lld dataLength %lu",_location,_length,_end,_data.length];
}
@end
需要使用-initOffset:清除数据的情况:
- 当用户拖动进度条时,可能导致我们目前存储的数据不是播放器所需的数据。
- 当一段数据已经被接收之后,将接收过的数据清除减少内存占用。
这样我们就可以将准备好等待推送的数据先存储起来。
- (void)see_prepareDataFormCache:(long long)startOffset {
uint8_t buffer[262144] = {};
NSInteger readByte = [_inputStream read:buffer maxLength:262144 startOffset:startOffset - _inputFile.startOffset];
[((NSMutableData *)_prepareData.data) appendBytes:buffer length:readByte];
_prepareData.length += readByte;
}
数据有了,怎么推送给resourceLoaderDelegate呢?
数据推送方案
既然我们的dataManager是通过推送的方式来向其代理输出数据的,我们需要一个能够稳定的循环执行指定代码的方式并且要避免阻塞线程。
Excuse me? 你说什么?for循环?
while?
while (alive) {
spittingBlood(3);
}
咳咳... 你不是不忘了NSTimer,时间可是一种神奇的东西。(严肃脸)
如何使用Timer推送
在撸代码之前首先要想清楚,什么时候开始推送?什么时候结束推送?
如果我们在初始化dataManager的时候就开始推送数据可以吗?
可以是可以,但是这样就比较low了,dataManager初始化的时候可能播放器还没有开始播放,resourceLoaderDelegate还没有接收到任何loadingRequest,然后dataManager就像个二傻子一样疯狂推送数据,空气瞬间凝固,气氛好像有那么一丢丢尴尬。
为了避免上述情况出现,我们选择在接收到loadingRequest之后再开始数据推送,这样即使播放完成之后我们终止了推送,用户拖动进度条或者重新播放时播放器会再次发出loadingRequest,此时我们的推送会被再次开启。
- (BOOL)resourceLoader:(AVAssetResourceLoader *)resourceLoader shouldWaitForLoadingOfRequestedResource:(AVAssetResourceLoadingRequest *)loadingRequest {
//将request添加进数组记录,得到数据后进行填充
[self.loadingRequests addObject:loadingRequest];
//dataManager开始准备推送数据
[_dataManager begin];
return YES;
}
当全部数据推送完成、播放另一个url或者播放器销毁时则需要将推送终止,也就是将timer移除。
既然这样,那我们就开始吧:
- (void)begin {
_timer = [NSTimer timerWithTimeInterval:0.1 target:self selector:@selector(see_pushData:) userInfo:nil repeats:YES];
[[NSRunLoop currentRunLoop] addTimer:_timer forMode:NSRunLoopCommonModes];
[[NSRunLoop currentRunLoop] run];
}
你以为这样就可以了?
too young too simple.
运行代码就会发现,当我们的执行[[NSRunLoop currentRunLoop] run]之后,后面的代码并没有执行。导致resourceLoaderDelegate的代理方法没有返回,而由于代理方法没有返回所以播放器一直处在等待状态,不会发出新的loadingRequest无法播放。
所以为什么会出现这中情况呢?
仔细阅读官方文档中给出的 run 方法的解释
If no input sources or timers are attached to the run loop, this method exits immediately; otherwise, it runs the receiver in the NSDefaultRunLoopMode by repeatedly invoking runMode:beforeDate:. In other words, this method effectively begins an infinite loop that processes data from the run loop’s input sources and timers.
大意说run方法会重复调用runMode:beforeDate:方法,也就是启动了一个无限循环,来处理Timer和source。
另外官方文档中还很贴心的给出了一个例子:
BOOL shouldKeepRunning = YES; // global
NSRunLoop *theRL = [NSRunLoop currentRunLoop];
while (shouldKeepRunning && [theRL runMode:NSDefaultRunLoopMode beforeDate:[NSDate distantFuture]]);
虽然这个例子并不是run方法的实现,但是通过这个例子,我们大致可以猜测到run方法的实现应该与其相似。
所以我们的代码变成了这样:
_timer = [NSTimer timerWithTimeInterval:0.1 target:self selector:@selector(see_pushData:) userInfo:nil repeats:YES];
[[NSRunLoop currentRunLoop] addTimer:_timer forMode:NSDefaultRunLoopMode];
//[[NSRunLoop currentRunLoop] run];
while ([[NSRunLoop currentRunLoop] runMode:NSDefaultRunLoopMode beforeDate:[NSDate distantFuture]]);
那我们应该怎么办呢?
当然是不要在当前正在执行的子线程添加Timer了。
可以选择将timer添加进主线程里面,但是如果我们的播放器是添加在TableViewCell上,这样做还是有点心虚的,毕竟好像Timer回调执行的代码有点多...
所以我们选择指定一个子线程来执行Timer事件。
- (void)begin {
if (self.state == SEEDataManagerStateInit) {
self.state = SEEDataManagerStateBegin;
_timerThread = [[NSThread alloc]initWithTarget:self selector:@selector(see_timerWithThread) object:nil];
[_timerThread start];
}
}
- (void)see_timerWithThread {
@autoreleasepool {
[[NSThread currentThread] setName:@"player_timer"];
_timer = [NSTimer timerWithTimeInterval:0.1 target:self selector:@selector(see_pushData:) userInfo:nil repeats:YES];
[[NSRunLoop currentRunLoop] addTimer:_timer forMode:NSRunLoopCommonModes];
[[NSRunLoop currentRunLoop] run];
}
}
怎么样,这个代码是不是有点眼熟?
什么?你说听不懂我在说什么?
相信我,你一定有机会和面试官尬聊。
你还是赶紧看看iOS开发之线程永驻吧。(没有喂自己袋盐,请放心食用)
现在我们的dataManager已经成功觉醒,拥有了能够稳定的循环执行指定代码的能力。but...
- (void)see_pushData:(NSTimer *)sender {
}
代码呢?
数据推送逻辑
在考虑推送逻辑之前,先看看我们现在已经完成的工作:
- 接收loadingRequest并记录。
- 从所有loadingRequest中查找期望数据位置。
- 读取大文件。
- 将所有缓存文件使用文件信息管理类进行统一管理。
- 内存缓存数据的管理。
- 推送方案的确定以及实现。
有没有看到我们的推送逻辑?
其实很简单 loop ( 6 => (1,2) => (3,4) => 5 ) 这就是我们的推送逻辑。
dataManager在推送开始时将当前内存缓存数据推送给resourceLoaderDelegate,resourceLoaderDelegate接收到数据进行填充,之后遍历所有loadingRequest查找到期望数据起始位置offset回传给dataManager(哥,说好了,下一次你给我数据的时候一定要有这个位置的数据。),dataManager接收到offset将填充完成的数据进行清除,然后使用offset通过文件信息管理类查找到对应缓存文件读取数据,将读取的数据存储在内存缓存中等待下一次推送。如此循环,直到(地球爆炸,宇宙消亡,那是不可能的)播放器销毁或者全部数据推送完成。
如果我们的推送逻辑修改为 loop ((1,2) => (3,4) => 5 => 6)也是可以的,但是这样我们在第一次推送时并不知道代理需要的数据的起始位置,这样我们只能盲目读取一段数据,如果我们读的这段数据不是代理所需要的那TM就尴尬了。如果第一次推送不读取数据那就需要跳过 (1,2) => (3,4) => 5 这三步,这又和我们之前讨论的逻辑有什么不同呢?还多了一个判断。
所以这里我选择使用 6 => (1,2) => (3,4) => 5 这种方式,由dataManager主动询问需要的数据,而不是二话不说就准备数据然后代理回应之后再去调整数据。毕竟男孩子还是要主动一些的。(滑稽)
等等,你本地缓存文件从哪里来?
别急,还没到,这是两码事憋打岔。
既然这样也就没什么好说的了,开始着手把上面的逻辑翻译成代码吧:(我们不生产代码,我们是代码的搬运工。)
- (void)see_pushData:(NSTimer *)sender {
if (!self->_responder.didReceiveData) return;
long long finishOffset = 0;
//将数据推送给接收者得到填充完成位置
finishOffset = [self->_delegate didReceiveData:self->_prepareData];
//清除填充完成的数据
[self see_clearFinishData:finishOffset];
//检查推送是否完成
if (finishOffset && finishOffset == self->_fileInfo.fileAttribute.totalBytes) {
[self stop];
return;
}
//查找数据
if (finishOffset < self->_prepareData.location || finishOffset >= self->_prepareData.end) {
//如果请求的数据不连续,将内存缓存清空
if (finishOffset != _prepareData.end)[_prepareData initOffset:finishOffset];
[self see_prepareData:finishOffset];
}
}
- (void)see_clearFinishData:(long long)finishOffset {
//每完成1M数据推送清除一次内存缓存
if (finishOffset >= _prepareData.location + 1048576) {
[_prepareData initOffset:_prepareData.location + 1048576];
}
}
关于上述代码中部分可能存在的疑问:
为什么不填充一次数据就立即将数据清除而要等到数据量达到某一数量才进行清除?
这样设计出发点在于当播放器开始播放时会先发出一段2字节的数据请求来确认数据,之后会发出从0开始到文件末尾的数据请求,如果一开始就立即将这两字节的数据清除当再一次发出请求的时候为了保证数据正确性,我们需要将当前内存缓存中的数据全部清除重新从0开始读取。
另外这样做也会减少在整个播放过程中清除数据的次数,保证性能。毕竟整个播放过程中进行的计算并不少,能省一点是一点。
为什么在查找数据之前有时需要清除内存缓存?
首先我们看看清除缓存的方法实现:
- (void)initOffset:(long long)offset {
if (offset == _location) return;
long long initLength = offset - _location;
_location = offset;
if (initLength < 0 || initLength > _length) {
//如果初始化的位置不在当前数据片段内则清除当前全部数据
[((NSMutableData *)_data) replaceBytesInRange:NSMakeRange(0, _length) withBytes:NULL length:0];
self.length = 0;
return;
}
//清除指定offset之前的数据
[((NSMutableData *)_data) replaceBytesInRange:NSMakeRange(0, initLength) withBytes:NULL length:0];
self.length -= initLength;
}
这里我们所说的清除缓存并不是将所有数据清除并且将数据片段指向0,而是通过修改数据将offset之前的数据全部清除,只保留内存缓存中offset之后的连续数据。
当finishOffset比当前内存中的数据起始位置小或者大于结束位置时,如果我们不清除内存缓存直接将数据拼接在末尾,此时内存中的数据就发生了错误,举个例子:
比如说我们的数据为 “hello word” (c字符串) 一共是11个字节。
当前内存缓存如下:
location: 3
length: 1
end: 4
data: "l"
如果此时代理返回finishOffset为8。
我们不清除内存缓存直接拼接:
location: 3
length: 2
end: 5
data: "lo"
你觉得对吗?看起来好像是没毛病的是吧。其实是错觉。
那下一次推送的时候,由于我们提供的数据对象中当前数据片段为3-5,并不包含代理所需要的数据(其实是包含的,只不过我们的数据出现了错误),因此代理再次返回8:
location: 3
length: 3
end: 6
data: "loo"
...
如此循环几次,直到我们的end等于9为止,此时我们的内存缓存变成了这样:
location: 3
length: 6
end: 9
data: "looooo"
Excuse me? 我们的原始数据中好像并没有 "looooo"。
所以我们需要对缓存进行清除。
清除之前:
location: 3
length: 1
end: 4
data: "l"
清除之后会变成这样:
location: 8
length: 0
end: 8
data: ""
此时我们将读取到的数据放入缓存:
location: 8
length: 1
end: 9
data: "o"
Are you ok?
ok ok !
到这里我们的主要流程就已经完成了,先按耐住你躁动的内心,千万不要command+R,你会在手机屏幕里看到你最爱的人...
以上我们已经完成了使用本地缓存对播放器进行数据填充,接下来我们着手进行网络数据下载并缓存。
数据下载逻辑
什么情况下需要下载数据呢?
根据我们的设计,只有当本地没有所需的数据时才需要开启下载,并且将下载的数据缓存至本地。
也就是说当我们的dataManager执行 - (void)see_prepareData:(long long)startOffset 时如果
- (void)see_prepareData:(long long)startOffset {
//如果当前访问的文件中包含后续数据则在当前文件中读取
if (_inputFile && _inputFile.endOffset > startOffset && _inputFile.startOffset <= startOffset) {
[self see_prepareDataFormCache:startOffset];
return;
}
//如果当前访问的文件中不包含后续数据则重新查找对应文件
_inputFile = [_fileInfo fileForOffset:startOffset];
if (_inputFile){
//打开对应的流
[_inputStream setFileAtPath:[_cacheBasePath stringByAppendingPathComponent:_inputFile.path]];
[self see_prepareDataFormCache:startOffset];
return;
}
//如果代码执行到这里说明缓存中不包含所需数据则开启下载任务
[self see_prepareDataFromNetwork:startOffset];
}
代码执行到了最后一行,说明本地没有所需的数据,这时我们就需要发出从startOffset开始的网络请求来下载对应的数据。
既然要下载数据,那么Range要设置为多少才能保证某一数据片段不会被重复下载呢?
"bytes = startOffset - " 这样是显然不行的,如果在startOffset之后有已经缓存过的数据片段,那么这部分数据将会被重复下载。
我们可能需要确定一个准确的范围来进行数据下载,以保证既能够得到想要的数据而又不会对数据进行重复下载。
下面的例子就是我们的Range确定方案:
每一次我们都只下载从startOffset开始,寻找本地缓存文件中数据起始位置值比startOffset大的最小值作为下载结束位置进行下载,即下载所需最小数据片段。这样既保证了数据的正确性,并且防止了数据重复下载。
下载范围结束值确定
既然我们的结束只是基于已缓存的数据范围进行查找的,那么毫无疑问这里我们需要借助_fileInfo的帮忙才能完成。
为什么?
你难道忘记了,_fileInfo管理着我们的缓存文件信息,不问他问谁。
仔细观察 - (void)see_prepareData:(long long)startOffset 这个方法的实现,我们会发现每当下载数据之前_fileInfo一定会使用startOffset查找一次缓存文件,我们可以在查找过程中顺便将这个值也一并查找出来。所以_fileInfo查找文件的方法被修改成了这样:
- (SEEFile *)fileForOffset:(long long)offset {
__block SEEFile * file = nil;
self.missingEndOffset = self.fileAttribute.totalBytes;
[self.files enumerateObjectsUsingBlock:^(SEEFile * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
if (obj.startOffset <= offset && obj.endOffset > offset) {
file = obj;
*stop = YES;
}
//查找下载结束位置
if (obj.startOffset > offset && obj.startOffset < self.missingEndOffset) {
self.missingEndOffset = obj.startOffset - 1;
}
}];
return file;
}
这样每当我们需要下载数据的时候_fileInfo就已经为我们准备好了下载的结束位置值。
downloader
说到网络请求,可供我们选择的方案就很多了,除了直接使用NSURLConnection或者NSURLSession之外还可以使用很多已经封装好的第三方网络框架。请随意选择。
downloader需要提供以下功能:
- 下载指定范围数据。
- 请求状态回调
- 数据回调。
- 没了...
本文中使用的是NSURLSession,由于没有什么太大的技术含量,我就直接贴代码了。
//SEEDownloader.h
#import
NS_ASSUME_NONNULL_BEGIN
@protocol SEEDownloaderDelegate
@optional
/**
接收到响应
@param response 响应体对象
*/
- (void)didReceiveResponse:(NSURLResponse *)response;
/**
传输完成
@param error 错误信息,如果传输正常完成该项为nil
*/
- (void)didCompleteWithError:(NSError * _Nullable)error;
/**
接收数据
@param data 数据
*/
- (void)didreceiveData:(NSData *)data;
@end
@interface SEEDownloader : NSObject
@property (nonatomic, assign) long long startOffset;
@property (nonatomic, assign) long long currentOffset;
@property (nonatomic, assign) long long endOffset;
//初始化
- (instancetype)initWithURL:(NSURL *)url delegate:(id)delegate;
/**
从 offset 位置开始重新请求文件
@param offset 起始 offset
*/
- (void)resetWithStartOffset:(long long)offset endOffset:(long long)endOffset;
//取消下载,清除资源
- (void)invalidateAndCancel;
@end
NS_ASSUME_NONNULL_END
//SEEDownloader.m
#import "SEEDownloader.h"
#import "SEEPlayerMacro.h"
@interface SEEDownloader ()
@end
@implementation SEEDownloader {
NSURLSession * _session;
NSURL * _url;
__weak id _delegate;
struct {
char didReceiveResponse;
char didCompleteWithError;
char didReceiveData;
}_responder;
NSString * _headerRange;
}
- (instancetype)initWithURL:(NSURL *)url delegate:(nonnull id)delegate {
if (self = [super init]) {
_startOffset = -1;
_endOffset = -1;
_currentOffset = -1;
_url = url;
[self setDelegate:delegate];
}
return self;
}
#pragma mark public method
/**
从指定offset开始下载
@param startOffset offset
*/
- (void)resetWithStartOffset:(long long)startOffset endOffset:(long long)endOffset {
/* 需要开启下载的情况
1. 请求起始位置不再当前下载范围之内
2. 请求起始位置在当前下载的范围内并且比当前下载到的位置大300K 以上
*/
if (startOffset >= _startOffset && startOffset <= _endOffset){
if (startOffset <= _currentOffset + 307200) {
return;
}
}
self.startOffset = startOffset;
self.currentOffset = startOffset;
self.endOffset = endOffset;
[self see_downloadStart];
}
- (void)invalidateAndCancel {
if (_session) {
[_session invalidateAndCancel];
_session = nil;
}
}
#pragma mark private method
- (void)see_downloadStart {
NSMutableURLRequest * request = [NSMutableURLRequest requestWithURL:_url];
if (self.endOffset == 0) _headerRange = [NSString stringWithFormat:@"bytes=%lld-",self.startOffset];
else _headerRange = [NSString stringWithFormat:@"bytes=%lld-%lld",self.startOffset,self.endOffset];
[request setValue:_headerRange forHTTPHeaderField:@"Range"];
[self invalidateAndCancel];
NSURLSessionConfiguration * configuration = [NSURLSessionConfiguration defaultSessionConfiguration];
_session = [NSURLSession sessionWithConfiguration:configuration delegate:self delegateQueue:[NSOperationQueue currentQueue]];
NSURLSessionDataTask * task = [_session dataTaskWithRequest:request];
[task resume];
}
#pragma mark delegate
- (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask
didReceiveResponse:(NSURLResponse *)response
completionHandler:(void (^)(NSURLSessionResponseDisposition disposition))completionHandler {
if (self.endOffset == 0) {
self.endOffset = response.expectedContentLength - 1;
}
if (_responder.didReceiveResponse) {
[_delegate didReceiveResponse:response];
}
completionHandler(NSURLSessionResponseAllow);
}
- (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask
didReceiveData:(NSData *)data {
_currentOffset += data.length;
if (_responder.didReceiveData) {
[_delegate didreceiveData:data];
}
}
- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didCompleteWithError:(nullable NSError *)error {
if (_responder.didCompleteWithError) {
[_delegate didCompleteWithError:error];
}
//每次下载完成重新调整结束位置,防止由于网络问题导致下载结束后重新请求同段数据时通不过- (void)resetWithStartOffset:(long long)startOffset endOffset:(long long)endOffset 方法检测。
if ([task.originalRequest.allHTTPHeaderFields[@"Range"] isEqualToString:_headerRange]) {
_endOffset = _currentOffset - 1;
}
}
#pragma mark getter & setter
- (void)setDelegate:(id )delegate {
_delegate = delegate;
//_responder
_responder.didReceiveResponse = [delegate respondsToSelector:@selector(didReceiveResponse:)];
_responder.didCompleteWithError = [delegate respondsToSelector:@selector(didCompleteWithError:)];
_responder.didReceiveData = [delegate respondsToSelector:@selector(didreceiveData:)];
}
@end
这里我们唯一需要注意的就是当我们的下载由于网络异常而终止时我们需要将_endOffset修改为当前已经下载完成的数据的末尾,使其处于假完成状态,这样当网络恢复之后,dataManager检测到磁盘没有数据就会再次发出从中断位置开始的下载请求,而此时,我们已经将当前下载器手动结束,- (void)resetWithStartOffset:(long long)startOffset endOffset:(long long)endOffset 可以正常执行。
当然如果大家有什么更好的方式,请通过各种渠道告诉我,谢过!
本地缓存文件创建以及数据写入
我们已经成功的将数据进行了下载,并且通过代理回调给了dataManager,接下来dataManager接收到数据之后就需要对数据进行存储。
我们的数据是存储在多个文件中的,因此我们需要在接收到数据之前将输出流创建完成,所以在dataManager接收到由downloader回调回来的响应时进行输出流的创建。
- (void)didReceiveResponse:(NSURLResponse *)response {
if (self.MIMEType == nil) {
self.MIMEType = response.MIMEType;
}
if (self.totalBytes == 0) {
self.totalBytes = response.expectedContentLength;
}
long long startOffset = _downloader.startOffset;
_fileInfo.fileAttribute.exceptFileName = response.suggestedFilename;
//关闭当前输出文件
[self see_closeCurrentOutputFile];
//初始化输出流
[self see_initOutputFile:startOffset];
}
首先我们将当前视频的基本信息存储起来,之后如果当前有正在写入的文件将其关闭并且创建一个新的输出流。
为了使缓存文件数量尽可能的少,我们选择在创建新的输出流之前首先检查新的数据是否可以拼接在某一个现有的缓存文件之后,如果没有再创建新的文件:
/**
1. 当目前文件中有在offset处结尾的文件,则将下载的文件拼接在该文件之后
2. 如果当前文件中没有在offset处结尾的文件,则新建文件存储
@param offset 数据起始位置
*/
- (void)see_initOutputFile:(long long)offset {
_outputFile = [_fileInfo acceptableFileForDownloadOffset:offset];
if (_outputFile == nil){
_outputFile = [[SEEFile alloc]init];
_outputFile.startOffset = offset;
NSString * path = [self see_pathForOffset:offset];
_outputFile.path = [path lastPathComponent];
[_fileInfo.files addObject:_outputFile];
[_cacheRanges addObject:[NSValue valueWithRange:NSMakeRange((NSUInteger)offset, (NSUInteger)offset)]];
}
_outputStream = [NSOutputStream outputStreamToFileAtPath:[_cacheBasePath stringByAppendingPathComponent:_outputFile.path] append:YES];
[_outputStream open];
}
之后我们在接收到数据之后只需要通过输出流写入对应的文件即可:
- (void)didreceiveData:(NSData *)data {
if (_outputFile) {
_outputFile.length += data.length;
NSInteger index = [_fileInfo.files indexOfObject:_outputFile];
[_cacheRanges replaceObjectAtIndex:index withObject:[NSValue valueWithRange:NSMakeRange((NSUInteger) _outputFile.startOffset, (NSUInteger)_outputFile.endOffset)]];
}
if (_outputStream)[_outputStream write:data.bytes maxLength:data.length];
}
当下一次推送数据时就可以从缓存中读出对应的数据了。
三、Demo
关于边下边播功能到这里就全部结束了,至于播放、暂停、进度条等等等等这些有机会会写在另一篇文章中,如果有我会在下面贴出相关文章。
Demo中已经有部分播放、暂停、进度、缓存进度条等功能,大家可以自行参考。
SEEAssetsPlayer
另外,虽然可能大概也许文章中有些许漏洞,后续发现问题会及时更新,还请大家尊重原创。