AVPlayer邊下邊播

關(guān)于邊下邊播功能目前流傳的版本大體相同,本篇文章主要介紹另一種可行的實(shí)現(xiàn)方式佳鳖。

關(guān)于AVPlayer在這里我們不做詳細(xì)解釋霍殴,如果你是剛剛開始接觸AVPlayer,不妨先看看下面兩篇文章:

iOS視頻播放詳解1-基本使用
iOS視頻播放詳解2-封裝邊下邊播的播放器
“你這篇都這么長(zhǎng)系吩,還讓我看另外兩篇繁成,太TM蛋疼了,我能不能不看淑玫?”
嗯...頭皮硬的話...你隨意巾腕。??

如果上面兩篇還沒有研究懂的話,出于人道主義關(guān)懷絮蒿,建議還是不要繼續(xù)往下看了尊搬,頭皮真的會(huì)變硬。
講真土涝,從開始了解AVPlayer到寫出這篇文章前前后后歷時(shí)近兩個(gè)月(還不是因?yàn)閼校┓鹗伲韵胍私釧VPlayer的兄dei姐妹別急,慢慢來但壮。

一冀泻、流程

下面這張圖是iOS視頻播放詳解2-封裝邊下邊播的播放器中提供的流程(下文稱流程A),至于詳細(xì)解釋文中也已經(jīng)寫的非常清晰蜡饵,這里之所以要貼出來就是為了讓大家回憶一下弹渔,因?yàn)楸疚囊榻B的流程正是在這個(gè)流程的基礎(chǔ)上變種而來。(代碼基因突變你見過嗎溯祸?)

流程A

接下來肢专,我想確認(rèn)一下舞肆,應(yīng)該沒有人叫黑板的吧。
我要敲黑板了博杖,咳咳...

從流程A來看椿胯,我們所需要解決的問題只有兩個(gè)
  1. 如何提供數(shù)據(jù)給播放器?
    如果緩存中有數(shù)據(jù)使用緩存播放剃根,如果沒有則開啟下載使用網(wǎng)絡(luò)數(shù)據(jù)哩盲。

  2. 何時(shí)從網(wǎng)絡(luò)下載數(shù)據(jù),怎么下載以及數(shù)據(jù)緩存策略狈醉?
    當(dāng)本地沒有緩存文件或者當(dāng)前請(qǐng)求offset大于或者小于已經(jīng)下載的offset時(shí)從offset開始到文件尾向服務(wù)器發(fā)起請(qǐng)求种冬,只要代理重新向服務(wù)器發(fā)起請(qǐng)求,就會(huì)導(dǎo)致緩存的數(shù)據(jù)不連續(xù)舔糖,則加載結(jié)束后不用將緩存的數(shù)據(jù)放入本地cache

從這兩個(gè)問題出發(fā)娱两,我們可以將為播放器提供數(shù)據(jù)與下載數(shù)據(jù)拆分為兩個(gè)單獨(dú)的部分。而播放的過程中一定是有緩存文件的金吗,只不過文件不一定完整十兢,如果無論本地是否有完整的緩存文件存在,播放器的數(shù)據(jù)全部來自緩存而不直接來自網(wǎng)絡(luò)會(huì)怎么樣呢摇庙?且看下圖:
基因突變產(chǎn)物

resourceLoaderDelegate:向dataManager請(qǐng)求數(shù)據(jù)旱物,并且使用dataManager返回的數(shù)據(jù)填充loadingRequest。

dataManager:從本地緩存中提供數(shù)據(jù)卫袒,如果遇到緩存中沒有的數(shù)據(jù)則計(jì)算緩存中從指定offset開始缺失的數(shù)據(jù)范圍并通知downloader下載對(duì)應(yīng)范圍數(shù)據(jù)宵呛,并且將downloader下載的數(shù)據(jù)存入緩存等待推送。(注意夕凝,dataManager并不會(huì)直接使用從downloader返回的數(shù)據(jù)宝穗,downloader下載的數(shù)據(jù)只會(huì)被存入緩存文件。)

downloader:下載指定范圍數(shù)據(jù)码秉。

基于上面的流程逮矛,我們?cè)賮砘卮鹨幌逻@兩個(gè)問題:
如何提供數(shù)據(jù)給播放器?

resourceLoaderDelegate接收到loadingRequest之后通過計(jì)算得出所需數(shù)據(jù)起始位置转砖,并且將起始位置偏移量傳遞給dataManager须鼎。dataManager接收到偏移量之后查找到對(duì)應(yīng)的緩存文件,并且開始定速定量推送數(shù)據(jù)給resourceLoaderDelegate府蔗,循環(huán)往復(fù)晋控,直至數(shù)據(jù)末尾蜗顽。(注:一段完整的視頻可能分為多個(gè)文件存儲(chǔ)亥曹。)

注: 在此過程中沒有直接使用任何網(wǎng)絡(luò)數(shù)據(jù)礁遣,全部數(shù)據(jù)從緩存中獲得屋摔。

何時(shí)從網(wǎng)絡(luò)下載數(shù)據(jù),怎么下載以及數(shù)據(jù)緩存策略仲墨?

dataManager在推送數(shù)據(jù)的過程中勋磕,如果發(fā)現(xiàn)緩存中沒有需要的數(shù)據(jù)片段則計(jì)算出從offset開始緩存中缺失的最短數(shù)據(jù)范圍并且通知downloader下載悼枢,downloader下載好指定范圍數(shù)據(jù)返回給dataManager狂男,dataManager接收到下載的數(shù)據(jù)存入緩存等待下一次數(shù)據(jù)推送讀取综看。取決于下載起始偏移量的值downloader下載的數(shù)據(jù)可能有兩種存儲(chǔ)方式即拼接在已有文件之后(下載起始偏移量與已有文件結(jié)束位置相同)或者新建文件存儲(chǔ)(下載起始偏移量與已有文件結(jié)束位置不同)。

看著很簡(jiǎn)單是不是岖食,簡(jiǎn)單不簡(jiǎn)單代碼說了算hh...
其實(shí)在碼代碼的整個(gè)過程中I have questioned the feasibility of this method more than once. 坑太TM多了:毂!泡垃!

  1. 在整個(gè)播放過程中析珊,可能不止一個(gè)loadingRequest,resourceLoaderDelegate如何得出下一次所需的數(shù)據(jù)起始位置蔑穴?
  2. 如何解決讀取大文件時(shí)的內(nèi)存暴漲問題忠寻?
  3. dataManager既然是推送數(shù)據(jù)給resourceLoaderDelegate,以什么樣的方式推送合適存和?
  4. 如何避免阻塞主線程奕剃?
  5. 如何避免downloader重復(fù)下載?
  6. 由于數(shù)據(jù)可能是分段下載捐腿,如何保證數(shù)據(jù)完整性以及正確性?
    ...

在開始之前首先展示一下成果纵朋,堅(jiān)定一下各位繼續(xù)往下讀的信心(信心爆棚的同學(xué)請(qǐng)看屏幕右側(cè)滾動(dòng)條)。


緩存文件

請(qǐng)忽略上圖中的正在等待下載茄袖,說多了都是淚操软。


plist文件結(jié)構(gòu)

What are you 弄啥嘞 (╯‵□′)╯︵┻━┻

這樣做有什么好處呢?
首先類的分工更加明確宪祥,有利于寫代碼聂薪。
其次由于我們是分多個(gè)文件存儲(chǔ)視頻,所以在播放時(shí)只需要下載緩存中沒有的數(shù)據(jù)即可蝗羊,避免了數(shù)據(jù)的重復(fù)下載胆建,節(jié)省流量。
這兩點(diǎn)應(yīng)該是能看得到的最大的好處了吧肘交。

啰啰嗦嗦這么多笆载,接下來我們就開始愉快的擼代碼吧。


二涯呻、實(shí)現(xiàn)

創(chuàng)建assets以及resourceLoaderDelegate

我們要通過AVURLAssets創(chuàng)建AVPlayer凉驻,這樣開始播放視頻時(shí)AVPlayer會(huì)向AVURLAssets請(qǐng)求數(shù)據(jù),而AVURLAssets就會(huì)根據(jù)創(chuàng)建時(shí)我們提供的URL請(qǐng)求數(shù)據(jù)并提供給AVPlayer播放复罐。
到這里為止涝登,好像我們并沒有我們操作的空間,而我們需要能夠在AVPlayer播放的過程中操縱視頻數(shù)據(jù)效诅,這時(shí)我們就需要借助AVURLAssets的resourceLoaderDelegate幫忙胀滚。

resourceLoaderDelegate對(duì)于AVURLAssets來說有什么用呢趟济?
如果我們提供給AVURLAssets的URL是它能夠識(shí)別的URL,那么AVURLAssets就會(huì)自己開始播放咽笼,而當(dāng)我們提供一個(gè)AVURLAssets不能識(shí)別的URL時(shí)顷编,為了能夠正常播放,AVURLAssets就會(huì)詢問resourceLoaderDelegate:
AVURLAssets:兄弟剑刑,這個(gè)地址我播不了媳纬,你能不能播?
resourceLoaderDelegate:哥施掏,你這話問的钮惠,那必須能啊七芭!
AVURLAssets:好的素挽,我需要xxx字節(jié)-xxx字節(jié)的數(shù)據(jù),謝謝狸驳!
resourceLoaderDelegate:ojbk;倭狻(說出來有點(diǎn)傷感,害你播不了的地址就是我偷偷換的)
...

為了我們的resourceLoaderDelegate能夠成功上位锌历,我們需要對(duì)URL做一些手腳贮庞,把原始地址的scheme進(jìn)行更改,至于改什么那就看你的個(gè)人喜好了究西,比如gblw什么的窗慎,隨心所欲,你開心就好卤材。

首先遮斥,我們需要?jiǎng)?chuàng)建SEEResourceLoaderDelegate,基于高內(nèi)聚低耦合原則(我瞎說的)我們?yōu)镾EEResourceLoaderDelegate添加一個(gè)對(duì)象方法扇丛,用于返回一個(gè)綁定當(dāng)前對(duì)象為resourceLoaderDelegate的AVURLAssets對(duì)象术吗。

//SEEResourceLoaderDelegate.h
#import <Foundation/Foundation.h>
@class AVURLAsset;

NS_ASSUME_NONNULL_BEGIN

@interface SEEResourceLoaderDelegate : NSObject

- (AVURLAsset *)assetWithURL:(NSURL *)url;

@end

NS_ASSUME_NONNULL_END

//SEEResourceLoaderDelegate.m
- (AVURLAsset *)assetWithURL:(NSURL *)url {
    [self.loadingRequests removeAllObjects];
    _url = url;
    NSURLComponents * components = [NSURLComponents componentsWithURL:url resolvingAgainstBaseURL:YES];
    //替換scheme
    components.scheme = @"seeplayer";
    NSURL * target = [components URL];
    //使用替換后的url創(chuàng)建AVURLAssets
    AVURLAsset * asset = [AVURLAsset assetWithURL:target];
    //為了防止阻塞主線程,我們新建一個(gè)串行隊(duì)列來接收代理回調(diào)
    [asset.resourceLoader setDelegate:self queue:self.queue];
    return asset;
}
/**
 這個(gè)隊(duì)列用于執(zhí)行接收播放器發(fā)出的resourceLoader帆精、下載器接收回調(diào)较屿、文件管理器推送數(shù)據(jù)
 @return 串行隊(duì)列
 */
- (dispatch_queue_t)queue {
    if (_queue) {
        return _queue;
    }
    _queue = dispatch_queue_create("player", DISPATCH_QUEUE_SERIAL);
    return _queue;
}

根據(jù)我多年的經(jīng)驗(yàn),很少有人能夠把文章里面貼的代碼完整讀完的卓练,別問我為什么知道隘蝎,我就是大部分人里面光榮的一員。
幸會(huì)幸會(huì)...
所以每當(dāng)貼一段代碼我都會(huì)緊跟著對(duì)貼的代碼做一個(gè)簡(jiǎn)單的解釋襟企,簡(jiǎn)直不要太貼心嘱么。
上面的代碼中我們將原始URL進(jìn)行了記錄,并且使用處理后的URL創(chuàng)建了AVURLAssets對(duì)象并且將自己作為AVURLAssets對(duì)象的resourceLoaderDelegate顽悼,為了防止阻塞主隊(duì)列曼振,我們創(chuàng)建了一個(gè)新的串行隊(duì)列來作為回調(diào)隊(duì)列几迄。


創(chuàng)建player
//2 重新設(shè)置item
AVPlayerItem * newItem = [AVPlayerItem playerItemWithAsset:[_resourceLoaderDelegate assetWithURL:url] automaticallyLoadedAssetKeys:@[@"duration",@"preferredRate",@"preferredVolume",@"preferredTransform"]];
//3 播放器播放新url之前清除上一次的監(jiān)聽等
[self see_clearPlayer];
//4 設(shè)置新的item
[_player replaceCurrentItemWithPlayerItem:newItem];
//5 添加對(duì)播放器的監(jiān)聽
[self see_preparePlayer];

以上代碼我們通過AVURLAssets創(chuàng)建AVPlayerItem,并且將player的item進(jìn)行替換冰评。
接下來映胁,我們需要實(shí)現(xiàn)AVAssetResourceLoaderDelegate中定義的代理方法來接收loadingRequest。


實(shí)現(xiàn)AVAssetResourceLoaderDelegate方法

每當(dāng)assets發(fā)出一個(gè)loadingRequest我們就會(huì)在
- (BOOL)resourceLoader:(AVAssetResourceLoader *)resourceLoader shouldWaitForLoadingOfRequestedResource:(AVAssetResourceLoadingRequest *)loadingRequest
中接收到集索,如果我們可以加載loadingRequest中指定的數(shù)據(jù)片段返回YES屿愚,不能則返回NO汇跨。
這還用說务荆?當(dāng)然返回YES,否則我們?cè)谧鍪裁础?/p>

- (BOOL)resourceLoader:(AVAssetResourceLoader *)resourceLoader shouldWaitForLoadingOfRequestedResource:(AVAssetResourceLoadingRequest *)loadingRequest {
    //將request添加進(jìn)數(shù)組記錄穷遂,得到數(shù)據(jù)后進(jìn)行填充
    [self.loadingRequests addObject:loadingRequest];
    return YES;
}

每當(dāng)?shù)玫揭粋€(gè)loadingRequest之后函匕,我們需要對(duì)其中需要的數(shù)據(jù)進(jìn)行填充,而我們得到這個(gè)loadingRequest中所需數(shù)據(jù)的時(shí)間不確定蚪黑,因此我們需要將其存儲(chǔ)起來盅惜,等待得到數(shù)據(jù)之后再向其中填充數(shù)據(jù)。

- (void)resourceLoader:(AVAssetResourceLoader *)resourceLoader didCancelLoadingRequest:(AVAssetResourceLoadingRequest *)loadingRequest {
    //移除被取消的loadingRequest
    [self.loadingRequests removeObject:loadingRequest];
}

當(dāng)一個(gè)loadingRequest被取消后忌穿,我們?cè)賹?duì)其進(jìn)行數(shù)據(jù)填充是無意義的抒寂,所以我們需要將存儲(chǔ)的loadingRequest從數(shù)組中移除不再進(jìn)行數(shù)據(jù)填充。

通過上面一波騷氣的操作掠剑,我們已經(jīng)成功的得到了播放數(shù)據(jù)的掌控權(quán)屈芜,接下來我們需要考慮的就是如何得從我們得到的一堆loadingRequest中找到播放需要的數(shù)據(jù)的起始位置。


數(shù)據(jù)起始位置查找

正常播放狀態(tài)下朴译,當(dāng)我們每完成一個(gè)loadingRequest井佑,播放器會(huì)重新發(fā)出一個(gè)或者多個(gè)loadingRequest來請(qǐng)求后續(xù)的數(shù)據(jù)。
如果用戶拖動(dòng)進(jìn)度條前進(jìn)或者后退眠寿,播放器同樣會(huì)發(fā)出新的loadingRequest躬翁,而此時(shí)我們之前的loadingRequest并沒有填充完成。
基于上述兩點(diǎn)盯拱,我們發(fā)現(xiàn)播放器最新發(fā)出的loadingRequest請(qǐng)求的一定是接下來播放所需要的數(shù)據(jù)片段盒发。
所以我們只需要找到loadingRequest數(shù)組中最后一個(gè)元素也就是最新發(fā)出的loadingRequest,并且以它的currentOffset為基準(zhǔn)通知dataManager狡逢,當(dāng)dataManager返回?cái)?shù)據(jù)之后進(jìn)行填充即可迹辐。

- (long long)see_expectOffset {
    //獲取最新的loadingRequest的currentOffset即可
    if (self.loadingRequests.count != 0) {
        return ((AVAssetResourceLoadingRequest *)self.loadingRequests.lastObject).dataRequest.currentOffset;
    }
    return 0;
}

說實(shí)話,為了這幾行代碼嘔心瀝血好幾天...

需要的數(shù)據(jù)的起始位置我們已經(jīng)得到了甚侣,接下來就是dataManager獲取數(shù)據(jù)明吩、推送數(shù)據(jù)。


創(chuàng)建dataManager

既然我們會(huì)將視頻緩存到本地殷费,那么我們用什么來判斷當(dāng)前正在播放的視頻有沒有緩存呢印荔?
在播放一個(gè)視頻之前低葫,我們唯一知道的信息就是播放地址,而視頻的播放地址又具有唯一性仍律,這樣看來URL是我們用來判斷是否擁有緩存的不二選擇嘿悬。因此我們需要利用URL來初始化dataManager,而dataManager會(huì)將數(shù)據(jù)推送出去水泉,因此我們還需要一個(gè)代理作為數(shù)據(jù)的接收者善涨。

- (instancetype)initWithURL:(NSURL *)url delegate:(id<SEEDataManagerDelegate>)delegate {
    if (self = [super init]) {
        _prepareData = [SEEData mutableData];
        _url = url;
        _cacheBasePath = NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES).lastObject;
        _fileInfo = [[SEEFileInfo alloc]initWithURL:url];
        _totalBytes = _fileInfo.fileAttribute.totalBytes;
        _MIMEType = _fileInfo.fileAttribute.MIMEType;
        _inputStream = [[SEEInputStream alloc]init];
        _inputFile = [_fileInfo fileForOffset:0];
        if (_inputFile)[_inputStream setFileAtPath:[_cacheBasePath stringByAppendingPathComponent:_inputFile.path]];
        _downloader = [[SEEDownloader alloc]initWithURL:url delegate:self];
        _cacheRanges = [NSMutableArray array];
        if (_fileInfo.files.count) {
            [_fileInfo.files enumerateObjectsUsingBlock:^(SEEFile * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
                NSRange range = NSMakeRange((NSUInteger)obj.startOffset,(NSUInteger)obj.endOffset);
                [self->_cacheRanges addObject:[NSValue valueWithRange:range]];
            }];
        }
        [self see_postRanges];
        [self setDelegate:delegate];
    }
    return self;
}

ヾ(?`Д′?)我擦,毛啊草则,怎么一下就初始化了這么多東西钢拧,這TM什么鬼?
別急炕横,我慢慢解釋給大家聽源内。
_url : 播放地址,用來查找本地緩存以及下載數(shù)據(jù)份殿。
_cacheBasePath: 緩存目錄膜钓,所有的緩存文件全部放在這個(gè)目錄下。
_fileInfo: 緩存文件信息卿嘲,包括當(dāng)前視頻信息以及每一個(gè)緩存文件信息颂斜。
_inputStream: 輸入流,從緩存文件讀取數(shù)據(jù)拾枣,防止大文件讀取時(shí)的內(nèi)存保障沃疮。
_inputFile: 當(dāng)前正在輸入的文件信息。
_downloader: 下載器放前。
_cacheRanges: 已經(jīng)緩存的數(shù)據(jù)的范圍忿磅。
_prepareData: 推送過程中的數(shù)據(jù)緩存,用于存放正在推送的數(shù)據(jù)凭语。
以上的所有成員變量的作用在后文中對(duì)應(yīng)部分會(huì)進(jìn)行詳細(xì)講解葱她,這里我們先簡(jiǎn)單了解一下即可。

這樣我們?cè)趓esourceLoaderDelegate的- (AVURLAsset *)assetWithURL:(NSURL *)url方法中將dataManager一并創(chuàng)建即可似扔。


讀取大文件

在我們的設(shè)計(jì)中吨些,dataManager只能從本地緩存中讀取數(shù)據(jù),那么問題來了炒辉,假設(shè)本地已經(jīng)有了數(shù)據(jù)豪墅,我們?cè)撛趺醋x取才能既保證效率又避免內(nèi)存暴漲呢?

NSData:如果直接使用NSData進(jìn)行數(shù)據(jù)讀取的話黔寇,在整個(gè)播放過程中偶器,每次獲取數(shù)據(jù)都要將全部文件讀入內(nèi)存,然后找到對(duì)應(yīng)的數(shù)據(jù)返回∑梁洌總感覺這種代碼下不了手颊郎。pass!

NSFileHandle:傳說中能夠解決讀入大文件時(shí)的內(nèi)存暴漲問題(在Stack Overflow上看到有人說并不能)霎苗,但是姆吭,對(duì)不起,每次都要自己算偏移量有點(diǎn)惡心唁盏。pass!

NSInputStream: 單向流内狸,不用操心偏移量,可以控制每次讀取數(shù)據(jù)量大小厘擂,但是讀取過的數(shù)據(jù)不能再次讀取昆淡。嗯...最后一點(diǎn)好像有點(diǎn)惡心,用戶拖拽播放器進(jìn)度條之后好像需要能夠從指定offset讀取數(shù)據(jù)驴党,不過我們可是程序員啊怕個(gè)錘子瘪撇,改获茬!
哥港庄,你眼角怎么濕了...
你懂個(gè)錘子,這是姓 福 的眼淚恕曲!

以下解決方案僅供參考鹏氧,智商壓制,實(shí)在想不出什么更好的辦法佩谣,如果你有把还,請(qǐng)告訴我,作為回報(bào)茸俭,我可以把我潛心研究多年的葵花寶典心得傳授給你吊履。
哎,憋走啊调鬓。雖然欲練此功必先自宮艇炎,但是如不自宮亦可成功的啊親!

思路是這樣的腾窝,首先每次讀取數(shù)據(jù)時(shí)我們通過計(jì)算得出讀取之后的offsetA缀踪,下一次讀取數(shù)據(jù)時(shí)首先判斷要讀取的數(shù)據(jù)起始位置offsetB和當(dāng)前stream讀取到的位置offsetA是否一致,如果一致則繼續(xù)讀取指定長(zhǎng)度返回虹脯,如果不一致關(guān)閉當(dāng)前stream重新創(chuàng)建相同path的stream驴娃,然后一直讀取數(shù)據(jù)到offsetB位置之后再讀取指定長(zhǎng)度數(shù)據(jù)返回。

又見代碼:

@interface SEEInputStream: NSObject

- (void)setFileAtPath:(NSString *)path;

- (NSInteger)read:(uint8_t *)buffer maxLength:(NSUInteger)len startOffset:(long long)startOffset;

@end

@implementation SEEInputStream {
    long long _currentOffset;
    NSString * _path;
    NSInputStream * _stream;
}

- (void)setFileAtPath:(NSString *)path {
    //關(guān)閉之前打開的文件
    [self close];
    _currentOffset = 0;
    _path = path;
    _stream = [[NSInputStream alloc]initWithFileAtPath:path];
    [self open];
}
//重新打開當(dāng)前文件
- (void)resetStream {
    [self close];
    _stream = [NSInputStream inputStreamWithFileAtPath:_path];
    [self open];
    _currentOffset = 0;
}

- (void)open {
    if (!_stream)return;
    [_stream open];
}

- (void)close {
    if (!_stream)return;
    [_stream close];
    _stream = nil;
}
//從stream中讀取數(shù)據(jù)
- (NSInteger)read:(uint8_t *)buffer maxLength:(NSUInteger)len {
    NSInteger readBytes = [_stream read:buffer maxLength:len];
    if (readBytes == -1 || readBytes == 0) {
        NSLog(@"%@",_stream.streamError);
    }
    _currentOffset += readBytes;
    return readBytes;
}
//讀取指定位置數(shù)據(jù)
- (NSInteger)read:(uint8_t *)buffer maxLength:(NSUInteger)len startOffset:(long long)startOffset {
    if (startOffset < _currentOffset) {
        //如果起始位置小于當(dāng)前讀取到的位置則重新打開stream
        [self resetStream];
        return [self read:buffer maxLength:len startOffset:startOffset];
    }
    else if (startOffset > _currentOffset) {
        //如果讀取到的位置小于讀取的起始位置則一直讀取數(shù)據(jù)直到startOffset == _currentOffset
        int loopCount = (int)((startOffset - _currentOffset) / len);
        for (int i = 0; i <= loopCount; i++) {
            if (i == loopCount) {
                NSUInteger bufferLength = (startOffset - _currentOffset) % len;
                if (bufferLength == 0) break;
                uint8_t buffer[bufferLength];
                [self read:buffer maxLength:bufferLength];
            }
            else {
                uint8_t buffer[len];
                [self read:buffer maxLength:len];
            }
        }
        return [self read:buffer maxLength:len startOffset:startOffset];
    }
    else {
        //讀取數(shù)據(jù)返回
        return [self read:buffer maxLength:len];
    }
}

@end

以上代碼循集,我們將對(duì)于InputStream的管理放在SEEInputStream類中唇敞,外界只需要設(shè)置需要讀取的文件目錄,然后只管盡情的讀取數(shù)據(jù)即可。

那讀取大文件的問題解決了疆柔,問題是我們的文件是分段存儲(chǔ)的蕉世,我怎么知道我該從哪個(gè)文件里面讀取數(shù)據(jù)。
別急婆硬,_fileInfo告訴你狠轻。


文件信息管理

每當(dāng)我們準(zhǔn)備讀取數(shù)據(jù)時(shí)只有一個(gè)offset告訴我們?cè)撟x取的數(shù)據(jù)的起始位置,但是究竟這段數(shù)據(jù)有沒有彬犯,有的話存儲(chǔ)在哪里向楼。這些我們目前還是一臉懵逼的。所以我們需要借助一個(gè)類來幫助我們管理這些瑣事谐区。這樣每當(dāng)dataManager得到一個(gè)offset就問這個(gè)類:“兄弟湖蜕,快告訴我這段數(shù)據(jù)在哪個(gè)文件里面”。然后就去對(duì)應(yīng)的文件讀取數(shù)據(jù)宋列。這也是這個(gè)類的設(shè)計(jì)初衷昭抒。

既然我們?cè)诒镜鼐彺媪藬?shù)據(jù),那總不能每次要播放時(shí)都請(qǐng)求網(wǎng)絡(luò)獲取MIME類型炼杖,數(shù)據(jù)大小等信息吧灭返,否則無網(wǎng)絡(luò)情況下就會(huì)出現(xiàn)本地有緩存而因?yàn)槿鄙龠@些數(shù)據(jù)導(dǎo)致不能播放的問題,所以我們需要存儲(chǔ)這些數(shù)據(jù)坤邪。

每當(dāng)dataManager得到一個(gè)offset時(shí)都會(huì)來詢問它這些數(shù)據(jù)在那個(gè)文件里面熙含,那首先它得知道目前的緩存文件有多少,每個(gè)文件存儲(chǔ)的數(shù)據(jù)是哪一段的數(shù)據(jù)艇纺,多以還需要存儲(chǔ)每個(gè)文件的文件名怎静,數(shù)據(jù)起始位置,數(shù)據(jù)結(jié)束位置黔衡,文件長(zhǎng)度等信息蚓聘。

由此我們的類被設(shè)計(jì)成了這樣

@interface SEEFileAttribute: NSObject
//MIME類型
@property (nonatomic, copy) NSString * MIMEType;
//總數(shù)據(jù)量
@property (nonatomic, assign) long long  totalBytes;
//已經(jīng)下載的數(shù)據(jù)總量
@property (nonatomic, assign) long long cacheBytes;
//緩存是否完成  isComplete = totalBytes == cacheBytes
@property (nonatomic, assign) BOOL isComplete;
//文件名
@property (nonatomic, copy) NSString * exceptFileName;

@end

@interface SEEFile: NSObject
//緩存文件名
@property (nonatomic, copy) NSString * path;
//緩存文件起始位置
@property (nonatomic, assign) long long  startOffset;
//緩存文件結(jié)束位置
@property (nonatomic, assign) long long  endOffset;
//緩存文件長(zhǎng)度
@property (nonatomic, assign) NSUInteger  length;

@end

@interface SEEFileInfo: NSObject

@property (nonatomic, strong) SEEFileAttribute * fileAttribute;

@property (nonatomic, strong) NSMutableArray <SEEFile *> * files;

- (instancetype)initWithURL:(NSURL *)url;

@end

這樣,這個(gè)類就掌握了本地緩存文件的所有信息盟劫,接下來我們需要他可以根據(jù)offset提供給我們對(duì)應(yīng)的文件信息:

- (SEEFile *)fileForOffset:(long long)offset {
    __block SEEFile * file = nil;
    self.missingEndOffset = self.fileAttribute.totalBytes;
    [self.files enumerateObjectsUsingBlock:^(SEEFile * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
        if (obj.startOffset <= offset && obj.endOffset > offset) {
            file = obj;
            *stop = YES;
        }
    }];
    return file;
}

通過遍歷緩存文件夜牡,并且使用offset與每個(gè)文件進(jìn)行比較得到包含offset的文件并返回。


讀取并持有數(shù)據(jù)等待推送

dataManager在接到offset之后就可以通過以下代碼來讀取對(duì)應(yīng)的數(shù)據(jù)了捞高。

//如果當(dāng)前訪問的文件中包含后續(xù)數(shù)據(jù)則在當(dāng)前文件中讀取
if (_inputFile && _inputFile.endOffset > startOffset && _inputFile.startOffset <= startOffset) {
    [self see_prepareDataFormCache:startOffset];
    return;
}
//如果當(dāng)前訪問的文件中不包含后續(xù)數(shù)據(jù)則重新查找對(duì)應(yīng)文件
_inputFile = [_fileInfo fileForOffset:startOffset];
if (_inputFile){
    [_inputStream setFileAtPath:[_cacheBasePath stringByAppendingPathComponent:_inputFile.path]];
    [self see_prepareDataFormCache:startOffset];
    return;
}

由于NSInputStream在讀取數(shù)據(jù)時(shí)必須指定長(zhǎng)度氯材,因此,我們讀取到的數(shù)據(jù)不一定會(huì)在一次推送中被全部接收硝岗,有可能只是接收了其中的一部分氢哮,而另一部分會(huì)在接下來的一次或多次推送中才被接收(取決于loadingRequest),因此需要將讀取到的數(shù)據(jù)保存在內(nèi)存中持續(xù)推送型檀,直到數(shù)據(jù)全部被接收(通過每次推送完成resourceLoaderDelegate返回的offset來判斷數(shù)據(jù)是否被接收)為止冗尤。

在此過程中,我們不僅要保證存儲(chǔ)的數(shù)據(jù)在使用之前被釋放,而且還要保證數(shù)據(jù)的正確性裂七,并且為數(shù)據(jù)的接收者提供本段數(shù)據(jù)的起始位置皆看、結(jié)束位置等信息,以保證接受者可以根據(jù)這些信息正確的使用這些數(shù)據(jù)背零。

為了滿足這些需求腰吟,我們選擇使用一個(gè)專門的類來管理帶推送的數(shù)據(jù)。

@interface SEEData: NSObject
//數(shù)據(jù)起始位置
@property (nonatomic, assign, readonly) long long location;
//數(shù)據(jù)長(zhǎng)度
@property (nonatomic, assign, readonly) NSUInteger  length;
//數(shù)據(jù)結(jié)束位置
@property (nonatomic, assign, readonly) long long end;
//數(shù)據(jù)
@property (nonatomic, strong, readonly) __kindof NSData * data;

+ (instancetype)dataWithLocation:(long long)location lenght:(NSUInteger)length data:(__kindof NSData *)data;

/**
 拼接數(shù)據(jù)
 只有通過mutableData創(chuàng)建的對(duì)象可以使用該方法
 @param data 數(shù)據(jù)
 @param length 長(zhǎng)度
 */
- (void)appendData:(const void *)data length:(NSUInteger)length;

/**
 初始化指定offset之前的數(shù)據(jù)
 只有通過mutableData創(chuàng)建的對(duì)象可以使用該方法
 @param offset offset
 */
- (void)initOffset:(long long)offset;

//創(chuàng)建的對(duì)象data不可變
+ (instancetype)data;

//創(chuàng)建的對(duì)象data為可變
+ (instancetype)mutableData;

- (id)copy;

- (id)mutableCopy;

- (BOOL)isEqual:(id)object;

@end

@interface SEEData()

@property (nonatomic, assign) long long location;

@property (nonatomic, assign) NSUInteger  length;

@property (nonatomic, assign) long long end;

@property (nonatomic, strong) __kindof NSData * data;

@end

@implementation SEEData

+ (instancetype)dataWithLocation:(long long)location lenght:(NSUInteger)length data:(__kindof NSData *)data {
    SEEData * instance = [[SEEData alloc]init];
    instance.location = location;
    instance.length = length;
    instance.data = data;
    return instance;
}

+ (instancetype)data {
    return [self dataWithLocation:0 lenght:0 data:[NSData data]];
}

+ (instancetype)mutableData {
    return [self dataWithLocation:0 lenght:0 data:[NSMutableData data]];
}

- (void)appendData:(const void *)buffer length:(NSUInteger)length {
    [((NSMutableData *)_data) appendBytes:buffer length:length];
    self.length += length;
}

- (void)initOffset:(long long)offset {
    if (offset == _location) return;
    long long initLength = offset - _location;
    _location = offset;
    if (initLength < 0 || initLength > _length) {
        //如果初始化的位置不在當(dāng)前數(shù)據(jù)片段內(nèi)則清除當(dāng)前全部數(shù)據(jù)
        [((NSMutableData *)_data) replaceBytesInRange:NSMakeRange(0, _length) withBytes:NULL length:0];
        self.length = 0;
        return;
    }
    //清除指定offset之前的數(shù)據(jù)
    [((NSMutableData *)_data) replaceBytesInRange:NSMakeRange(0, initLength) withBytes:NULL length:0];
    self.length -= initLength;
}

- (id)copy {
    if ([self.data isMemberOfClass:[NSData class]]) {
        return [SEEData dataWithLocation:self.location lenght:self.length data:self.data];
    }
    else {
        return [SEEData dataWithLocation:self.location lenght:self.length data:[NSData dataWithData:self.data]];
    }
}

- (id)mutableCopy {
    if ([self.data isMemberOfClass:[NSData class]]) {
        return [SEEData dataWithLocation:self.location lenght:self.length data:[NSMutableData dataWithData:self.data]];
    }
    else {
        return [SEEData dataWithLocation:self.location lenght:self.length data:self.data];
    }
}

- (BOOL)isEqual:(id)object {
    if ([object isMemberOfClass:[self class]]) {
        SEEData * target = object;
        return self.location == target.location && self.length == target.length && [self.data isEqualToData:target.data];
    }
    else {
        return NO;
    }
}

- (void)setLength:(NSUInteger)length {
    _length = length;
    _end = _location + _length;
}

- (NSString *)description {
    return [NSString stringWithFormat:@"location %lld length %lu end %lld dataLength %lu",_location,_length,_end,_data.length];
}

@end

需要使用-initOffset:清除數(shù)據(jù)的情況:

  1. 當(dāng)用戶拖動(dòng)進(jìn)度條時(shí)徙瓶,可能導(dǎo)致我們目前存儲(chǔ)的數(shù)據(jù)不是播放器所需的數(shù)據(jù)毛雇。
  2. 當(dāng)一段數(shù)據(jù)已經(jīng)被接收之后,將接收過的數(shù)據(jù)清除減少內(nèi)存占用侦镇。

這樣我們就可以將準(zhǔn)備好等待推送的數(shù)據(jù)先存儲(chǔ)起來灵疮。

- (void)see_prepareDataFormCache:(long long)startOffset {
    uint8_t buffer[262144] = {};
    NSInteger readByte = [_inputStream read:buffer maxLength:262144 startOffset:startOffset - _inputFile.startOffset];
    [((NSMutableData *)_prepareData.data) appendBytes:buffer length:readByte];
    _prepareData.length += readByte;
}

數(shù)據(jù)有了,怎么推送給resourceLoaderDelegate呢壳繁?


數(shù)據(jù)推送方案

既然我們的dataManager是通過推送的方式來向其代理輸出數(shù)據(jù)的震捣,我們需要一個(gè)能夠穩(wěn)定的循環(huán)執(zhí)行指定代碼的方式并且要避免阻塞線程。
Excuse me闹炉? 你說什么蒿赢?for循環(huán)?
while剩胁?

while (alive) {
  spittingBlood(3);
}

咳咳... 你不是不忘了NSTimer诉植,時(shí)間可是一種神奇的東西祥国。(嚴(yán)肅臉)


如何使用Timer推送

在擼代碼之前首先要想清楚昵观,什么時(shí)候開始推送?什么時(shí)候結(jié)束推送舌稀?

如果我們?cè)诔跏蓟痙ataManager的時(shí)候就開始推送數(shù)據(jù)可以嗎啊犬?
可以是可以,但是這樣就比較low了壁查,dataManager初始化的時(shí)候可能播放器還沒有開始播放觉至,resourceLoaderDelegate還沒有接收到任何loadingRequest,然后dataManager就像個(gè)二傻子一樣瘋狂推送數(shù)據(jù)睡腿,空氣瞬間凝固语御,氣氛好像有那么一丟丟尷尬。

為了避免上述情況出現(xiàn)席怪,我們選擇在接收到loadingRequest之后再開始數(shù)據(jù)推送应闯,這樣即使播放完成之后我們終止了推送,用戶拖動(dòng)進(jìn)度條或者重新播放時(shí)播放器會(huì)再次發(fā)出loadingRequest挂捻,此時(shí)我們的推送會(huì)被再次開啟碉纺。

- (BOOL)resourceLoader:(AVAssetResourceLoader *)resourceLoader shouldWaitForLoadingOfRequestedResource:(AVAssetResourceLoadingRequest *)loadingRequest {

    //將request添加進(jìn)數(shù)組記錄,得到數(shù)據(jù)后進(jìn)行填充
    [self.loadingRequests addObject:loadingRequest];
    //dataManager開始準(zhǔn)備推送數(shù)據(jù)
    [_dataManager begin];
    return YES;
}

當(dāng)全部數(shù)據(jù)推送完成、播放另一個(gè)url或者播放器銷毀時(shí)則需要將推送終止骨田,也就是將timer移除耿导。

既然這樣,那我們就開始吧:

- (void)begin {
    _timer = [NSTimer timerWithTimeInterval:0.1 target:self selector:@selector(see_pushData:) userInfo:nil repeats:YES];
    [[NSRunLoop currentRunLoop] addTimer:_timer forMode:NSRunLoopCommonModes];
    [[NSRunLoop currentRunLoop] run];
}

你以為這樣就可以了态贤?
too young too simple.
運(yùn)行代碼就會(huì)發(fā)現(xiàn)舱呻,當(dāng)我們的執(zhí)行[[NSRunLoop currentRunLoop] run]之后,后面的代碼并沒有執(zhí)行悠汽。導(dǎo)致resourceLoaderDelegate的代理方法沒有返回狮荔,而由于代理方法沒有返回所以播放器一直處在等待狀態(tài),不會(huì)發(fā)出新的loadingRequest無法播放介粘。
所以為什么會(huì)出現(xiàn)這中情況呢殖氏?
仔細(xì)閱讀官方文檔中給出的 run 方法的解釋

If no input sources or timers are attached to the run loop, this method exits immediately; otherwise, it runs the receiver in the NSDefaultRunLoopMode by repeatedly invoking runMode:beforeDate:. In other words, this method effectively begins an infinite loop that processes data from the run loop’s input sources and timers.

大意說run方法會(huì)重復(fù)調(diào)用runMode:beforeDate:方法,也就是啟動(dòng)了一個(gè)無限循環(huán)姻采,來處理Timer和source雅采。
另外官方文檔中還很貼心的給出了一個(gè)例子:

BOOL shouldKeepRunning = YES; // global
NSRunLoop *theRL = [NSRunLoop currentRunLoop];
while (shouldKeepRunning && [theRL runMode:NSDefaultRunLoopMode beforeDate:[NSDate distantFuture]]);

雖然這個(gè)例子并不是run方法的實(shí)現(xiàn),但是通過這個(gè)例子慨亲,我們大致可以猜測(cè)到run方法的實(shí)現(xiàn)應(yīng)該與其相似婚瓜。
所以我們的代碼變成了這樣:

_timer = [NSTimer timerWithTimeInterval:0.1 target:self selector:@selector(see_pushData:) userInfo:nil repeats:YES];
[[NSRunLoop currentRunLoop] addTimer:_timer forMode:NSDefaultRunLoopMode];
//[[NSRunLoop currentRunLoop] run];
while ([[NSRunLoop currentRunLoop] runMode:NSDefaultRunLoopMode beforeDate:[NSDate distantFuture]]);

那我們應(yīng)該怎么辦呢?
當(dāng)然是不要在當(dāng)前正在執(zhí)行的子線程添加Timer了刑棵。
可以選擇將timer添加進(jìn)主線程里面巴刻,但是如果我們的播放器是添加在TableViewCell上,這樣做還是有點(diǎn)心虛的蛉签,畢竟好像Timer回調(diào)執(zhí)行的代碼有點(diǎn)多...
所以我們選擇指定一個(gè)子線程來執(zhí)行Timer事件胡陪。

- (void)begin {
    if (self.state == SEEDataManagerStateInit) {
        self.state = SEEDataManagerStateBegin;
        _timerThread = [[NSThread alloc]initWithTarget:self selector:@selector(see_timerWithThread) object:nil];
        [_timerThread start];
    }
}

- (void)see_timerWithThread {
    @autoreleasepool {
        [[NSThread currentThread] setName:@"player_timer"];
        _timer = [NSTimer timerWithTimeInterval:0.1 target:self selector:@selector(see_pushData:) userInfo:nil repeats:YES];
        [[NSRunLoop currentRunLoop] addTimer:_timer forMode:NSRunLoopCommonModes];
        [[NSRunLoop currentRunLoop] run];
    }
}

怎么樣,這個(gè)代碼是不是有點(diǎn)眼熟碍舍?
什么柠座?你說聽不懂我在說什么?
相信我片橡,你一定有機(jī)會(huì)和面試官尬聊妈经。??
你還是趕緊看看iOS開發(fā)之線程永駐吧。(沒有喂自己袋鹽捧书,請(qǐng)放心食用)

現(xiàn)在我們的dataManager已經(jīng)成功覺醒吹泡,擁有了能夠穩(wěn)定的循環(huán)執(zhí)行指定代碼的能力。but...

- (void)see_pushData:(NSTimer *)sender {
}

代碼呢经瓷?


數(shù)據(jù)推送邏輯

在考慮推送邏輯之前爆哑,先看看我們現(xiàn)在已經(jīng)完成的工作:

  1. 接收loadingRequest并記錄。
  2. 從所有l(wèi)oadingRequest中查找期望數(shù)據(jù)位置了嚎。
  3. 讀取大文件泪漂。
  4. 將所有緩存文件使用文件信息管理類進(jìn)行統(tǒng)一管理廊营。
  5. 內(nèi)存緩存數(shù)據(jù)的管理。
  6. 推送方案的確定以及實(shí)現(xiàn)萝勤。

有沒有看到我們的推送邏輯露筒?
其實(shí)很簡(jiǎn)單 loop ( 6 => (1,2) => (3,4) => 5 ) 這就是我們的推送邏輯。

dataManager在推送開始時(shí)將當(dāng)前內(nèi)存緩存數(shù)據(jù)推送給resourceLoaderDelegate敌卓,resourceLoaderDelegate接收到數(shù)據(jù)進(jìn)行填充慎式,之后遍歷所有l(wèi)oadingRequest查找到期望數(shù)據(jù)起始位置offset回傳給dataManager(哥,說好了趟径,下一次你給我數(shù)據(jù)的時(shí)候一定要有這個(gè)位置的數(shù)據(jù)瘪吏。),dataManager接收到offset將填充完成的數(shù)據(jù)進(jìn)行清除蜗巧,然后使用offset通過文件信息管理類查找到對(duì)應(yīng)緩存文件讀取數(shù)據(jù)掌眠,將讀取的數(shù)據(jù)存儲(chǔ)在內(nèi)存緩存中等待下一次推送。如此循環(huán)幕屹,直到(地球爆炸蓝丙,宇宙消亡,那是不可能的)播放器銷毀或者全部數(shù)據(jù)推送完成望拖。

如果我們的推送邏輯修改為 loop ((1,2) => (3,4) => 5 => 6)也是可以的渺尘,但是這樣我們?cè)诘谝淮瓮扑蜁r(shí)并不知道代理需要的數(shù)據(jù)的起始位置,這樣我們只能盲目讀取一段數(shù)據(jù)说敏,如果我們讀的這段數(shù)據(jù)不是代理所需要的那TM就尷尬了鸥跟。如果第一次推送不讀取數(shù)據(jù)那就需要跳過 (1,2) => (3,4) => 5 這三步,這又和我們之前討論的邏輯有什么不同呢盔沫?還多了一個(gè)判斷医咨。

所以這里我選擇使用 6 => (1,2) => (3,4) => 5 這種方式,由dataManager主動(dòng)詢問需要的數(shù)據(jù)迅诬,而不是二話不說就準(zhǔn)備數(shù)據(jù)然后代理回應(yīng)之后再去調(diào)整數(shù)據(jù)腋逆。畢竟男孩子還是要主動(dòng)一些的。(滑稽)

等等侈贷,你本地緩存文件從哪里來?
別急等脂,還沒到俏蛮,這是兩碼事憋打岔。

既然這樣也就沒什么好說的了上遥,開始著手把上面的邏輯翻譯成代碼吧:(我們不生產(chǎn)代碼搏屑,我們是代碼的搬運(yùn)工。)

- (void)see_pushData:(NSTimer *)sender {
    if (!self->_responder.didReceiveData) return;
    long long finishOffset = 0;
    //將數(shù)據(jù)推送給接收者得到填充完成位置
    finishOffset = [self->_delegate didReceiveData:self->_prepareData];
    
    //清除填充完成的數(shù)據(jù)
    [self see_clearFinishData:finishOffset];
    
    //檢查推送是否完成
    if (finishOffset && finishOffset == self->_fileInfo.fileAttribute.totalBytes) {
        [self stop];
        return;
    }
    
    //查找數(shù)據(jù)
    if (finishOffset < self->_prepareData.location || finishOffset >= self->_prepareData.end) {
        //如果請(qǐng)求的數(shù)據(jù)不連續(xù)粉楚,將內(nèi)存緩存清空
        if (finishOffset != _prepareData.end)[_prepareData initOffset:finishOffset];
        [self see_prepareData:finishOffset];
    }
}

- (void)see_clearFinishData:(long long)finishOffset {
    //每完成1M數(shù)據(jù)推送清除一次內(nèi)存緩存
    if (finishOffset >= _prepareData.location + 1048576) {
        [_prepareData initOffset:_prepareData.location + 1048576];
    }
}

關(guān)于上述代碼中部分可能存在的疑問:

為什么不填充一次數(shù)據(jù)就立即將數(shù)據(jù)清除而要等到數(shù)據(jù)量達(dá)到某一數(shù)量才進(jìn)行清除辣恋?
這樣設(shè)計(jì)出發(fā)點(diǎn)在于當(dāng)播放器開始播放時(shí)會(huì)先發(fā)出一段2字節(jié)的數(shù)據(jù)請(qǐng)求來確認(rèn)數(shù)據(jù)亮垫,之后會(huì)發(fā)出從0開始到文件末尾的數(shù)據(jù)請(qǐng)求,如果一開始就立即將這兩字節(jié)的數(shù)據(jù)清除當(dāng)再一次發(fā)出請(qǐng)求的時(shí)候?yàn)榱吮WC數(shù)據(jù)正確性伟骨,我們需要將當(dāng)前內(nèi)存緩存中的數(shù)據(jù)全部清除重新從0開始讀取饮潦。
另外這樣做也會(huì)減少在整個(gè)播放過程中清除數(shù)據(jù)的次數(shù),保證性能携狭。畢竟整個(gè)播放過程中進(jìn)行的計(jì)算并不少继蜡,能省一點(diǎn)是一點(diǎn)。

為什么在查找數(shù)據(jù)之前有時(shí)需要清除內(nèi)存緩存逛腿?
首先我們看看清除緩存的方法實(shí)現(xiàn):

- (void)initOffset:(long long)offset {
    if (offset == _location) return;
    long long initLength = offset - _location;
    _location = offset;
    if (initLength < 0 || initLength > _length) {
        //如果初始化的位置不在當(dāng)前數(shù)據(jù)片段內(nèi)則清除當(dāng)前全部數(shù)據(jù)
        [((NSMutableData *)_data) replaceBytesInRange:NSMakeRange(0, _length) withBytes:NULL length:0];
        self.length = 0;
        return;
    }
    //清除指定offset之前的數(shù)據(jù)
    [((NSMutableData *)_data) replaceBytesInRange:NSMakeRange(0, initLength) withBytes:NULL length:0];
    self.length -= initLength;
}

這里我們所說的清除緩存并不是將所有數(shù)據(jù)清除并且將數(shù)據(jù)片段指向0稀并,而是通過修改數(shù)據(jù)將offset之前的數(shù)據(jù)全部清除,只保留內(nèi)存緩存中offset之后的連續(xù)數(shù)據(jù)单默。

當(dāng)finishOffset比當(dāng)前內(nèi)存中的數(shù)據(jù)起始位置小或者大于結(jié)束位置時(shí)碘举,如果我們不清除內(nèi)存緩存直接將數(shù)據(jù)拼接在末尾,此時(shí)內(nèi)存中的數(shù)據(jù)就發(fā)生了錯(cuò)誤搁廓,舉個(gè)例子:
比如說我們的數(shù)據(jù)為 “hello word” (c字符串) 一共是11個(gè)字節(jié)殴俱。
當(dāng)前內(nèi)存緩存如下:
location: 3
length: 1
end: 4
data: "l"
如果此時(shí)代理返回finishOffset為8。
我們不清除內(nèi)存緩存直接拼接:
location: 3
length: 2
end: 5
data: "lo"
你覺得對(duì)嗎枚抵?看起來好像是沒毛病的是吧线欲。其實(shí)是錯(cuò)覺。
那下一次推送的時(shí)候汽摹,由于我們提供的數(shù)據(jù)對(duì)象中當(dāng)前數(shù)據(jù)片段為3-5李丰,并不包含代理所需要的數(shù)據(jù)(其實(shí)是包含的,只不過我們的數(shù)據(jù)出現(xiàn)了錯(cuò)誤)逼泣,因此代理再次返回8:
location: 3
length: 3
end: 6
data: "loo"
...
如此循環(huán)幾次趴泌,直到我們的end等于9為止,此時(shí)我們的內(nèi)存緩存變成了這樣:
location: 3
length: 6
end: 9
data: "looooo"
Excuse me拉庶? 我們的原始數(shù)據(jù)中好像并沒有 "looooo"嗜憔。
所以我們需要對(duì)緩存進(jìn)行清除。
清除之前:
location: 3
length: 1
end: 4
data: "l"
清除之后會(huì)變成這樣:
location: 8
length: 0
end: 8
data: ""
此時(shí)我們將讀取到的數(shù)據(jù)放入緩存:
location: 8
length: 1
end: 9
data: "o"

Are you ok?

ok ok !

到這里我們的主要流程就已經(jīng)完成了氏仗,先按耐住你躁動(dòng)的內(nèi)心吉捶,千萬不要command+R,你會(huì)在手機(jī)屏幕里看到你最愛的人...??


不知道有多少人能看到這里皆尔,皮一下先

以上我們已經(jīng)完成了使用本地緩存對(duì)播放器進(jìn)行數(shù)據(jù)填充呐舔,接下來我們著手進(jìn)行網(wǎng)絡(luò)數(shù)據(jù)下載并緩存。


數(shù)據(jù)下載邏輯
什么情況下需要下載數(shù)據(jù)呢慷蠕?

根據(jù)我們的設(shè)計(jì)珊拼,只有當(dāng)本地沒有所需的數(shù)據(jù)時(shí)才需要開啟下載,并且將下載的數(shù)據(jù)緩存至本地流炕。
也就是說當(dāng)我們的dataManager執(zhí)行 - (void)see_prepareData:(long long)startOffset 時(shí)如果

- (void)see_prepareData:(long long)startOffset {
    //如果當(dāng)前訪問的文件中包含后續(xù)數(shù)據(jù)則在當(dāng)前文件中讀取
    if (_inputFile && _inputFile.endOffset > startOffset && _inputFile.startOffset <= startOffset) {
        [self see_prepareDataFormCache:startOffset];
        return;
    }
    //如果當(dāng)前訪問的文件中不包含后續(xù)數(shù)據(jù)則重新查找對(duì)應(yīng)文件
    _inputFile = [_fileInfo fileForOffset:startOffset];
    if (_inputFile){
        //打開對(duì)應(yīng)的流
        [_inputStream setFileAtPath:[_cacheBasePath stringByAppendingPathComponent:_inputFile.path]];
        [self see_prepareDataFormCache:startOffset];
        return;
    }
    //如果代碼執(zhí)行到這里說明緩存中不包含所需數(shù)據(jù)則開啟下載任務(wù)
    [self see_prepareDataFromNetwork:startOffset];
}

代碼執(zhí)行到了最后一行澎现,說明本地沒有所需的數(shù)據(jù)仅胞,這時(shí)我們就需要發(fā)出從startOffset開始的網(wǎng)絡(luò)請(qǐng)求來下載對(duì)應(yīng)的數(shù)據(jù)。

既然要下載數(shù)據(jù)剑辫,那么Range要設(shè)置為多少才能保證某一數(shù)據(jù)片段不會(huì)被重復(fù)下載呢干旧?

"bytes = startOffset - " 這樣是顯然不行的,如果在startOffset之后有已經(jīng)緩存過的數(shù)據(jù)片段揭斧,那么這部分?jǐn)?shù)據(jù)將會(huì)被重復(fù)下載莱革。
我們可能需要確定一個(gè)準(zhǔn)確的范圍來進(jìn)行數(shù)據(jù)下載,以保證既能夠得到想要的數(shù)據(jù)而又不會(huì)對(duì)數(shù)據(jù)進(jìn)行重復(fù)下載讹开。

下面的例子就是我們的Range確定方案:


Range范圍

每一次我們都只下載從startOffset開始盅视,尋找本地緩存文件中數(shù)據(jù)起始位置值比startOffset大的最小值作為下載結(jié)束位置進(jìn)行下載,即下載所需最小數(shù)據(jù)片段旦万。這樣既保證了數(shù)據(jù)的正確性闹击,并且防止了數(shù)據(jù)重復(fù)下載。


下載范圍結(jié)束值確定

既然我們的結(jié)束只是基于已緩存的數(shù)據(jù)范圍進(jìn)行查找的成艘,那么毫無疑問這里我們需要借助_fileInfo的幫忙才能完成赏半。
為什么?
你難道忘記了,_fileInfo管理著我們的緩存文件信息淆两,不問他問誰断箫。
仔細(xì)觀察 - (void)see_prepareData:(long long)startOffset 這個(gè)方法的實(shí)現(xiàn),我們會(huì)發(fā)現(xiàn)每當(dāng)下載數(shù)據(jù)之前_fileInfo一定會(huì)使用startOffset查找一次緩存文件秋冰,我們可以在查找過程中順便將這個(gè)值也一并查找出來救欧。所以_fileInfo查找文件的方法被修改成了這樣:

- (SEEFile *)fileForOffset:(long long)offset {
    __block SEEFile * file = nil;
    self.missingEndOffset = self.fileAttribute.totalBytes;
    [self.files enumerateObjectsUsingBlock:^(SEEFile * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
        if (obj.startOffset <= offset && obj.endOffset > offset) {
            file = obj;
            *stop = YES;
        }
        //查找下載結(jié)束位置  
        if (obj.startOffset > offset && obj.startOffset < self.missingEndOffset) {
            self.missingEndOffset = obj.startOffset - 1;
        }
    }];
    return file;
}

這樣每當(dāng)我們需要下載數(shù)據(jù)的時(shí)候_fileInfo就已經(jīng)為我們準(zhǔn)備好了下載的結(jié)束位置值睁壁。


downloader

說到網(wǎng)絡(luò)請(qǐng)求派歌,可供我們選擇的方案就很多了雇寇,除了直接使用NSURLConnection或者NSURLSession之外還可以使用很多已經(jīng)封裝好的第三方網(wǎng)絡(luò)框架。請(qǐng)隨意選擇虽另。

downloader需要提供以下功能:
  1. 下載指定范圍數(shù)據(jù)暂刘。
  2. 請(qǐng)求狀態(tài)回調(diào)
  3. 數(shù)據(jù)回調(diào)。
  4. 沒了...

本文中使用的是NSURLSession捂刺,由于沒有什么太大的技術(shù)含量谣拣,我就直接貼代碼了。

//SEEDownloader.h
#import <Foundation/Foundation.h>

NS_ASSUME_NONNULL_BEGIN

@protocol SEEDownloaderDelegate <NSObject>

@optional

/**
 接收到響應(yīng)

 @param response 響應(yīng)體對(duì)象
 */
- (void)didReceiveResponse:(NSURLResponse *)response;

/**
 傳輸完成

 @param error 錯(cuò)誤信息叠萍,如果傳輸正常完成該項(xiàng)為nil
 */
- (void)didCompleteWithError:(NSError * _Nullable)error;

/**
 接收數(shù)據(jù)

 @param data 數(shù)據(jù)
 */
- (void)didreceiveData:(NSData *)data;

@end


@interface SEEDownloader : NSObject

@property (nonatomic, assign) long long startOffset;

@property (nonatomic, assign) long long currentOffset;

@property (nonatomic, assign) long long endOffset;

//初始化
- (instancetype)initWithURL:(NSURL *)url delegate:(id<SEEDownloaderDelegate>)delegate;

/**
 從 offset 位置開始重新請(qǐng)求文件

 @param offset 起始 offset
 */
- (void)resetWithStartOffset:(long long)offset endOffset:(long long)endOffset;

//取消下載芝发,清除資源
- (void)invalidateAndCancel;

@end

NS_ASSUME_NONNULL_END

//SEEDownloader.m
#import "SEEDownloader.h"
#import "SEEPlayerMacro.h"
@interface SEEDownloader () <NSURLSessionDataDelegate>

@end

@implementation SEEDownloader {
    NSURLSession * _session;
    NSURL * _url;
    
    __weak id <SEEDownloaderDelegate> _delegate;
    
    struct {
        char didReceiveResponse;
        char didCompleteWithError;
        char didReceiveData;
    }_responder;
    
    NSString * _headerRange;
}

- (instancetype)initWithURL:(NSURL *)url delegate:(nonnull id<SEEDownloaderDelegate>)delegate {
    if (self = [super init]) {
        _startOffset = -1;
        _endOffset = -1;
        _currentOffset = -1;
        _url = url;
        [self setDelegate:delegate];
    }
    return self;
}


#pragma mark public method

/**
 從指定offset開始下載

 @param startOffset offset
 */
- (void)resetWithStartOffset:(long long)startOffset  endOffset:(long long)endOffset {
    /* 需要開啟下載的情況
     1. 請(qǐng)求起始位置不再當(dāng)前下載范圍之內(nèi)
     2. 請(qǐng)求起始位置在當(dāng)前下載的范圍內(nèi)并且比當(dāng)前下載到的位置大300K 以上
     */
    if (startOffset >= _startOffset && startOffset <= _endOffset){
        if (startOffset <= _currentOffset + 307200) {
            return;
        }
    }
    self.startOffset = startOffset;
    self.currentOffset = startOffset;
    self.endOffset = endOffset;
    [self see_downloadStart];
}

- (void)invalidateAndCancel {
    if (_session) {
        [_session invalidateAndCancel];
        _session = nil;
    }
}

#pragma mark private method

- (void)see_downloadStart {
    NSMutableURLRequest * request = [NSMutableURLRequest requestWithURL:_url];
    
    if (self.endOffset == 0) _headerRange = [NSString stringWithFormat:@"bytes=%lld-",self.startOffset];
    else _headerRange = [NSString stringWithFormat:@"bytes=%lld-%lld",self.startOffset,self.endOffset];
    
    [request setValue:_headerRange forHTTPHeaderField:@"Range"];
    
    [self invalidateAndCancel];
    NSURLSessionConfiguration * configuration = [NSURLSessionConfiguration defaultSessionConfiguration];
    _session = [NSURLSession sessionWithConfiguration:configuration delegate:self delegateQueue:[NSOperationQueue currentQueue]];
    
    NSURLSessionDataTask * task = [_session dataTaskWithRequest:request];
    [task resume];
}

#pragma mark delegate
- (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask
didReceiveResponse:(NSURLResponse *)response
 completionHandler:(void (^)(NSURLSessionResponseDisposition disposition))completionHandler {
    if (self.endOffset == 0) {
        self.endOffset = response.expectedContentLength - 1;
    }
    if (_responder.didReceiveResponse) {
        [_delegate didReceiveResponse:response];
    }
    completionHandler(NSURLSessionResponseAllow);
}

- (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask
    didReceiveData:(NSData *)data {
    _currentOffset += data.length;
    if (_responder.didReceiveData) {
        [_delegate didreceiveData:data];
    }
    
}

- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didCompleteWithError:(nullable NSError *)error {
    if (_responder.didCompleteWithError) {
        [_delegate didCompleteWithError:error];
    }
    //每次下載完成重新調(diào)整結(jié)束位置,防止由于網(wǎng)絡(luò)問題導(dǎo)致下載結(jié)束后重新請(qǐng)求同段數(shù)據(jù)時(shí)通不過- (void)resetWithStartOffset:(long long)startOffset  endOffset:(long long)endOffset 方法檢測(cè)苛谷。
    if ([task.originalRequest.allHTTPHeaderFields[@"Range"] isEqualToString:_headerRange]) {
        _endOffset = _currentOffset - 1;
    }
}


#pragma mark getter & setter
- (void)setDelegate:(id <SEEDownloaderDelegate>)delegate {
    _delegate = delegate;
    //_responder
    _responder.didReceiveResponse = [delegate respondsToSelector:@selector(didReceiveResponse:)];
    _responder.didCompleteWithError = [delegate respondsToSelector:@selector(didCompleteWithError:)];
    _responder.didReceiveData = [delegate respondsToSelector:@selector(didreceiveData:)];
}

@end

這里我們唯一需要注意的就是當(dāng)我們的下載由于網(wǎng)絡(luò)異常而終止時(shí)我們需要將_endOffset修改為當(dāng)前已經(jīng)下載完成的數(shù)據(jù)的末尾,使其處于假完成狀態(tài)格郁,這樣當(dāng)網(wǎng)絡(luò)恢復(fù)之后腹殿,dataManager檢測(cè)到磁盤沒有數(shù)據(jù)就會(huì)再次發(fā)出從中斷位置開始的下載請(qǐng)求独悴,而此時(shí),我們已經(jīng)將當(dāng)前下載器手動(dòng)結(jié)束锣尉,- (void)resetWithStartOffset:(long long)startOffset endOffset:(long long)endOffset 可以正常執(zhí)行刻炒。

當(dāng)然如果大家有什么更好的方式,請(qǐng)通過各種渠道告訴我自沧,謝過坟奥!


本地緩存文件創(chuàng)建以及數(shù)據(jù)寫入

我們已經(jīng)成功的將數(shù)據(jù)進(jìn)行了下載,并且通過代理回調(diào)給了dataManager拇厢,接下來dataManager接收到數(shù)據(jù)之后就需要對(duì)數(shù)據(jù)進(jìn)行存儲(chǔ)爱谁。

我們的數(shù)據(jù)是存儲(chǔ)在多個(gè)文件中的,因此我們需要在接收到數(shù)據(jù)之前將輸出流創(chuàng)建完成孝偎,所以在dataManager接收到由downloader回調(diào)回來的響應(yīng)時(shí)進(jìn)行輸出流的創(chuàng)建访敌。

- (void)didReceiveResponse:(NSURLResponse *)response {
    if (self.MIMEType == nil) {
        self.MIMEType = response.MIMEType;
    }
    if (self.totalBytes == 0) {
        self.totalBytes = response.expectedContentLength;
    }
    
    long long startOffset = _downloader.startOffset;
    _fileInfo.fileAttribute.exceptFileName = response.suggestedFilename;
    //關(guān)閉當(dāng)前輸出文件
    [self see_closeCurrentOutputFile];
    //初始化輸出流
    [self see_initOutputFile:startOffset];
}

首先我們將當(dāng)前視頻的基本信息存儲(chǔ)起來,之后如果當(dāng)前有正在寫入的文件將其關(guān)閉并且創(chuàng)建一個(gè)新的輸出流衣盾。

為了使緩存文件數(shù)量盡可能的少寺旺,我們選擇在創(chuàng)建新的輸出流之前首先檢查新的數(shù)據(jù)是否可以拼接在某一個(gè)現(xiàn)有的緩存文件之后,如果沒有再創(chuàng)建新的文件:

/**
 1. 當(dāng)目前文件中有在offset處結(jié)尾的文件势决,則將下載的文件拼接在該文件之后
 2. 如果當(dāng)前文件中沒有在offset處結(jié)尾的文件阻塑,則新建文件存儲(chǔ)

 @param offset 數(shù)據(jù)起始位置
 */
- (void)see_initOutputFile:(long long)offset {
    _outputFile = [_fileInfo acceptableFileForDownloadOffset:offset];
    if (_outputFile == nil){
        _outputFile = [[SEEFile alloc]init];
        _outputFile.startOffset = offset;
        NSString * path = [self see_pathForOffset:offset];
        _outputFile.path = [path lastPathComponent];
        [_fileInfo.files addObject:_outputFile];
        [_cacheRanges addObject:[NSValue valueWithRange:NSMakeRange((NSUInteger)offset, (NSUInteger)offset)]];
    }
    _outputStream = [NSOutputStream outputStreamToFileAtPath:[_cacheBasePath stringByAppendingPathComponent:_outputFile.path] append:YES];
    [_outputStream open];
}

之后我們?cè)诮邮盏綌?shù)據(jù)之后只需要通過輸出流寫入對(duì)應(yīng)的文件即可:

- (void)didreceiveData:(NSData *)data {
    if (_outputFile) {
        _outputFile.length += data.length;
        NSInteger index = [_fileInfo.files indexOfObject:_outputFile];
        [_cacheRanges replaceObjectAtIndex:index withObject:[NSValue valueWithRange:NSMakeRange((NSUInteger) _outputFile.startOffset, (NSUInteger)_outputFile.endOffset)]];
    }
    if (_outputStream)[_outputStream write:data.bytes maxLength:data.length];
}

當(dāng)下一次推送數(shù)據(jù)時(shí)就可以從緩存中讀出對(duì)應(yīng)的數(shù)據(jù)了。


三果复、Demo

關(guān)于邊下邊播功能到這里就全部結(jié)束了陈莽,至于播放、暫停据悔、進(jìn)度條等等等等這些有機(jī)會(huì)會(huì)寫在另一篇文章中传透,如果有我會(huì)在下面貼出相關(guān)文章。

Demo中已經(jīng)有部分播放极颓、暫停朱盐、進(jìn)度、緩存進(jìn)度條等功能菠隆,大家可以自行參考兵琳。

SEEAssetsPlayer

另外,雖然可能大概也許文章中有些許漏洞骇径,后續(xù)發(fā)現(xiàn)問題會(huì)及時(shí)更新躯肌,還請(qǐng)大家尊重原創(chuàng)。

轉(zhuǎn)載請(qǐng)注明出處破衔。
匿了匿了...
最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末清女,一起剝皮案震驚了整個(gè)濱河市,隨后出現(xiàn)的幾起案子晰筛,更是在濱河造成了極大的恐慌嫡丙,老刑警劉巖拴袭,帶你破解...
    沈念sama閱讀 206,602評(píng)論 6 481
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場(chǎng)離奇詭異曙博,居然都是意外死亡拥刻,警方通過查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,442評(píng)論 2 382
  • 文/潘曉璐 我一進(jìn)店門父泳,熙熙樓的掌柜王于貴愁眉苦臉地迎上來般哼,“玉大人,你說我怎么就攤上這事惠窄≌裘撸” “怎么了?”我有些...
    開封第一講書人閱讀 152,878評(píng)論 0 344
  • 文/不壞的土叔 我叫張陵睬捶,是天一觀的道長(zhǎng)黔宛。 經(jīng)常有香客問我,道長(zhǎng)擒贸,這世上最難降的妖魔是什么臀晃? 我笑而不...
    開封第一講書人閱讀 55,306評(píng)論 1 279
  • 正文 為了忘掉前任,我火速辦了婚禮介劫,結(jié)果婚禮上徽惋,老公的妹妹穿的比我還像新娘。我一直安慰自己座韵,他們只是感情好险绘,可當(dāng)我...
    茶點(diǎn)故事閱讀 64,330評(píng)論 5 373
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著誉碴,像睡著了一般宦棺。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上黔帕,一...
    開封第一講書人閱讀 49,071評(píng)論 1 285
  • 那天代咸,我揣著相機(jī)與錄音,去河邊找鬼成黄。 笑死呐芥,一個(gè)胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的奋岁。 我是一名探鬼主播思瘟,決...
    沈念sama閱讀 38,382評(píng)論 3 400
  • 文/蒼蘭香墨 我猛地睜開眼,長(zhǎng)吁一口氣:“原來是場(chǎng)噩夢(mèng)啊……” “哼闻伶!你這毒婦竟也來了滨攻?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 37,006評(píng)論 0 259
  • 序言:老撾萬榮一對(duì)情侶失蹤,失蹤者是張志新(化名)和其女友劉穎铡买,沒想到半個(gè)月后更鲁,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體霎箍,經(jīng)...
    沈念sama閱讀 43,512評(píng)論 1 300
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡奇钞,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 35,965評(píng)論 2 325
  • 正文 我和宋清朗相戀三年,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了漂坏。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片景埃。...
    茶點(diǎn)故事閱讀 38,094評(píng)論 1 333
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡,死狀恐怖顶别,靈堂內(nèi)的尸體忽然破棺而出谷徙,到底是詐尸還是另有隱情,我是刑警寧澤驯绎,帶...
    沈念sama閱讀 33,732評(píng)論 4 323
  • 正文 年R本政府宣布完慧,位于F島的核電站,受9級(jí)特大地震影響剩失,放射性物質(zhì)發(fā)生泄漏屈尼。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 39,283評(píng)論 3 307
  • 文/蒙蒙 一拴孤、第九天 我趴在偏房一處隱蔽的房頂上張望脾歧。 院中可真熱鬧,春花似錦演熟、人聲如沸鞭执。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,286評(píng)論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽兄纺。三九已至,卻和暖如春化漆,著一層夾襖步出監(jiān)牢的瞬間估脆,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 31,512評(píng)論 1 262
  • 我被黑心中介騙來泰國(guó)打工获三, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留旁蔼,地道東北人。 一個(gè)月前我還...
    沈念sama閱讀 45,536評(píng)論 2 354
  • 正文 我出身青樓疙教,卻偏偏與公主長(zhǎng)得像棺聊,于是被迫代替她去往敵國(guó)和親。 傳聞我的和親對(duì)象是個(gè)殘疾皇子贞谓,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 42,828評(píng)論 2 345

推薦閱讀更多精彩內(nèi)容