iOS流媒體開(kāi)發(fā)之三:HLS直播(M3U8)回看和下載功能的實(shí)現(xiàn)

尊重知識(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)度每一步。

HLS下載流程
*解碼

解碼這一步就做一件事情,拿到播放鏈接,讀取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文件下載過(guò)程
TS文件下載過(guò)程

播放

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ī)播放
VLC播放

尾巴

到這里我們已經(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í)指正,感激不盡。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容