標籤:
之前使用NSURLSession做了一個斷點續傳的demo,主要實現了在下載的過程中中斷下載,然後可以再次啟動延續上次的下載連結繼續下載的功能.原理是將task的方法cancelByProducingResumeData的Block塊中的resumeData擷取下來,當再次下載的時候,通過session的downloadTaskWithResumeData方法使用該resumeData建立一個新的task,然後啟動下載,就實現了斷點續傳的功能.但是如果說當前任務正在下載,程式切到後台之後被kill掉,當再次啟動應用的時候,就無法繼續上次的下載,也就是說,剛才的那種思路,只是適用於使用者手動暫停在程式不退出的情況下實現的斷點續傳,如果應用直接終結則不會繼續下載,也就是說並不是真正意義上的斷點續傳,因為再次啟動應用的時候,仍然要重新下載.
於是我就在考慮,如何能夠實現當應用意外退出的時候,再次啟動應用,仍然可以繼續上次的下載任務.我們知道,resumenData中儲存的資料是當前任務的下載資訊,將其還原序列化出來成為字串的格式輸出的話,其內容如下所示(部分冗餘內容已經切除,重要訊息已添加註釋):
<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"><plist version="1.0"><dict> <key>NSURLSessionDownloadURL</key> <string>http://oarbi0614.bkt.clouddn.com/%E5%86%B0%E6%B2%B3%E4%B8%96%E7%BA%AA.mp4</string> 請求的地址 <key>NSURLSessionResumeBytesReceived</key> <integer>12038723</integer> 當前下載的檔案大小 <key>NSURLSessionResumeCurrentRequest</key> <data> YnBsaXN0MDD..... </data> <key>NSURLSessionResumeEntityTag</key> <string>"ljECR82nRMhHvP8D5M9sGQuKBjgK"</string> <key>NSURLSessionResumeInfoTempFileName</key> <string>CFNetworkDownload_6XdKfZ.tmp</string> 下載使用的臨時檔案名稱 <key>NSURLSessionResumeInfoVersion</key> <integer>2</integer> <key>NSURLSessionResumeOriginalRequest</key> <data> YnBsaXN0MDD... </data> <key>NSURLSessionResumeServerDownloadDate</key> <string>Mon, 25 Jul 2016 10:42:23 GMT</string></dict></plist>
這個資料區塊裡儲存了當前下載的的狀態,包括臨時檔案的名字以及當前下載的檔案大小.當檔案正在下載的時候,會在tmp檔案夾內產生一個.tmp檔案,儲存了當前實際下載的資料,當通過session的downloadTaskWithResumeData方法使用該resumeData建立一個新的task,然後啟動下載的時候,task會通過該資料區塊找到這個檔案,然後繼續下載.這樣一來,貌似我們只需要將resumeData資料區塊儲存下來並且儲存儲存資料的.tmp檔案就好了,當應用意外退出再次開啟下載任務的時候,我們只需要使用resumeData建立下載任務,然後將.tmp檔案放在tmp檔案夾下就好了.
那麼現在問題到了如何在程式意外退出的時候如何儲存resumeData資料區塊和.tmp檔案上了.我們該如何?呢?
方法一:當程式意外退出的時候,當前的控制器會調用-(void)viewWillDisappear:(BOOL)animated,應用的代理會調用-applicationWillTerminate:(UIApplication *)application,我們可以在這兩個方法裡做文章.
在-(void)viewWillDisappear:(BOOL)animated或者-applicationWillTerminate:(UIApplication *)application方法裡將resumeData儲存到cache檔案夾中
要取出resuemData,必然是要通過
__weak typeof(self)weakSelf = self;[self.downloadTask cancelByProducingResumeData:^(NSData * _Nullable resumeData) { //在這裡可以擷取到resumeData weakSelf.resumeData = resumeData; }];
這個方法來擷取resumeData,但是在實際使用的時候,卻遇到了這樣的問題,無論是將該代碼放在-(void)viewWillDisappear:(BOOL)animated還是-applicationWillTerminate:(UIApplication *)application方法裡,weakSelf.resumeData裡始終是空的,經過實驗,當程式啟動並執行時候,該block塊裡的代碼是不走的,但是在該Block塊外的代碼,全部都是執行的.我們都知道,當建立session的時候,如果設定queue的時候傳入參數為nil,那麼他的代理方法都是在全域並發隊列裡完成的,難道是因為這個原因麼?是不是當程式意外退出的時候,這兩個方法裡的子線程代碼都不會執行?我做了如下的實驗:
在這兩個方法裡添加如下代碼dispatch_async(dispatch_get_global_queue(0, 0), ^{ for(int i = 0; i < 100; i ++){ NSLog(@"%d",i); }});
當在後台kill掉應用的時候,有時候輸出0 ,但是如果把下面的代碼添加到該方法裡,裡面的代碼仍然是不執行的
dispatch_async(dispatch_get_main_queue(), ^{ for(int i = 0; i < 100; i ++){ NSLog(@"%d",i); } });
但是如果只把
for(int i = 0; i < 100; i ++){ NSLog(@"%d",i); }
這幾行代碼放在這兩個方法中,則都是可以完整的執行的,令人百思不得其解.
但是思考一下可以發現:
- 當我們設定session為主線程的時候,經過檢查可以發現cancelByProducingResumeData方法裡的Block塊是在主線程啟動並執行;
- 當我們設定session線程為nil的時候,其代理方法是在全域並發隊列裡執行的,包括cancelByProducingResumeData方法裡的Block塊也是在子線程中啟動並執行.
所以我們可以推測一下,在OC底層,cancelByProducingResumeData方法很有可能也是像dispatch_async方法一樣是通過向線程隊列裡新增工作來獲得執行機會的.加上上面的三個實驗,我猜測是當程式終結的時候,只會執行主線程中的代碼,此時如果再通過擷取線程向主線程新增工作的話,那麼該任務就不會添加到主線程隊列裡去,更別提往子線程裡新增工作了.OC是編譯型語言,當應用終結的時候,只有那些寫到主線程上面的,編譯好的代碼在程式終結的時候可以得到執行,而在終結的時候動態添加的任務則無法添加成功,所以在這兩個方法裡,當程式意外終結的時候,是不能夠擷取到resumeData的.
方法二:動態儲存
既然不能在程式終結的時候擷取到resumeData,那麼只能在下載的過程中動態儲存了.當應用在下載的時候,會頻繁的調用其代理方法:
- (void)URLSession:(NSURLSession *)session downloadTask:(NSURLSessionDownloadTask *)downloadTask didWriteData:(int64_t)bytesWritten totalBytesWritten:(int64_t)totalBytesWrittentotalBytesExpectedToWrite:(int64_t)totalBytesExpectedToWrite{ }}
每當有資料過來的時候,都會調用這個方法,經過實驗可以發現這個方法的調用頻率是很高的,尤其是在全域並發隊列裡執行的時候,根據測試,該方法的調用頻率達到了1200次以上,所以如果每次下載都進行儲存的話,那麼將會大大的影響應用的效率,因為檔案流的輸入輸出本身就很佔用效能,再加上如此高頻率的調用,對效能的影響是不敢想像的.所以我做了這樣的設計,每當下載檔案的十分之一時,擷取resumeData.
那麼擷取resumeData的問題解決了,接下來就是擷取.tmp檔案了.如果我們要對.tmp檔案進行操作,那麼就必須要擷取到該檔案完整的檔案名稱,因為tmp檔案夾下絕大多數檔案都是.tmp結尾的,而且每次下載產生的.tmp檔案都是不同的,所以如果將所有.tmp檔案都儲存的話,肯定是很不合理的.這時候,就用到了我們擷取到的resumeData,因為經過還原序列化我們可以知道,resumeData中是儲存著當前下載的臨時檔案名稱的,所以我們可以對resumeData解析之後,取出其中的臨時檔案名稱,而且當下載的時候,其肯定是放在tmp檔案夾下的,有了這些東西,我們就可以對.tmp檔案進行儲存了.
最終採用的方法流程:
由方法二我們可以擷取到resumeData和.tmp檔案,有了這兩個檔案,我們就可以在應用下次啟動的時候繼續上次未完成的下載,在檔案每下載十分之一的時候儲存一次.具體操作如下:
在session下載代理方法裡檢測檔案下載過程,每當下載超過十分之一的時候,擷取resumeData資料區塊和.tmp檔案的路徑,然後將resumeData寫入到Cache檔案夾下,將.tmp檔案拷貝至Cache檔案夾下,同時在Cache檔案夾下建立一個plist檔案,key值為.tmp檔案的檔案名稱,value值是一個Bool值,標記該檔案是否下載完成,相當於該plist檔案是管理下載檔案的目錄;
當應用再次啟動的時候,首先從Cache檔案夾下讀取下載目錄,尋找未下載完成的檔案,找到後將以該檔案命名的.tmp臨時檔案複製到tmp檔案夾中,然後取出”Resume_” + 該檔案名稱 命名的上次儲存的resumeData資料,使用該資料建立task並開啟下載.
備忘:Caches檔案夾是蘋果為使用者提供的緩衝路徑,應用重啟該目錄不會清考,tmp檔案夾會清空,而Documents目錄下的檔案備份的時候被上傳到iCloud並且很快就用完有限的空間,所以我們選擇在cache檔案夾下緩衝我們需要的檔案.
經過這樣的處理,基本就實現了當程式意外退出的時候,再次啟動仍然可以繼續上次未完成的下載.不過對效能有些許影響而且不會百分之百續傳上次未下載完的資料,會丟失一點點(因為並不是即時儲存的).如果使用FMDB或者其他的資料庫來管理緩衝的檔案的話,效果會更好些,不過這次想自己動手寫這些東西,就沒有用.
這個功能我寫了一個Demo,當下載好再次啟動的時候,會直接播放本地檔案,沒有下載完會繼續下載,若沒有快取資料則會重新下載.Demo裡包括了我自己封裝的一個基本的播放器和進度列指示器.緩衝的功能我寫成了一個單例放在了工程裡,方便給各位看官查看.這個Demo放在了GitHub上,地址為https://github.com/TheRuningAnt/KillAppDownload.git,歡迎下載查看.
如果各位有更好的思路或者建議的話,跪求指點,多謝!
Kill應用之後斷點續傳的實現