尊重知識(shí),轉(zhuǎn)發(fā)請(qǐng)注明出處:iOS流媒體開(kāi)發(fā)之三:HLS直播(M3U8)回看和下載功能的實(shí)現(xiàn)
概要
流媒體開(kāi)發(fā)第一篇文章就說(shuō)要把這些不是隨便就可以百度到的知識(shí)獻(xiàn)給“簡(jiǎn)書(shū)”,拖了一個(gè)多月了,總算弄完了,深深松了口氣,萬(wàn)幸沒(méi)有食言,否則對(duì)不起小伙伴們。
流媒體始終是大眾生活?yuàn)蕵?lè)最為重要的一個(gè)部分,同時(shí)也是技術(shù)開(kāi)發(fā)中比較有難度的,尤其是直播,不僅功能是點(diǎn)播無(wú)法替代的,開(kāi)發(fā)難度也要比點(diǎn)播大,里約奧運(yùn)會(huì)等重大體育賽事大家只能通過(guò)直播觀看比賽,體會(huì)現(xiàn)場(chǎng)觀看的緊張和刺激,點(diǎn)播是無(wú)法做到的。
如今我們也會(huì)有直播回看和下載的需求,一些APP包括我們自己的項(xiàng)目也已經(jīng)實(shí)現(xiàn)了這些功能,網(wǎng)上講解這部分技術(shù)的知識(shí)相對(duì)較少,而且有很多都不是很靠譜,我這里拋磚引玉,給大家提供一種思路,僅供參考。所以建議大家理解我的思路,盡量不要直接拿來(lái)用在項(xiàng)目里,后面我會(huì)詳細(xì)講解有哪些地方在應(yīng)用到項(xiàng)目中需要額外的處理。
注意: 1、本文不適合初級(jí)iOS開(kāi)發(fā)者,需要有一定的開(kāi)發(fā)經(jīng)驗(yàn),和對(duì)流媒體技術(shù)的基本概念和開(kāi)發(fā)技術(shù)的了解,例如本文不會(huì)講解什么是TS、AAC和M3U8等概念,這些知識(shí)網(wǎng)上很多,大家可以自行查閱理解,這里就贅述了; 2、直播的回看和下載相對(duì)于音視頻的播放開(kāi)發(fā)難度要大一些,數(shù)據(jù)處理的思路也比較復(fù)雜,所以為了大家能更快的理解和接受,本文著重核心功能的講解,以免過(guò)多的代碼對(duì)理解產(chǎn)生干擾,比如我們拿到一個(gè)M3U8鏈接,我們要判斷這個(gè)鏈接是否是http或者h(yuǎn)ttps的,其次要去除鏈接中的空白字符,注意空白字符不一定是空格,還有可能是回車、TAB等其他的空白字符,處理起來(lái)也比較繁瑣,本文不對(duì)這些做過(guò)多處理,默認(rèn)M3U8鏈接是有效的,小伙伴們?cè)趯?shí)際項(xiàng)目中要對(duì)這些地方做處理,避免因此出現(xiàn)bug; 3、鑒于HLS直播的回看和下載網(wǎng)上可參考的資料太少,如果觀看本文的小伙伴有更好的實(shí)現(xiàn)方案,歡迎留言,對(duì)本文的實(shí)現(xiàn)方案提出建議,感激不盡。
回看
HLS直播的回看功能有2種實(shí)現(xiàn)方案,2種方案都需要借助服務(wù)器。
1、第一種方案是服務(wù)器將實(shí)時(shí)獲取的TS(AAC音頻處理流程一樣,后面不贅述)文件片段存儲(chǔ)到指定的路徑下,當(dāng)客戶端請(qǐng)求某一時(shí)間段的回看節(jié)目時(shí),服務(wù)器取出相對(duì)應(yīng)的TS,打包這些TS片段生成.M3U8索引文件和播放鏈接,返回給客戶端,這是客戶端拿到的播放鏈接和直播的鏈接是一樣的,播放的處理流程也是一樣的,只不過(guò)這時(shí)的直播只能播放一段時(shí)間。
2、第二種方案是服務(wù)器將制定節(jié)目的直播內(nèi)容使用FFMPEG轉(zhuǎn)碼成MP4和3GP等點(diǎn)播源,生成播放連接返回給客戶端播放就可以了。
注意: 由于回看要借助服務(wù)器實(shí)現(xiàn),這里就不附上實(shí)現(xiàn)的代碼了,客戶端的實(shí)現(xiàn)比較簡(jiǎn)單,拿到播放源直接播放就可以了,后面要講的下載和回看的第一種方案是一樣的,都是將TS片段下載下來(lái),可以參考后面的內(nèi)容。
3、兩中方案的優(yōu)缺點(diǎn)分析:
①第一種方案對(duì)于服務(wù)器來(lái)說(shuō)處理比較簡(jiǎn)單,只需要將TS存儲(chǔ)并打包即可。對(duì)于客戶端來(lái)說(shuō)播放很簡(jiǎn)單,同時(shí)HLS的傳輸效率也要更高一些,播放速度會(huì)很快,但是涉及到調(diào)整視頻進(jìn)度、截取視頻某一幀圖片,監(jiān)聽(tīng)視頻播放狀態(tài)這些就比較麻煩了?;乜吹膬?nèi)容雖然也是直播的內(nèi)容,但是在用戶看來(lái)無(wú)所謂點(diǎn)播和直播,這些已經(jīng)是播放過(guò)的節(jié)目,自然可以調(diào)整進(jìn)度。這里給出一種調(diào)整進(jìn)度的方案,根據(jù)客戶端的時(shí)間戳向服務(wù)器獲取相應(yīng)的TS片段。例如下面這個(gè)鏈接:
self.playerUrl = @"http://cctv2.vtime.cntv.wscdns.com:8000/live/no/204_/seg0/index.m3u8?begintime=1469509516000";
這個(gè)鏈接有一個(gè)參數(shù):begintime,從命名我們可以看出是要傳輸一個(gè)播放源從哪里開(kāi)始播放的時(shí)間戳,服務(wù)器拿到這個(gè)參數(shù)后會(huì)生成對(duì)應(yīng)的數(shù)據(jù)返回給客戶端播放,這里就可以實(shí)現(xiàn)精準(zhǔn)的進(jìn)度控制了。
②第二種方案對(duì)于服務(wù)器來(lái)說(shuō)要繁瑣些,多了一步制作點(diǎn)播源的步驟。對(duì)于客戶端,第二種方案的好處是直接拿到的是點(diǎn)播的播放源,無(wú)論是進(jìn)度調(diào)整、獲取幀率圖和播放狀態(tài)的控制都很簡(jiǎn)單,雖然播放速度相對(duì)與HLS來(lái)說(shuō)會(huì)慢一點(diǎn),但影響并不大。同時(shí)由于服務(wù)器已經(jīng)將每一個(gè)節(jié)目轉(zhuǎn)碼成功,如果用戶要下載這些節(jié)目觀看,客戶端的實(shí)現(xiàn)也比較簡(jiǎn)單。這種方案的缺點(diǎn)是不夠靈活,用戶只能以節(jié)目為時(shí)間單位進(jìn)行回看,無(wú)法像第一種方案一樣,以時(shí)間戳為單位回看,精細(xì)度不夠。
總結(jié) 兩種回看方案并沒(méi)有優(yōu)略之分,具體采用哪一種,要看具體項(xiàng)目的需求,小伙伴們?cè)陂_(kāi)發(fā)過(guò)程中要注意和服務(wù)器的聯(lián)調(diào)測(cè)試,尤其是第一種方案,M3U8的各種tag設(shè)置的不準(zhǔn)確也會(huì)造成各種播放錯(cuò)誤,并沒(méi)有那么容易實(shí)現(xiàn),當(dāng)然服務(wù)器那邊也會(huì)有一些第三方庫(kù)可以直接用,所以對(duì)于有些開(kāi)發(fā)經(jīng)驗(yàn)的服務(wù)器工程師還是比較容易實(shí)現(xiàn)的。
下載
下載的流程比較復(fù)雜,為了讓小伙伴更容易理解,我不會(huì)按照我的代碼一步步講解,這樣只會(huì)讓人頭暈?zāi)X脹,意義不大。我這里按照我在學(xué)習(xí)新知識(shí)時(shí)比較容易理解知識(shí)的經(jīng)驗(yàn)來(lái)講解。
我們?cè)趯W(xué)習(xí)時(shí),如果只是拿來(lái)別人的代碼一行行看,遇到不會(huì)的查閱,然后再下面的,沒(méi)一會(huì)就頭暈了,相信大家都有過(guò)這種經(jīng)驗(yàn),效果非常差,而且作者在寫(xiě)這些代碼的時(shí)候并不是逐字逐行的寫(xiě)的,而是一次次優(yōu)化改動(dòng)得來(lái)的,通過(guò)代碼我們很難明白作者寫(xiě)代碼的邏輯和心路歷程,自控力強(qiáng)的多看幾遍屢清楚思路能看明白,自控力稍差的可能就放棄了,下面講解下我的講解思路和學(xué)習(xí)方法。
*學(xué)習(xí)思路
①首先我會(huì)說(shuō)明HLS下載的實(shí)現(xiàn)思路,小伙伴們?cè)诳催@部分的時(shí)候不要把自己當(dāng)成技術(shù)人員,各行各業(yè)最有價(jià)值的都是解決問(wèn)題的思想和能力,而不是代碼、文字和各種工具等,所以我盡量讓一個(gè)沒(méi)有任何開(kāi)發(fā)技術(shù)的人明白HLS下載的邏輯,明白了解決問(wèn)題的邏輯,再看后面的代碼就不至于暈頭轉(zhuǎn)向了;
②其次我會(huì)按照流程逐步講解,在講解每一步流程時(shí),每一步也是一個(gè)相對(duì)獨(dú)立的子流程,我也會(huì)大概的描述下每一步子流程的實(shí)現(xiàn)思路,小伙伴們理解起來(lái)也會(huì)更加簡(jiǎn)單;
③最后說(shuō)下小伙伴們?cè)陂喿x時(shí)的一些注意事項(xiàng)。在對(duì)核心功能還沒(méi)有充分理解的前提下,不要太在意一些技術(shù)細(xì)節(jié),比如這里為什么調(diào)用這個(gè)方法、這樣做性能不太高等等和核心功能無(wú)關(guān)的。等小伙伴們對(duì)核心功能理解了,再來(lái)優(yōu)化和理解一些小的地方,才會(huì)得心應(yīng)手。由于我們寫(xiě)這些代碼的時(shí)候考慮的也不是很健全,所有會(huì)有很多地方寫(xiě)得不完美,也歡迎小伙伴們留言指出來(lái),絕對(duì)知錯(cuò)就改,感激不盡。
*實(shí)現(xiàn)思路
實(shí)現(xiàn)思路可以分為4大步:解碼、下載、打包、播放。
解碼:拿到一個(gè)M3U8鏈接后解析出M3U8索引的具體內(nèi)容,包括每一個(gè)TS的下載鏈接、時(shí)長(zhǎng)等;
下載:拿到每一個(gè)TS文件的鏈接就可以逐個(gè)下載了,下載后存儲(chǔ)到手機(jī)里;
打包:將下載的TS數(shù)據(jù)按照播放順序打包,供客戶端播放;
播放:數(shù)據(jù)打包完成,就可以播放了。
說(shuō)明: 1、本文借鑒了iOS端M3U8第三方庫(kù)的處理流程,由于這個(gè)第三方庫(kù)長(zhǎng)時(shí)間沒(méi)有維護(hù)和更新,并且采用了ASI作為網(wǎng)絡(luò)請(qǐng)求,直接采用會(huì)給項(xiàng)目帶來(lái)大量的警告和錯(cuò)誤,還會(huì)導(dǎo)致無(wú)法適配各種架構(gòu)等問(wèn)題,處理起來(lái)很是繁瑣和棘手,并且即使配置成功,也是無(wú)法直接使用的,還是需要改動(dòng)第三方庫(kù)的很多地方,所以我這里模仿M3U8庫(kù)的部分處理邏輯,同時(shí)網(wǎng)絡(luò)請(qǐng)求使用AFN,當(dāng)然這里建議大家對(duì)AFN做一層封裝后再使用,避免AFN升級(jí)換代帶來(lái)不必要的麻煩。 2、本文封裝了一個(gè)名為“ZYLDecodeTool”的工具類,負(fù)責(zé)調(diào)度每一步。

*解碼
解碼這一步就做一件事情,拿到播放鏈接,讀取M3U8索引文件,解析出每一個(gè)TS文件的下載地址和時(shí)長(zhǎng),封裝到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;
/**
* 存儲(chǔ)TS片段的數(shù)組
*/
@property (strong, nonatomic) NSMutableArray *segmentArray;
/**
* 打包獲取的TS片段
*/
@property (strong, nonatomic) M3U8Playlist *playList;
/**
* 存儲(chǔ)原始的M3U8數(shù)據(jù)
*/
@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];//這一步是耗時(shí)操作,要在子線程中進(jìn)行
self.oriM3U8Str = m3u8Str;
/*注意1、請(qǐng)看代碼下方注意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里沒(méi)有TS文件
if (self.delegate != nil && [self.delegate respondsToSelector:@selector(praseM3U8Failed:)]) {
[self.delegate praseM3U8Failed:self];
}
return;
}
if (self.segmentArray.count > 0) {
[self.segmentArray removeAllObjects];
}
//逐個(gè)解析TS文件,并存儲(chǔ)
while (segmentRange.location != NSNotFound) {
//聲明一個(gè)model存儲(chǔ)TS文件鏈接和時(shí)長(zhǎng)的model
M3U8SegmentModel *model = [[M3U8SegmentModel alloc] init];
//讀取TS片段時(shí)長(zhǎng)
NSRange commaRange = [m3u8Str rangeOfString:@","];
NSString* value = [m3u8Str substringWithRange:NSMakeRange(segmentRange.location + [@"#EXTINF:" length], commaRange.location -(segmentRange.location + [@"#EXTINF:" length]))];
model.duration = [value integerValue];
//截取M3U8
m3u8Str = [m3u8Str substringFromIndex:commaRange.location];
//獲取TS下載鏈接,這需要根據(jù)具體的M3U8獲取鏈接,可以根據(jù)自己公司的需求
NSRange linkRangeBegin = [m3u8Str rangeOfString:@","];
NSRange linkRangeEnd = [m3u8Str rangeOfString:@".ts"];
NSString* linkUrl = [m3u8Str substringWithRange:NSMakeRange(linkRangeBegin.location + 2, (linkRangeEnd.location + 3) - (linkRangeBegin.location + 2))];
model.locationUrl = linkUrl;
[self.segmentArray addObject:model];
m3u8Str = [m3u8Str substringFromIndex:(linkRangeEnd.location + 3)];
segmentRange = [m3u8Str rangeOfString:@"#EXTINF:"];
}
/*注意2、請(qǐng)看代碼下方注意2*/
//已經(jīng)獲取了所有TS片段,繼續(xù)打包數(shù)據(jù)
[self.playList initWithSegmentArray:self.segmentArray];
self.playList.uuid = @"moive1";
//到此數(shù)據(jù)TS解析成功,通過(guò)代理發(fā)送成功消息
if (self.delegate != nil && [self.delegate respondsToSelector:@selector(praseM3U8Finished:)]) {
[self.delegate praseM3U8Finished:self];
}
}
#pragma mark - getter
- (NSMutableArray *)segmentArray {
if (_segmentArray == nil) {
_segmentArray = [[NSMutableArray alloc] init];
}
return _segmentArray;
}
- (M3U8Playlist *)playList {
if (_playList == nil) {
_playList = [[M3U8Playlist alloc] init];
}
return _playList;
}
@end
注意:
1、下面就是解析出來(lái)的M3U8索引數(shù)據(jù),#EXTINF:10表示的是這段TS的時(shí)長(zhǎng)是10秒,57b3f432.ts這里表示的是每一個(gè)TS的文件名,有的M3U8這里直接是一個(gè)完成的http鏈接。前面說(shuō)到我們要拼接處每一個(gè)TS文件的下載鏈接,這里應(yīng)該如何拼接呢,在一開(kāi)始做這里的時(shí)候,我也費(fèi)解了一段時(shí)間,查閱了一些資料和博文都不靠譜,所以不建議大家根據(jù)這些不靠譜的信息拼接鏈接,我這里總結(jié)出來(lái)的經(jīng)驗(yàn)是,TS文件一般都存儲(chǔ)在.M3U8索引文件所在的路徑,只需要將TS文件名替換到.M3U8索引即可,當(dāng)然最靠譜的做法和你們的服務(wù)器小伙伴協(xié)商好下載路徑。
#EXTM3U
#EXT-X-VERSION:2
#EXT-X-MEDIA-SEQUENCE:102
#EXT-X-TARGETDURATION:12
#EXTINF:10,
57b3f432.ts
#EXTINF:12,
57b3f43c.ts
#EXTINF:9,
57b3f446.ts
2、M3U8Playlist是一個(gè)存儲(chǔ)一個(gè)M3U8數(shù)據(jù)的Model,存儲(chǔ)的是TS下載鏈接數(shù)組,數(shù)組的數(shù)量。uuid設(shè)置為固定的"moive1",主要是用來(lái)拼接統(tǒng)一的緩存路徑。
*下載
拿到每一個(gè)TS的鏈接就可以下載了,下載后緩存到本地。
下載器ZYLVideoDownLoader.h文件
#import <Foundation/Foundation.h>
#import "M3U8Playlist.h"
@class ZYLVideoDownLoader;
@protocol ZYLVideoDownLoaderDelegate <NSObject>
/**
* 下載成功
*/
- (void)videoDownloaderFinished:(ZYLVideoDownLoader *)videoDownloader;
/**
* 下載失敗
*/
- (void)videoDownloaderFailed:(ZYLVideoDownLoader *)videoDownloader;
@end
@interface ZYLVideoDownLoader : NSObject
@property (strong, nonatomic) M3U8Playlist *playList;
/**
* 記錄原始的M3U8
*/
@property (copy, nonatomic) NSString *oriM3U8Str;
/**
* 下載TS數(shù)據(jù)
*/
- (void)startDownloadVideo;
/**
* 儲(chǔ)存正在下載的數(shù)組
*/
@property (strong, nonatomic) NSMutableArray *downLoadArray;
/**
* 下載成功或者失敗的代理
*/
@property (weak, nonatomic) id <ZYLVideoDownLoaderDelegate> delegate;
/**
* 創(chuàng)建M3U8文件
*/
- (void)createLocalM3U8file;
@end
下載器ZYLVideoDownLoader.m文件
#import "ZYLVideoDownLoader.h"
#import "M3U8SegmentModel.h"
#import "SegmentDownloader.h"
@interface ZYLVideoDownLoader () <SegmentDownloaderDelegate>
@property (assign, nonatomic) NSInteger index;//記錄一共多少TS文件
@property (strong, nonatomic) NSMutableArray *downloadUrlArray;//記錄所有的下載鏈接
@property (assign, nonatomic) NSInteger sIndex;//記錄下載成功的文件的數(shù)量
@end
@implementation ZYLVideoDownLoader
-(instancetype)init {
self = [super init];
if (self) {
self.index = 0;
self.sIndex = 0;
}
return self;
}
#pragma mark - 下載TS數(shù)據(jù)
- (void)startDownloadVideo {
//首相檢查是否存在路徑
[self checkDirectoryIsCreateM3U8:NO];
__weak __typeof(self)weakSelf = self;
/*注意1,請(qǐng)看下方注意1*/
//將解析的數(shù)據(jù)打包成一個(gè)個(gè)獨(dú)立的下載器裝進(jìn)數(shù)組
[self.playList.segmentArray enumerateObjectsUsingBlock:^(M3U8SegmentModel *obj, NSUInteger idx, BOOL * _Nonnull stop) {
//檢查此下載對(duì)象是否存在
__block BOOL isE = NO;
[weakSelf.downloadUrlArray enumerateObjectsUsingBlock:^(NSString *inObj, NSUInteger inIdx, BOOL * _Nonnull inStop) {
if ([inObj isEqualToString:obj.locationUrl]) {
//已經(jīng)存在
isE = YES;
*inStop = YES;
} else {
//不存在
isE = NO;
}
}];
if (isE) {
//存在
} else {
//不存在
NSString *fileName = [NSString stringWithFormat:@"id%ld.ts", (long)weakSelf.index];
SegmentDownloader *sgDownloader = [[SegmentDownloader alloc] initWithUrl:[@"http://111.206.23.22:55336/tslive/c25_ct_btv2_btvwyHD_smooth_t10/" stringByAppendingString:obj.locationUrl] andFilePath:weakSelf.playList.uuid andFileName:fileName withDuration:obj.duration withIndex:weakSelf.index];
sgDownloader.delegate = weakSelf;
[weakSelf.downLoadArray addObject:sgDownloader];
[weakSelf.downloadUrlArray addObject:obj.locationUrl];
weakSelf.index++;
}
}];
/*注意2,請(qǐng)看下方注意2*/
//根據(jù)新的數(shù)據(jù)更改新的playList
__block NSMutableArray *newPlaylistArray = [[NSMutableArray alloc] init];
[self.downLoadArray enumerateObjectsUsingBlock:^(SegmentDownloader *obj, NSUInteger idx, BOOL * _Nonnull stop) {
M3U8SegmentModel *model = [[M3U8SegmentModel alloc] init];
model.duration = obj.duration;
model.locationUrl = obj.fileName;
model.index = obj.index;
[newPlaylistArray addObject:model];
}];
if (newPlaylistArray.count > 0) {
self.playList.segmentArray = newPlaylistArray;
}
//打包完成開(kāi)始下載
[self.downLoadArray enumerateObjectsUsingBlock:^(SegmentDownloader *obj, NSUInteger idx, BOOL * _Nonnull stop) {
obj.flag = YES;
[obj start];
}];
}
#pragma mark - 檢查路徑
- (void)checkDirectoryIsCreateM3U8:(BOOL)isC {
//創(chuàng)建緩存路徑
NSString *pathPrefix = [NSSearchPathForDirectoriesInDomains(NSDocumentDirectory,NSUserDomainMask,YES) objectAtIndex:0];
NSString *saveTo = [[pathPrefix stringByAppendingPathComponent:@"Downloads"] stringByAppendingPathComponent:self.playList.uuid];
NSFileManager *fm = [NSFileManager defaultManager];
//路徑不存在就創(chuàng)建一個(gè)
BOOL isD = [fm fileExistsAtPath:saveTo];
if (isD) {
//存在
} else {
//不存在
BOOL isS = [fm createDirectoryAtPath:saveTo withIntermediateDirectories:YES attributes:nil error:nil];
if (isS) {
NSLog(@"路徑不存在創(chuàng)建成功");
} else {
NSLog(@"路徑不存在創(chuàng)建失敗");
}
}
}
#pragma mark - SegmentDownloaderDelegate
/*注意3,請(qǐng)看下方注意3*/
#pragma mark - 數(shù)據(jù)下載成功
- (void)segmentDownloadFinished:(SegmentDownloader *)downloader {
//數(shù)據(jù)下載成功后再數(shù)據(jù)源中移除當(dāng)前下載器
self.sIndex++;
if (self.sIndex >= 3) {
//每次下載完成后都要?jiǎng)?chuàng)建M3U8文件
[self createLocalM3U8file];
//證明所有的TS已經(jīng)下載完成
[self.delegate videoDownloaderFinished:self];
}
}
#pragma mark - 數(shù)據(jù)下載失敗
- (void)segmentDownloadFailed:(SegmentDownloader *)downloader {
[self.delegate videoDownloaderFailed:self];
}
#pragma mark - 進(jìn)度更新
- (void)segmentProgress:(SegmentDownloader *)downloader TotalUnitCount:(int64_t)totalUnitCount completedUnitCount:(int64_t)completedUnitCount {
//NSLog(@"下載進(jìn)度:%f", completedUnitCount * 1.0 / totalUnitCount * 1.0);
}
/*注意4,請(qǐng)看下方注意4*/
#pragma mark - 創(chuàng)建M3U8文件
- (void)createLocalM3U8file {
[self checkDirectoryIsCreateM3U8:YES];
//創(chuàng)建M3U8的鏈接地址
NSString *path = [[[[NSSearchPathForDirectoriesInDomains(NSDocumentDirectory,NSUserDomainMask,YES) objectAtIndex:0] stringByAppendingPathComponent:@"Downloads"] stringByAppendingPathComponent:self.playList.uuid] stringByAppendingPathComponent:@"movie.m3u8"];
//拼接M3U8鏈接的頭部具體內(nèi)容
//NSString *header = @"#EXTM3U\n#EXT-X-VERSION:2\n#EXT-X-MEDIA-SEQUENCE:371\n#EXT-X-TARGETDURATION:12\n";
NSString *header = [NSString stringWithFormat:@"#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-MEDIA-SEQUENCE:0\n#EXT-X-TARGETDURATION:15\n"];
//填充M3U8數(shù)據(jù)
__block NSString *tsStr = [[NSString alloc] init];
[self.playList.segmentArray enumerateObjectsUsingBlock:^(M3U8SegmentModel *obj, NSUInteger idx, BOOL * _Nonnull stop) {
//文件名
NSString *fileName = [NSString stringWithFormat:@"id%ld.ts", obj.index];
//文件時(shí)長(zhǎng)
NSString* length = [NSString stringWithFormat:@"#EXTINF:%ld,\n",obj.duration];
//拼接M3U8
tsStr = [tsStr stringByAppendingString:[NSString stringWithFormat:@"%@%@\n", length, fileName]];
}];
//M3U8頭部和中間拼接,到此我們完成的新的M3U8鏈接的拼接
header = [header stringByAppendingString:tsStr];
/*注意5,請(qǐng)看下方注意5*/
header = [header stringByAppendingString:@"#EXT-X-ENDLIST"];
//拼接完成,存儲(chǔ)到本地
NSMutableData *writer = [[NSMutableData alloc] init];
NSFileManager *fm = [NSFileManager defaultManager];
//判斷m3u8是否存在,已經(jīng)存在的話就不再重新創(chuàng)建
if ([fm fileExistsAtPath:path isDirectory:nil]) {
//存在這個(gè)鏈接
NSLog(@"存在這個(gè)鏈接");
} else {
//不存在這個(gè)鏈接
NSString *saveTo = [[[NSSearchPathForDirectoriesInDomains(NSDocumentDirectory,NSUserDomainMask,YES) objectAtIndex:0] stringByAppendingPathComponent:@"Downloads"] stringByAppendingPathComponent:self.playList.uuid];
BOOL isS = [fm createDirectoryAtPath:saveTo withIntermediateDirectories:YES attributes:nil error:nil];
if (isS) {
NSLog(@"創(chuàng)建目錄成功");
} else {
NSLog(@"創(chuàng)建目錄失敗");
}
}
[writer appendData:[header dataUsingEncoding:NSUTF8StringEncoding]];
BOOL bSucc = [writer writeToFile:path atomically:YES];
if (bSucc) {
//成功
NSLog(@"M3U8數(shù)據(jù)保存成功");
} else {
//失敗
NSLog(@"M3U8數(shù)據(jù)保存失敗");
}
NSLog(@"新數(shù)據(jù)\n%@", header);
}
#pragma mark - 刪除緩存文件
- (void)deleteCache {
//獲取緩存路徑
NSString *pathPrefix = [NSSearchPathForDirectoriesInDomains(NSDocumentDirectory,NSUserDomainMask,YES) objectAtIndex:0];
NSString *saveTo = [[pathPrefix stringByAppendingPathComponent:@"Downloads"] stringByAppendingPathComponent:@"moive1"];
NSFileManager *fm = [NSFileManager defaultManager];
//路徑不存在就創(chuàng)建一個(gè)
BOOL isD = [fm fileExistsAtPath:saveTo];
if (isD) {
//存在
NSArray *deleteArray = [_downloadUrlArray subarrayWithRange:NSMakeRange(0, _downloadUrlArray.count - 20)];
//清空當(dāng)前的M3U8文件
[deleteArray enumerateObjectsUsingBlock:^(NSString *obj, NSUInteger idx, BOOL * _Nonnull stop) {
BOOL isS = [fm removeItemAtPath:[saveTo stringByAppendingPathComponent:[NSString stringWithFormat:@"%@", obj]] error:nil];
if (isS) {
NSLog(@"多余路徑存在清空成功%@", obj);
} else {
NSLog(@"多余路徑存在清空失敗%@", obj);
}
}];
}
}
#pragma mark - getter
- (NSMutableArray *)downLoadArray {
if (_downLoadArray == nil) {
_downLoadArray = [[NSMutableArray alloc] init];
}
return _downLoadArray;
}
- (NSMutableArray *)downloadUrlArray {
if (_downloadUrlArray == nil) {
_downloadUrlArray = [[NSMutableArray alloc] init];
}
return _downloadUrlArray;
}
@end
注意:
1、這里獲取到的M3U8數(shù)據(jù)包含了很多TS文件,并不會(huì)在下載器里直接下載,而是要對(duì)每一個(gè)TS文件再次封裝,然后每一個(gè)封裝好的數(shù)據(jù)模型單獨(dú)下載;
2、這里更新playlist的目的是為了后續(xù)創(chuàng)建.M3U8索引,可以暫時(shí)略過(guò)這里,到了創(chuàng)建索引的地方自然就懂了;
3、這是數(shù)據(jù)下載成功的代理,由于本文使用的測(cè)試連接每一個(gè)M3U8里有3個(gè)TS文件,所以當(dāng)?shù)谝淮?個(gè)文件全部下載完成后告訴系在工具類下載完成,后續(xù)沒(méi)下載完成一個(gè)就告訴下載工具類一次;
4、在第一次3個(gè)TS文件下載成功和后續(xù)每有一個(gè)TS下載成功后,都會(huì)更新.M3U8索引文件,保證索引文件的更新;
5、這里要注意,添加了#EXT-X-ENDLIST,表明這個(gè)源事HLS的點(diǎn)播源,當(dāng)播放的時(shí)候,HLS會(huì)從頭開(kāi)始播放。
*TS文件下載器
上面的下載器將每一個(gè)TS文件單獨(dú)封裝,單獨(dú)下載,下面我們來(lái)看看每一個(gè)TS文件是如何下載的
TS文件下載器 SegmentDownloader.h文件
#import <Foundation/Foundation.h>
@class SegmentDownloader;
@protocol SegmentDownloaderDelegate <NSObject>
/**
* 下載成功
*/
- (void)segmentDownloadFinished:(SegmentDownloader *)downloader;
/**
* 下載失敗
*/
- (void)segmentDownloadFailed:(SegmentDownloader *)downloader;
/**
* 監(jiān)聽(tīng)進(jìn)度
*/
- (void)segmentProgress:(SegmentDownloader *)downloader TotalUnitCount:(int64_t)totalUnitCount completedUnitCount:(int64_t)completedUnitCount;
@end
@interface SegmentDownloader : NSObject
@property (nonatomic, copy) NSString *fileName;
@property (nonatomic, copy) NSString *filePath;
@property (nonatomic, copy) NSString *downloadUrl;
@property (assign, nonatomic) NSInteger duration;
@property (assign, nonatomic) NSInteger index;
/**
* 標(biāo)記這個(gè)下載器是否正在下載
*/
@property (assign, nonatomic) BOOL flag;
/**
* 初始化TS下載器
*/
- (instancetype)initWithUrl:(NSString *)url andFilePath:(NSString *)path andFileName:(NSString *)fileName withDuration:(NSInteger)duration withIndex:(NSInteger)index;
/**
* 傳遞數(shù)據(jù)下載成功或者失敗的代理
*/
@property (strong, nonatomic) id <SegmentDownloaderDelegate> delegate;
/**
* 開(kāi)始下載
*/
- (void)start;
@end
TS文件下載器 SegmentDownloader.m文件
#import "SegmentDownloader.h"
#import <AFNetworking.h>
@interface SegmentDownloader ()
@property (strong, nonatomic) AFHTTPRequestSerializer *serializer;
@property (strong, nonatomic) AFURLSessionManager *downLoadSession;
@end
@implementation SegmentDownloader
#pragma mark - 初始化TS下載器
- (instancetype)initWithUrl:(NSString *)url andFilePath:(NSString *)path andFileName:(NSString *)fileName withDuration:(NSInteger)duration withIndex:(NSInteger)index {
self = [super init];
if (self) {
self.downloadUrl = url;
self.filePath = path;
self.fileName = fileName;
self.duration = duration;
self.index = index;
}
return self;
}
#pragma mark - 開(kāi)始下載
- (void)start {
//首先檢查此文件是否已經(jīng)下載
if ([self checkIsDownload]) {
//下載了
[self.delegate segmentDownloadFinished:self];
return;
} else {
//沒(méi)下載
}
//首先拼接存儲(chǔ)數(shù)據(jù)的路徑
__block NSString *path = [[[[NSSearchPathForDirectoriesInDomains(NSDocumentDirectory,NSUserDomainMask,YES) objectAtIndex:0] stringByAppendingPathComponent:@"Downloads"] stringByAppendingPathComponent:self.filePath] stringByAppendingPathComponent:self.fileName];
/*注意1,請(qǐng)查看下方注意1*/
//這里使用AFN下載,并將數(shù)據(jù)同時(shí)存儲(chǔ)到沙盒目錄制定的目錄中
NSURLRequest *request = [NSURLRequest requestWithURL:[NSURL URLWithString:self.downloadUrl]];
__block NSProgress *progress = nil;
NSURLSessionDownloadTask *downloadTask = [self.downLoadSession downloadTaskWithRequest:request progress:&progress destination:^NSURL * _Nonnull(NSURL * _Nonnull targetPath, NSURLResponse * _Nonnull response) {
//在這里告訴AFN數(shù)據(jù)存儲(chǔ)的路徑和文件名
NSURL *documentsDirectoryURL = [NSURL fileURLWithPath:path isDirectory:NO];
return documentsDirectoryURL;
} completionHandler:^(NSURLResponse * _Nonnull response, NSURL * _Nullable filePath, NSError * _Nullable error) {
if (error == nil) {
//下載成功
//NSLog(@"路徑%@保存成功", filePath);
[self.delegate segmentDownloadFinished:self];
} else {
//下載失敗
[self.delegate segmentDownloadFailed:self];
}
[progress removeObserver:self forKeyPath:@"completedUnitCount"];
}];
//添加對(duì)進(jìn)度的監(jiān)聽(tīng)
[progress addObserver:self forKeyPath:@"completedUnitCount" options:NSKeyValueObservingOptionNew context:nil];
//開(kāi)始下載
[downloadTask resume];
}
#pragma mark - 檢查此文件是否下載過(guò)
- (BOOL)checkIsDownload {
//獲取緩存路徑
NSString *pathPrefix = [NSSearchPathForDirectoriesInDomains(NSDocumentDirectory,NSUserDomainMask,YES) objectAtIndex:0];
NSString *saveTo = [[pathPrefix stringByAppendingPathComponent:@"Downloads"] stringByAppendingPathComponent:self.filePath];
NSFileManager *fm = [NSFileManager defaultManager];
__block BOOL isE = NO;
//獲取緩存路徑下的所有的文件名
NSArray *subFileArray = [fm subpathsAtPath:saveTo];
[subFileArray enumerateObjectsUsingBlock:^(id _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
//判斷是否已經(jīng)緩存了此文件
if ([self.fileName isEqualToString:[NSString stringWithFormat:@"%@", obj]]) {
//已經(jīng)下載
isE = YES;
*stop = YES;
} else {
//沒(méi)有存在
isE = NO;
}
}];
return isE;
}
#pragma mark - 監(jiān)聽(tīng)進(jìn)度
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(NSProgress *)object change:(NSDictionary *)change context:(void *)context
{
if ([keyPath isEqualToString:@"completedUnitCount"]) {
[self.delegate segmentProgress:self TotalUnitCount:object.totalUnitCount completedUnitCount:object.completedUnitCount];
}
}
#pragma mark - getter
- (AFHTTPRequestSerializer *)serializer {
if (_serializer == nil) {
_serializer = [AFHTTPRequestSerializer serializer];
}
return _serializer;
}
- (AFURLSessionManager *)downLoadSession {
if (_downLoadSession == nil) {
NSURLSessionConfiguration *configuration = [NSURLSessionConfiguration defaultSessionConfiguration];
_downLoadSession = [[AFURLSessionManager alloc] initWithSessionConfiguration:configuration];
}
return _downLoadSession;
}
@end
注意:
1、這里使用AFN的AFURLSessionManager下載數(shù)據(jù)并緩存數(shù)據(jù)到本地,同時(shí)可以通過(guò)這里獲得下載的進(jìn)度;
2、由于這里是自己下載TS文件,所有若是我們的項(xiàng)目中有直接操作視頻數(shù)據(jù)的需求,就可以在這里獲取視頻數(shù)據(jù)進(jìn)行處理了。具體的下載流程,大家參考代碼即可。
3、為了直觀的看到TS文件的下載過(guò)程,小伙伴們可以在模擬器上運(yùn)行DEMO,然后進(jìn)入到沙盒目錄下,可以看到數(shù)據(jù)的實(shí)時(shí)更新,如下圖:


播放
TS文件下載完成了,.M3U8索引文件也創(chuàng)建好了,那么如何播放呢,看著一段段零散的TS文件,我們難道要一段段播放給用戶看嗎?這樣顯然不合理,這里我們要使用HLS直播播放技術(shù),模擬服務(wù)器和客戶端的交互的過(guò)程,所以我們?cè)诒镜亟⒁粋€(gè)http服務(wù)器,讓HLS訪問(wèn)本地的http服務(wù)器就可以播放了,下面看看具體的實(shí)現(xiàn)過(guò)程
*建立本地的http服務(wù)器
這里我們使用iOS端很有名也很好用的CocoaHTTPServer第三方庫(kù)建立http服務(wù)器,可以直接cocoaPods導(dǎo)入工程,導(dǎo)入后創(chuàng)建服務(wù)器,代碼如下:
- (void)openServer {
[DDLog addLogger:[DDTTYLogger sharedInstance]];
self.httpServer=[[HTTPServer alloc]init];
[self.httpServer setType:@"_http._tcp."];
[self.httpServer setPort:9479];
NSString *pathPrefix = [NSSearchPathForDirectoriesInDomains(NSDocumentDirectory,NSUserDomainMask,YES) objectAtIndex:0];
NSString *webPath = [pathPrefix stringByAppendingPathComponent:@"Downloads"];
[self.httpServer setDocumentRoot:webPath];
NSLog(@"服務(wù)器路徑:%@", webPath);
NSError *error;
if ([self.httpServer start:&error]) {
NSLog(@"開(kāi)啟HTTP服務(wù)器 端口:%hu",[self.httpServer listeningPort]);
}
else{
NSLog(@"服務(wù)器啟動(dòng)失敗錯(cuò)誤為:%@",error);
}
}
注意:
1、[self.httpServer setPort:9479];這里是設(shè)置服務(wù)器端口,端口號(hào)寫(xiě)一個(gè)不容易重復(fù)的即可,避免用戶手機(jī)其他APP也建立了端口號(hào)一樣的服務(wù)器,導(dǎo)致服務(wù)器建立失敗,或者數(shù)據(jù)混亂,另外用模擬器在本地建立的服務(wù)器,是直接建立的mac上的,可以把播放鏈接直接給vlc打開(kāi)播放;
2、[self.httpServer setDocumentRoot:webPath];這一步在給服務(wù)器設(shè)置路徑的時(shí)候,一定要注意和緩存TS數(shù)據(jù)的路徑一致;
3、解碼工具類中使用了一些定時(shí)器,小伙伴們?cè)谑褂玫臅r(shí)候,要記得聲明一個(gè)銷毀解碼工具類的方法,在這個(gè)方法里銷毀定時(shí)器等,避免頁(yè)面無(wú)法銷毀的bug。
*播放
服務(wù)器頁(yè)建立好了,那么播放鏈接是什么呢?懂一些網(wǎng)絡(luò)技術(shù)的小伙伴可能已經(jīng)猜到了,服務(wù)器是建立在本地的,網(wǎng)絡(luò)里127.0.0.1是本地IP地址,因此播放連接是:@"http://127.0.0.1:9479/moive1/movie.m3u8", 將這個(gè)連接直接交給AVPlayer就可以播放了,用VLC打開(kāi),不僅可以播放,還可以調(diào)整進(jìn)度。當(dāng)下載了一些文件后,退出APP,即使在沒(méi)有網(wǎng)絡(luò)的情況下打開(kāi),也可以正常播放,如圖:


尾巴
到這里我們已經(jīng)實(shí)現(xiàn)了M3U8直播的回看和下載,DEMO下載地址:Demo。
本文為小伙伴們提供了一種思路,整個(gè)實(shí)現(xiàn)過(guò)程還是有些復(fù)雜的,需要小伙伴們反復(fù)理解,當(dāng)然有一定的音視頻開(kāi)發(fā)技術(shù)理解起來(lái)就簡(jiǎn)單多了,本文并沒(méi)有對(duì)M3U8做過(guò)多技術(shù)講解,這方面的知識(shí)可以查閱蘋(píng)果官方文檔:HLS蘋(píng)果官方資料<small></small>,這里只是挑出一些問(wèn)題講解一下,最終能否理解還要靠小伙伴們自己的努力,若在文中發(fā)現(xiàn)錯(cuò)誤請(qǐng)及時(shí)指正,感激不盡。