尊重知識(shí),轉(zhuǎn)發(fā)請(qǐng)注明出處:iOS流媒體開發(fā)之三:HLS直播(M3U8)回看和下載功能的實(shí)現(xiàn)
概要
流媒體開發(fā)第一篇文章就說要把這些不是隨便就可以百度到的知識(shí)獻(xiàn)給“簡(jiǎn)書”脱盲,拖了一個(gè)多月了乾蛤,總算弄完了每界,深深松了口氣,萬幸沒有食言家卖,否則對(duì)不起小伙伴們眨层。
流媒體始終是大眾生活?yuàn)蕵纷顬橹匾囊粋€(gè)部分,同時(shí)也是技術(shù)開發(fā)中比較有難度的上荡,尤其是直播趴樱,不僅功能是點(diǎn)播無法替代的,開發(fā)難度也要比點(diǎn)播大酪捡,里約奧運(yùn)會(huì)等重大體育賽事大家只能通過直播觀看比賽叁征,體會(huì)現(xiàn)場(chǎng)觀看的緊張和刺激,點(diǎn)播是無法做到的逛薇。
如今我們也會(huì)有直播回看和下載的需求捺疼,一些APP包括我們自己的項(xiàng)目也已經(jīng)實(shí)現(xiàn)了這些功能,網(wǎng)上講解這部分技術(shù)的知識(shí)相對(duì)較少永罚,而且有很多都不是很靠譜啤呼,我這里拋磚引玉,給大家提供一種思路呢袱,僅供參考官扣。所以建議大家理解我的思路,盡量不要直接拿來用在項(xiàng)目里产捞,后面我會(huì)詳細(xì)講解有哪些地方在應(yīng)用到項(xiàng)目中需要額外的處理醇锚。
注意: 1、本文不適合初級(jí)iOS開發(fā)者,需要有一定的開發(fā)經(jīng)驗(yàn)焊唬,和對(duì)流媒體技術(shù)的基本概念和開發(fā)技術(shù)的了解恋昼,例如本文不會(huì)講解什么是TS、AAC和M3U8等概念赶促,這些知識(shí)網(wǎng)上很多液肌,大家可以自行查閱理解,這里就贅述了鸥滨; 2嗦哆、直播的回看和下載相對(duì)于音視頻的播放開發(fā)難度要大一些,數(shù)據(jù)處理的思路也比較復(fù)雜婿滓,所以為了大家能更快的理解和接受老速,本文著重核心功能的講解,以免過多的代碼對(duì)理解產(chǎn)生干擾凸主,比如我們拿到一個(gè)M3U8鏈接橘券,我們要判斷這個(gè)鏈接是否是http或者h(yuǎn)ttps的,其次要去除鏈接中的空白字符卿吐,注意空白字符不一定是空格旁舰,還有可能是回車、TAB等其他的空白字符嗡官,處理起來也比較繁瑣箭窜,本文不對(duì)這些做過多處理,默認(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索引文件和播放鏈接训裆,返回給客戶端眶根,這是客戶端拿到的播放鏈接和直播的鏈接是一樣的,播放的處理流程也是一樣的边琉,只不過這時(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片段下載下來扩氢,可以參考后面的內(nèi)容。
3爷辱、兩中方案的優(yōu)缺點(diǎn)分析:
①第一種方案對(duì)于服務(wù)器來說處理比較簡(jiǎn)單类茂,只需要將TS存儲(chǔ)并打包即可。對(duì)于客戶端來說播放很簡(jiǎn)單托嚣,同時(shí)HLS的傳輸效率也要更高一些,播放速度會(huì)很快厚骗,但是涉及到調(diào)整視頻進(jìn)度示启、截取視頻某一幀圖片,監(jiān)聽視頻播放狀態(tài)這些就比較麻煩了领舰》蛏ぃ回看的內(nèi)容雖然也是直播的內(nèi)容,但是在用戶看來無所謂點(diǎn)播和直播冲秽,這些已經(jīng)是播放過的節(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è)播放源從哪里開始播放的時(shí)間戳民轴,服務(wù)器拿到這個(gè)參數(shù)后會(huì)生成對(duì)應(yīng)的數(shù)據(jù)返回給客戶端播放攻柠,這里就可以實(shí)現(xiàn)精準(zhǔn)的進(jìn)度控制了。
②第二種方案對(duì)于服務(wù)器來說要繁瑣些后裸,多了一步制作點(diǎn)播源的步驟瑰钮。對(duì)于客戶端,第二種方案的好處是直接拿到的是點(diǎn)播的播放源微驶,無論是進(jìn)度調(diào)整浪谴、獲取幀率圖和播放狀態(tài)的控制都很簡(jiǎn)單,雖然播放速度相對(duì)與HLS來說會(huì)慢一點(diǎn),但影響并不大苟耻。同時(shí)由于服務(wù)器已經(jīng)將每一個(gè)節(jié)目轉(zhuǎn)碼成功篇恒,如果用戶要下載這些節(jié)目觀看,客戶端的實(shí)現(xiàn)也比較簡(jiǎn)單梁呈。這種方案的缺點(diǎn)是不夠靈活婚度,用戶只能以節(jié)目為時(shí)間單位進(jìn)行回看,無法像第一種方案一樣官卡,以時(shí)間戳為單位回看蝗茁,精細(xì)度不夠。
總結(jié) 兩種回看方案并沒有優(yōu)略之分寻咒,具體采用哪一種哮翘,要看具體項(xiàng)目的需求,小伙伴們?cè)陂_發(fā)過程中要注意和服務(wù)器的聯(lián)調(diào)測(cè)試毛秘,尤其是第一種方案饭寺,M3U8的各種tag設(shè)置的不準(zhǔn)確也會(huì)造成各種播放錯(cuò)誤,并沒有那么容易實(shí)現(xiàn)叫挟,當(dāng)然服務(wù)器那邊也會(huì)有一些第三方庫可以直接用艰匙,所以對(duì)于有些開發(fā)經(jīng)驗(yàn)的服務(wù)器工程師還是比較容易實(shí)現(xiàn)的。
下載
下載的流程比較復(fù)雜抹恳,為了讓小伙伴更容易理解员凝,我不會(huì)按照我的代碼一步步講解,這樣只會(huì)讓人頭暈?zāi)X脹奋献,意義不大健霹。我這里按照我在學(xué)習(xí)新知識(shí)時(shí)比較容易理解知識(shí)的經(jīng)驗(yàn)來講解。
我們?cè)趯W(xué)習(xí)時(shí)瓶蚂,如果只是拿來別人的代碼一行行看糖埋,遇到不會(huì)的查閱,然后再下面的窃这,沒一會(huì)就頭暈了瞳别,相信大家都有過這種經(jīng)驗(yàn),效果非常差杭攻,而且作者在寫這些代碼的時(shí)候并不是逐字逐行的寫的洒试,而是一次次優(yōu)化改動(dòng)得來的,通過代碼我們很難明白作者寫代碼的邏輯和心路歷程朴上,自控力強(qiáng)的多看幾遍屢清楚思路能看明白垒棋,自控力稍差的可能就放棄了,下面講解下我的講解思路和學(xué)習(xí)方法痪宰。
*學(xué)習(xí)思路
①首先我會(huì)說明HLS下載的實(shí)現(xiàn)思路叼架,小伙伴們?cè)诳催@部分的時(shí)候不要把自己當(dāng)成技術(shù)人員畔裕,各行各業(yè)最有價(jià)值的都是解決問題的思想和能力,而不是代碼乖订、文字和各種工具等扮饶,所以我盡量讓一個(gè)沒有任何開發(fā)技術(shù)的人明白HLS下載的邏輯,明白了解決問題的邏輯乍构,再看后面的代碼就不至于暈頭轉(zhuǎn)向了甜无;
②其次我會(huì)按照流程逐步講解,在講解每一步流程時(shí)哥遮,每一步也是一個(gè)相對(duì)獨(dú)立的子流程岂丘,我也會(huì)大概的描述下每一步子流程的實(shí)現(xiàn)思路,小伙伴們理解起來也會(huì)更加簡(jiǎn)單眠饮;
③最后說下小伙伴們?cè)陂喿x時(shí)的一些注意事項(xiàng)奥帘。在對(duì)核心功能還沒有充分理解的前提下,不要太在意一些技術(shù)細(xì)節(jié)仪召,比如這里為什么調(diào)用這個(gè)方法寨蹋、這樣做性能不太高等等和核心功能無關(guān)的。等小伙伴們對(duì)核心功能理解了扔茅,再來優(yōu)化和理解一些小的地方已旧,才會(huì)得心應(yīng)手。由于我們寫這些代碼的時(shí)候考慮的也不是很健全召娜,所有會(huì)有很多地方寫得不完美运褪,也歡迎小伙伴們留言指出來,絕對(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ù)打包完成,就可以播放了搔确。
說明: 1彼棍、本文借鑒了iOS端M3U8第三方庫的處理流程灭忠,由于這個(gè)第三方庫長(zhǎng)時(shí)間沒有維護(hù)和更新,并且采用了ASI作為網(wǎng)絡(luò)請(qǐng)求座硕,直接采用會(huì)給項(xiàng)目帶來大量的警告和錯(cuò)誤弛作,還會(huì)導(dǎo)致無法適配各種架構(gòu)等問題,處理起來很是繁瑣和棘手华匾,并且即使配置成功映琳,也是無法直接使用的,還是需要改動(dòng)第三方庫的很多地方蜘拉,所以我這里模仿M3U8庫的部分處理邏輯萨西,同時(shí)網(wǎng)絡(luò)請(qǐng)求使用AFN,當(dāng)然這里建議大家對(duì)AFN做一層封裝后再使用诸尽,避免AFN升級(jí)換代帶來不必要的麻煩原杂。 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里沒有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解析成功,通過代理發(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悴了、下面就是解析出來的M3U8索引數(shù)據(jù)搏恤,#EXTINF:10表示的是這段TS的時(shí)長(zhǎng)是10秒,57b3f432.ts這里表示的是每一個(gè)TS的文件名湃交,有的M3U8這里直接是一個(gè)完成的http鏈接熟空。前面說到我們要拼接處每一個(gè)TS文件的下載鏈接,這里應(yīng)該如何拼接呢搞莺,在一開始做這里的時(shí)候息罗,我也費(fèi)解了一段時(shí)間,查閱了一些資料和博文都不靠譜才沧,所以不建議大家根據(jù)這些不靠譜的信息拼接鏈接阱当,我這里總結(jié)出來的經(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"澈圈,主要是用來拼接統(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;
}
//打包完成開始下載
[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í)略過這里,到了創(chuàng)建索引的地方自然就懂了雇盖;
3忿等、這是數(shù)據(jù)下載成功的代理,由于本文使用的測(cè)試連接每一個(gè)M3U8里有3個(gè)TS文件刊懈,所以當(dāng)?shù)谝淮?個(gè)文件全部下載完成后告訴系在工具類下載完成这弧,后續(xù)沒下載完成一個(gè)就告訴下載工具類一次娃闲;
4虚汛、在第一次3個(gè)TS文件下載成功和后續(xù)每有一個(gè)TS下載成功后,都會(huì)更新.M3U8索引文件皇帮,保證索引文件的更新卷哩;
5、這里要注意属拾,添加了#EXT-X-ENDLIST将谊,表明這個(gè)源事HLS的點(diǎn)播源冷溶,當(dāng)播放的時(shí)候,HLS會(huì)從頭開始播放尊浓。
*TS文件下載器
上面的下載器將每一個(gè)TS文件單獨(dú)封裝逞频,單獨(dú)下載,下面我們來看看每一個(gè)TS文件是如何下載的
TS文件下載器 SegmentDownloader.h文件
#import <Foundation/Foundation.h>
@class SegmentDownloader;
@protocol SegmentDownloaderDelegate <NSObject>
/**
* 下載成功
*/
- (void)segmentDownloadFinished:(SegmentDownloader *)downloader;
/**
* 下載失敗
*/
- (void)segmentDownloadFailed:(SegmentDownloader *)downloader;
/**
* 監(jiān)聽進(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;
/**
* 開始下載
*/
- (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 - 開始下載
- (void)start {
//首先檢查此文件是否已經(jīng)下載
if ([self checkIsDownload]) {
//下載了
[self.delegate segmentDownloadFinished:self];
return;
} else {
//沒下載
}
//首先拼接存儲(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)聽
[progress addObserver:self forKeyPath:@"completedUnitCount" options:NSKeyValueObservingOptionNew context:nil];
//開始下載
[downloadTask resume];
}
#pragma mark - 檢查此文件是否下載過
- (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 {
//沒有存在
isE = NO;
}
}];
return isE;
}
#pragma mark - 監(jiān)聽進(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í)可以通過這里獲得下載的進(jìn)度瓦堵;
2基协、由于這里是自己下載TS文件,所有若是我們的項(xiàng)目中有直接操作視頻數(shù)據(jù)的需求菇用,就可以在這里獲取視頻數(shù)據(jù)進(jìn)行處理了澜驮。具體的下載流程,大家參考代碼即可惋鸥。
3杂穷、為了直觀的看到TS文件的下載過程,小伙伴們可以在模擬器上運(yùn)行DEMO揩慕,然后進(jìn)入到沙盒目錄下亭畜,可以看到數(shù)據(jù)的實(shí)時(shí)更新,如下圖:
播放
TS文件下載完成了迎卤,.M3U8索引文件也創(chuàng)建好了拴鸵,那么如何播放呢,看著一段段零散的TS文件蜗搔,我們難道要一段段播放給用戶看嗎劲藐?這樣顯然不合理,這里我們要使用HLS直播播放技術(shù)樟凄,模擬服務(wù)器和客戶端的交互的過程聘芜,所以我們?cè)诒镜亟⒁粋€(gè)http服務(wù)器,讓HLS訪問本地的http服務(wù)器就可以播放了缝龄,下面看看具體的實(shí)現(xiàn)過程
*建立本地的http服務(wù)器
這里我們使用iOS端很有名也很好用的CocoaHTTPServer第三方庫建立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(@"開啟HTTP服務(wù)器 端口:%hu",[self.httpServer listeningPort]);
}
else{
NSLog(@"服務(wù)器啟動(dòng)失敗錯(cuò)誤為:%@",error);
}
}
注意:
1瞎饲、[self.httpServer setPort:9479];這里是設(shè)置服務(wù)器端口,端口號(hào)寫一個(gè)不容易重復(fù)的即可炼绘,避免用戶手機(jī)其他APP也建立了端口號(hào)一樣的服務(wù)器嗅战,導(dǎo)致服務(wù)器建立失敗,或者數(shù)據(jù)混亂俺亮,另外用模擬器在本地建立的服務(wù)器驮捍,是直接建立的mac上的疟呐,可以把播放鏈接直接給vlc打開播放;
2东且、[self.httpServer setDocumentRoot:webPath];這一步在給服務(wù)器設(shè)置路徑的時(shí)候启具,一定要注意和緩存TS數(shù)據(jù)的路徑一致;
3珊泳、解碼工具類中使用了一些定時(shí)器富纸,小伙伴們?cè)谑褂玫臅r(shí)候,要記得聲明一個(gè)銷毀解碼工具類的方法旨椒,在這個(gè)方法里銷毀定時(shí)器等晓褪,避免頁面無法銷毀的bug。
*播放
服務(wù)器頁建立好了综慎,那么播放鏈接是什么呢涣仿?懂一些網(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打開钧汹,不僅可以播放,還可以調(diào)整進(jìn)度录择。當(dāng)下載了一些文件后拔莱,退出APP,即使在沒有網(wǎng)絡(luò)的情況下打開隘竭,也可以正常播放塘秦,如圖:
尾巴
到這里我們已經(jīng)實(shí)現(xiàn)了M3U8直播的回看和下載,DEMO下載地址:Demo动看。
本文為小伙伴們提供了一種思路尊剔,整個(gè)實(shí)現(xiàn)過程還是有些復(fù)雜的,需要小伙伴們反復(fù)理解菱皆,當(dāng)然有一定的音視頻開發(fā)技術(shù)理解起來就簡(jiǎn)單多了须误,本文并沒有對(duì)M3U8做過多技術(shù)講解,這方面的知識(shí)可以查閱蘋果官方文檔:HLS蘋果官方資料<small></small>仇轻,這里只是挑出一些問題講解一下京痢,最終能否理解還要靠小伙伴們自己的努力,若在文中發(fā)現(xiàn)錯(cuò)誤請(qǐng)及時(shí)指正拯田,感激不盡历造。