1、概要
流媒体开发第一篇文章就说要把这些不是随便就可以百度到的知识献给“简书”,拖了一个多月了,总算弄完了,深深松了口气,万幸没有食言,否则对不起小伙伴们。
流媒体始终是大众生活娱乐最为重要的一个部分,同时也是技术开发中比较有难度的,尤其是直播,不仅功能是点播无法替代的,开发难度也要比点播大,里约奥运会等重大体育赛事大家只能通过直播观看比赛,体会现场观看的紧张和刺激,点播是无法做到的。 如今我们也会有直播回看和下载的需求,一些APP包括我们自己的项目也已经实现了这些功能,网上讲解这部分技术的知识相对较少,而且有很多都不是很靠谱,我这里抛砖引玉,给大家提供一种思路,仅供参考。所以建议大家理解我的思路,尽量不要直接拿来用在项目里,后面我会详细讲解有哪些地方在应用到项目中需要额外的处理。
注意:
1、本文不适合初级iOS开发者,需要有一定的开发经验,和对流媒体技术的基本概念和开发技术的了解,例如本文不会讲解什么是TS、AAC和M3U8等概念,这些知识网上很多,大家可以自行查阅理解,这里就赘述了;
2、直播的回看和下载相对于音视频的播放开发难度要大一些,数据处理的思路也比较复杂,所以为了大家能更快的理解和接受,本文着重核心功能的讲解,以免过多的代码对理解产生干扰,比如我们拿到一个M3U8链接,我们要判断这个链接是否是http或者https的,其次要去除链接中的空白字符,注意空白字符不一定是空格,还有可能是回车、TAB等其他的空白字符,处理起来也比较繁琐,本文不对这些做过多处理,默认M3U8链接是有效的,小伙伴们在实际项目中要对这些地方做处理,避免因此出现bug;
3、鉴于HLS直播的回看和下载网上可参考的资料太少,如果观看本文的小伙伴有更好的实现方案,欢迎留言,对本文的实现方案提出建议,感激不尽。
2、回看
HLS直播的回看功能有2种实现方案,2种方案都需要借助服务器。
1、第一种方案是服务器将实时获取的TS(AAC音频处理流程一样,后面不赘述)文件片段存储到指定的路径下,当客户端请求某一时间段的回看节目时,服务器取出相对应的TS,打包这些TS片段生成.M3U8索引文件和播放链接,返回给客户端,这是客户端拿到的播放链接和直播的链接是一样的,播放的处理流程也是一样的,只不过这时的直播只能播放一段时间。
2、第二种方案是服务器将制定节目的直播内容使用FFMPEG转码成MP4和3GP等点播源,生成播放连接返回给客户端播放就可以了。 注意: 由于回看要借助服务器实现,这里就不附上实现的代码了,客户端的实现比较简单,拿到播放源直接播放就可以了,后面要讲的下载和回看的第一种方案是一样的,都是将TS片段下载下来,可以参考后面的内容。
3、两中方案的优缺点分析: ①第一种方案对于服务器来说处理比较简单,只需要将TS存储并打包即可。对于客户端来说播放很简单,同时HLS的传输效率也要更高一些,播放速度会很快,但是涉及到调整视频进度、截取视频某一帧图片,监听视频播放状态这些就比较麻烦了。回看的内容虽然也是直播的内容,但是在用户看来无所谓点播和直播,这些已经是播放过的节目,自然可以调整进度。这里给出一种调整进度的方案,根据客户端的时间戳向服务器获取相应的TS片段。例如下面这个链接:
self.playerUrl = @"http://cctv2.vtime.cntv.wscdns.com:8000/live/no/204_/seg0/index.m3u8?begintime=1469509516000";
这个链接有一个参数:begintime,从命名我们可以看出是要传输一个播放源从哪里开始播放的时间戳,服务器拿到这个参数后会生成对应的数据返回给客户端播放,这里就可以实现精准的进度控制了。 ②第二种方案对于服务器来说要繁琐些,多了一步制作点播源的步骤。对于客户端,第二种方案的好处是直接拿到的是点播的播放源,无论是进度调整、获取帧率图和播放状态的控制都很简单,虽然播放速度相对与HLS来说会慢一点,但影响并不大。同时由于服务器已经将每一个节目转码成功,如果用户要下载这些节目观看,客户端的实现也比较简单。这种方案的缺点是不够灵活,用户只能以节目为时间单位进行回看,无法像第一种方案一样,以时间戳为单位回看,精细度不够。 总结 两种回看方案并没有优略之分,具体采用哪一种,要看具体项目的需求,小伙伴们在开发过程中要注意和服务器的联调测试,尤其是第一种方案,M3U8的各种tag设置的不准确也会造成各种播放错误,并没有那么容易实现,当然服务器那边也会有一些第三方库可以直接用,所以对于有些开发经验的服务器工程师还是比较容易实现的。
3、下载
下载的流程比较复杂,为了让小伙伴更容易理解,我不会按照我的代码一步步讲解,这样只会让人头晕脑胀,意义不大。我这里按照我在学习新知识时比较容易理解知识的经验来讲解。 我们在学习时,如果只是拿来别人的代码一行行看,遇到不会的查阅,然后再下面的,没一会就头晕了,相信大家都有过这种经验,效果非常差,而且作者在写这些代码的时候并不是逐字逐行的写的,而是一次次优化改动得来的,通过代码我们很难明白作者写代码的逻辑和心路历程,自控力强的多看几遍屡清楚思路能看明白,自控力稍差的可能就放弃了,下面讲解下我的讲解思路和学习方法。
3.1 学习思路
①首先我会说明HLS下载的实现思路,小伙伴们在看这部分的时候不要把自己当成技术人员,各行各业最有价值的都是解决问题的思想和能力,而不是代码、文字和各种工具等,所以我尽量让一个没有任何开发技术的人明白HLS下载的逻辑,明白了解决问题的逻辑,再看后面的代码就不至于晕头转向了;
②其次我会按照流程逐步讲解,在讲解每一步流程时,每一步也是一个相对独立的子流程,我也会大概的描述下每一步子流程的实现思路,小伙伴们理解起来也会更加简单;
③最后说下小伙伴们在阅读时的一些注意事项。在对核心功能还没有充分理解的前提下,不要太在意一些技术细节,比如这里为什么调用这个方法、这样做性能不太高等等和核心功能无关的。等小伙伴们对核心功能理解了,再来优化和理解一些小的地方,才会得心应手。由于我们写这些代码的时候考虑的也不是很健全,所有会有很多地方写得不完美,也欢迎小伙伴们留言指出来,绝对知错就改,感激不尽。
3.2 实现思路
实现思路可以分为4大步:解码、下载、打包、播放。 解码:拿到一个M3U8链接后解析出M3U8索引的具体内容,包括每一个TS的下载链接、时长等; 下载:拿到每一个TS文件的链接就可以逐个下载了,下载后存储到手机里; 打包:将下载的TS数据按照播放顺序打包,供客户端播放; 播放:数据打包完成,就可以播放了。 说明: 1、本文借鉴了iOS端M3U8第三方库的处理流程,由于这个第三方库长时间没有维护和更新,并且采用了ASI作为网络请求,直接采用会给项目带来大量的警告和错误,还会导致无法适配各种架构等问题,处理起来很是繁琐和棘手,并且即使配置成功,也是无法直接使用的,还是需要改动第三方库的很多地方,所以我这里模仿M3U8库的部分处理逻辑,同时网络请求使用AFN,当然这里建议大家对AFN做一层封装后再使用,避免AFN升级换代带来不必要的麻烦。 2、本文封装了一个名为“ZYLDecodeTool”的工具类,负责调度每一步。
HLS下载流程
3.3 解码
解码这一步就做一件事情,拿到播放链接,读取M3U8索引文件,解析出每一个TS文件的下载地址和时长,封装到Model中,供后面使用。 解码器ZYLM3U8Handler.h文件
#import <Foundation/Foundation.h>
#import "M3U8Playlist.h"
@class ZYLM3U8Handler;
@protocol ZYLM3U8HandlerDelegate <NSObject>
/**
* 解析M3U8连接失败
*/
- (void)praseM3U8Finished:(ZYLM3U8Handler *)handler;
/**
* 解析M3U8成功
*/
- (void)praseM3U8Failed:(ZYLM3U8Handler *)handler;
@end
@interface ZYLM3U8Handler : NSObject
/**
* 解码M3U8
*/
- (void)praseUrl:(NSString *)urlStr;
/**
* 传输成功或者失败的代理
*/
@property (weak, nonatomic)id <ZYLM3U8HandlerDelegate> delegate;
/**
* 存储TS片段的数组
*/
@property (strong, nonatomic) NSMutableArray *segmentArray;
/**
* 打包获取的TS片段
*/
@property (strong, nonatomic) M3U8Playlist *playList;
/**
* 存储原始的M3U8数据
*/
@property (copy, nonatomic) NSString *oriM3U8Str;
@end
ZYLM3U8Handler.m文件
#import "ZYLM3U8Handler.h"
#import "M3U8SegmentModel.h"
@implementation ZYLM3U8Handler
#pragma mark - 解析M3U8链接
- (void)praseUrl:(NSString *)urlStr {
//判断是否是HTTP连接
if (!([urlStr hasPrefix:@"http://"] || [urlStr hasPrefix:@"https://"])) {
if (self.delegate != nil && [self.delegate respondsToSelector:@selector(praseM3U8Failed:)]) {
[self.delegate praseM3U8Failed:self];
}
return;
}
//解析出M3U8
NSError *error = nil;
NSStringEncoding encoding;
NSString *m3u8Str = [[NSString alloc] initWithContentsOfURL:[NSURL URLWithString:urlStr] usedEncoding:&encoding error:&error];//这一步是耗时操作,要在子线程中进行
self.oriM3U8Str = m3u8Str;
/*注意1、请看代码下方注意1*/
if (m3u8Str == nil) {
if (self.delegate != nil && [self.delegate respondsToSelector:@selector(praseM3U8Failed:)]) {
[self.delegate praseM3U8Failed:self];
}
return;
}
//解析TS文件
NSRange segmentRange = [m3u8Str rangeOfString:@"#EXTINF:"];
if (segmentRange.location == NSNotFound) {
//M3U8里没有TS文件
if (self.delegate != nil && [self.delegate respondsToSelector:@selector(praseM3U8Failed:)]) {
[self.delegate praseM3U8Failed:self];
}
return;
}
if (self.segmentArray.count > 0) {
[self.segmentArray removeAllObjects];
}
//逐个解析TS文件,并存储
while (segmentRange.location != NSNotFound) {
//声明一个model存储TS文件链接和时长的model
M3U8SegmentModel *model = [[M3U8SegmentModel alloc] init];
//读取TS片段时长
NSRange commaRange = [m3u8Str rangeOfString:@","];
NSString* value = [m3u8Str substringWithRange:NSMakeRange(segmentRange.location + [@"#EXTINF:" length], commaRange.location -(segmentRange.location + [@"#EXTINF:" length]))];
model.duration = [value integerValue];