对于移动开发来说,省流量是必须的。前三篇讲了用AudioQueue来编写流媒体播放,但是缓存的重要不言而喻。有网络的时候可以使用网络播放,没网络的时候就可以利用缓存播放。实现离线播放。本次的实现是当有缓存的时候使用缓存播放,没有缓存的时候才联网去请求数据。
缓存系统的设计
当时在考虑这个的时候,我一直想以一种简单的方式来实现,从当初的构思到实现,总共经过三次变化。
第一次设计
在我看来,音频缓存的难点应该是在于Seek操作的时候,因为中间都是空的,在Linux中,称做“空洞文件”。怎么样才能对应起来呢?我的想法是从http的响应头中,拿到整个文件的长度,然后在硬盘中创建一个同等大小的文件,文件内容设置为空,然后再配置一个描述文件,用于描述该文件已经缓存好的字节段,如果以后再有缓存好的字节段,再进行合并。最终描述文件中只剩一个段,即0到fileLen[文件长度]。
第二次设计
第一次设计想法我觉得是可以的,但是实现有点难,于是我在想,能不能把这个描述文件去掉,利用文件本身来描述自己哪些部分已经下载了,哪些部分未下载呢?我试过使用一个0或者EOF(-1)来描述,基本功能都已经实现了,但是我却忽略了一个问题,我是以二进制的方法读入文件的,文件本身也包含EOF(-1),看来这种想法也行不通。看来还是需要一个描述文件。
第三次设计
既然以我目前的设计,依旧无法省略描述文件,也不能利用文件结束符,那么我可以简化描述文件的结构,并由此定义描述文件(desc),一个和音频文件等长的描述文件,用于记录音频缓存中对应字节的数据是否已经下载成功,如果下载成功,则将该们置置为1,初始为0,这样做的好处是编码简单,但是缺点就是音频文件有多大,就增加了一个与音频等长的描述文件。可以说是以空间换复杂度吧。根据该设计,最终完成了一个基本的缓存系统。
缓存系统的实现
缓存文件的命名
首先,缓存文件当然是存放在硬盘上的,在这里,我使用一般的常规方法,即利用url的md5字符串来做文件名,最终在缓存目录中会生成两个文件,即音频文件和描述文件,如图所示:
相关代码如下所示:
这段代码利用了iOS自带的CommonCrypto中的CC_MD5方法进行加密。。如果url中带有文件的后缀名,那么就从url上截取,如果没有,那么默认为mp3。如下所示:
但是有个问题,即使url中没有后缀,但是仍可以以.*结尾,所以我这里就自定义了几个常见的后缀。
init方法里只是计算了文件名,并没有创建文件,应该什么时候创建音频文件和描述文件呢?可以试想想,如果没有网络请求的时候,缓存文件也根本用不着,而且没有网络请求,也根本无法知道文件到底有多长。所以缓存文件的建立或打开,应该放到第一次网络请求之后。建立的过程如下所示:
从代码中可以看到,如果缓存文件不存在的话,我们就建立缓存文件,但若是描述文件存在,而缓存文件不存在,说明该描述文件与其他的缓存文件并不匹配,因此删除该描述文件,重新建立。并打开该描述文件以备读写。描述文件中的文件指针最终应始终与缓存文件中的文件指针一致。
播放器的初始化
首先定义了一个枚举,用于标示,是本地播放还是网络播放
typedef NS_ENUM(NSUInteger, FeaturedAudioStreamerPlayMode) {
FeaturedAudioStreamerPlayModeLocal,
FeaturedAudioStreamerPlayModeStream,
};
对于本地文件来说,URL是以file://开头,即Scheme为file
#define kFeaturedAudioStreamerLocalFileScheme @"file"
初始化方法修改如下:
如果是本地文件,不需要缓存,如果是网络文件且开启了缓存,那么启动缓存系统,并将播放模式设置为本地播放模式。这样即优先从本地或缓存文件开始读取。
计算可读字节
由于描述文件仅用于描述该字节是否已经被缓存,因此,只能向其中写入1,而不能写入0,那么我们怎么知道一个缓冲区大小的区间内有多少字节被成功缓存呢?这就需要去查描述文件,如下所示:
如上代码,我们最多读取kSizeOfAudioQueueBuffer大小的字节出来。读取数据之后,就可以像之前已经完成的本地文件播放功能一样,去解析该段数据。
标记已缓存区间
如果从服务器收到数据之后,我们应该通过当前文件的偏移来标记这些字节区间已经被缓存,所以有如下代码:
其中,bytes表示期望标记的共有多少个字节,offset表示文件偏移量。这样一来,最终的描述文件内容如下所示:
其中,为零的区域是没有数据的,为1的位置是已经有缓存的数据,这样,在播放的时候,先查找描述文件,如果数据已经缓存,就使用缓存数据,没有再请求。
音频播放
在音频播放的时候,网络播放的操作还是一样的,构建请求,设置偏移Range,发送请求,接收数据,解析数据。但是在本地播放的时候就有点不一样,因为此时要确定是否有缓存数据,并且当没有缓存数据的时候的偏移量:
从代码中可以看出来,如果启用了缓存,则打开缓存文件以供读写,若打开失败且启用了缓存,那么说明数据并没有被缓存或没有缓存文件,此时就应该切换到网络播放模式(FeaturedAudioStreamerPlayModeStream),否则说明本地文件打开失败。
成功打开文件之后,我们需要查询描述文件以清楚有多少个字节可供读取,然后在缓存文件中读取相应的数据(读取数据之后,缓存文件的文件指针和描述文件的文件指针就一致了),如果读到的数据为0且确定已经读到文件末尾,那么此时应该使用AudioQueueFlush清洗AudioQueue,确保所以数据已被播放,清理所用资源。否则,利用Seek的方法设置偏移量。然后切换到网络播放模式(FeaturedAudioStreamerPlayModeStream),如果数据读取没有问题,那么照常解析数据,照常播放。
Seek操作
当执行Seek操作的时候,我们无法清楚知道Seek的位置是否有已缓存数据,所以在设置Seek所需要的相关信息之后,应该将播放模式切换为本地播放模式(FeaturedAudioStreamerPlayModeLocal),将数据的获取方式推迟到播放的时候去确定。
清理缓存
缓存数据应该可以被清理,否则只会越积越大,用尽磁盘空间,何况用这种方式来做缓存,所占空间更大于一倍,其实这种方法还有改进的地方,就是当数据全部下载完成之后,将描述文件删除,在建立缓存文件的时候,如果发现缓存文件已经存在,而没有描述文件,则可说明数据已经全部缓存成功,此时不需要再查询描述文件,而直接转换为本地播放。那么如何知道数据已经全部下载完成呢?我想,可以在描述文件的结尾多加一个unsigned long long,用于记录已下载的文件是否等于文件长度。这样一来,就可以在最后释放被描述文件占用的硬盘空间,达到节省资源的作用。
删除缓存文件应该有两个方法,一个用于删除缓存,一个用于清空缓存。两个都很简单,删除缓存只要判断缓存文件和描述文件是否存在于缓存目录,如果存在,则删除之。清空缓存更加简单,如果存在缓存目录,则将整个目录移除即可。而所有的缓存文件均位于以下位置:
#define kAudioFileCacheDirectory [[NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES) objectAtIndex:0] stringByAppendingPathComponent:@"Audio"]
两个方法的代码如下所示,很简单:
流程图
整个FeaturedAudioStreamer的流程图构思如下所示,由于本人对于流程图知之甚少,以下展示的流程图可能有所错漏,如有发现,请批评指正。
示例程序
本例实现一个基本的音频播放器,包括网络音频的播放和本地音频的播放,如果要播放网络音乐,请自行更换地址。首先定义一个宏,利用GCD使代码块保证在主线程中执行(不管有没有用,都可以)
播放和暂停
播放和暂停,在FeaturedAudioStreamer中都是使用的play方法,代码如下所示:
播放的时候,首先更换图片,创建或启动UI更新定时器。然后调用play方法来启动或者暂停播放。
更新定时器
更新定时器用于定时更新UI,如左侧标签,右侧标签,进度条等,代码如下:
前进和后退
前进和后退,在本例中设置了5秒,操作步骤应该是先停止定时器,然后执行Seek操作,再启动定时器。
进度条拖动
当进度条拖动的时候,操作和前进、后退一样。但是为了效果,在拖动的同时,还需要同步更新左侧时间标签的值,这样,就应该添加三个事件:UIControlEventTouchDown,UIControlEventValueChanged,UIControlEventTouchUpInside。
UIControlEventTouchDown:按下Thumb,准备拖动,此时应停止定时器
UIControlEventValueChanged:开始拖动,此时应同步更新时间标签
UIControlEventTouchUpInside:结束拖动,此时执行Seek操作,并重新启动定时器
状态变化通知
当播放器的状态变化的时候,可以收到通知,所以首先应该注册消息监听
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(stateChanged:) name:FeaturedAudioStreamerStateChangedNotification object:nil];
然后,在stateChanged:方法中来监听各状态的变化,执行相应的动作,如下所示:
退出播放
在退出的时候,应该清理资源,比如说释放播放器的资源,停止定时器,取消通知注册等,如下所示:
效果演示
最终总结
FeaturedAudioStreamer的起因是由于公司的项目有音频方面的需求,当时在做项目的时候,流媒体使用的是AudioStreamer库,而后来又添加了本地播放的功能,本以为AudioStreamer会同时支持本地播放,但实际上却并没有,于是才有了自己学习AudioQueue,自己编写一个可以使用的简单的音频播放库出来,但是由于自己水平所限,并未能写出一个高效的,完全正确的东西出来,如果哪位大神看到此文,觉得此文马虎,拙劣的话,请不要吐口水,毕竟水平有高低之别,在此感谢。
代码链接
最后在此附上FeaturedAudioStreamer,同时在此感谢AudioStreamer的作者Matt Gallagher先生,其实代码都大同小异,不过这里面的代码都是我一字一字编写出来的,虽然与AudioStreamer类似,却并不是直接抄袭。到这里,一个基本的带缓存的包括本地播放、网络流媒体播放功能的播放器,基本就已经实现了。
代码链接地址