基於iOS 10、realm封裝的下載器

weixin_33816946發表於2018-03-05

程式碼地址如下:
http://www.demodashi.com/demo/11653.html

概要

在決定自己封裝一個下載器前,我本以為沒有那麼複雜,可在實際開發過程中困難重重,再加上iOS10和Xcode8的釋出,更是帶來一些意外的麻煩,斷斷續續過了一個多月的時間才弄出一個可用的版本。目前網上關於iOS10下載模組出現的bug以及一些特殊情況如何處理的文章比較少,最起碼我還沒有看到過,這裡拋磚引玉,給小夥伴們提供一些思路,也算是這篇文章存在的一點點價值。

公司一個音訊專案的下載模組使用的是第三方的,總是會出現無法正常下載等問題,並且由於很難短時間內瞭解這個頗為龐大複雜的第三方庫,所有比較難以解決出現的bug,因此我決定自己封裝一個。當然網上會找到一些基於ASI封裝的下載器,下載demo簡單試用後均沒發現什麼問題,但是我還是棄用了,主要原因是怕出現問題,由於不瞭解這些第三方庫和ASI而無法解決,另一方面確實不想再將ASI引入到專案裡了,同時我覺得也確實應該好好研究下這方面的知識了。

在開發過程中發現這個太過頻繁使用的功能在iOS端並不那麼容易做好,基於Apple自己的介面開發確實比較難實現我們常用的下載需求,這或許就是AFN一直沒有很好的實現下載模組的原因,AFN對下載的封裝,完全基於Apple自己的介面簡單的封裝,其實和直接Apple的介面區別並不大,所以想直接使用AFN實現較為複雜下載功能的小夥伴可能要失望了。

下面說明下本文的講解思路,主要是按照下載功能進行模組化的講解,比如下載、斷點續傳、刪除資訊、更新資訊等,單個功能分開闡述,比較利於理解,也方便大家分不同的時間閱讀,避免一口氣讀完如此長的技術性文章的厭煩感,同時分模組闡述後大家覺得有用的可以借鑑下,覺得沒用的大可當糟粕一樣棄之。

注意 1、本文不敢妄稱封裝了可以直接在專案中使用的庫。一方面由於我自己只是寫了一個demo測試,還沒有在實際的專案中應用測試;另一方面由於這裡針對了iOS10以後蘋果出現的下載的bug進行了特殊處理,後續蘋果的API更新有可能會有變化。 2、本文旨在給有需求的小夥伴提供一些思路和意見,如果對大家有些許作用是我的榮幸,文中有任何不妥和錯誤煩請大家不吝筆墨給我指出來,感激不盡。

下載

本文的下載主要針對NSURLSession展開,其他的下載方式比如使用NSData,本文應用不到,這裡就不贅述了。

NSURLSession有2種下載模式
第一種:

NSURLRequest *request = [NSURLRequest requestWithURL:[NSURL URLWithString:self.downloadUrl]];
self.downloadSession = [NSURLSession sharedSession];
self.downloadTask = [self.downloadSession downloadTaskWithRequest:request];
[self.downloadTask resume];

第二種

NSURLRequest *request = [NSURLRequest requestWithURL:[NSURL URLWithString:self.downloadUrl]];
NSURLSessionConfiguration *sessionCon = [NSURLSessionConfiguration backgroundSessionConfigurationWithIdentifier:self.downloadUrl];
self.downloadSession = [NSURLSession sessionWithConfiguration:sessionCon delegate:self delegateQueue:[NSOperationQueue mainQueue]];
self.downloadTask = [self.downloadSession downloadTaskWithRequest:request];
[self.downloadTask resume];

2中方式的暫停下載和繼續下載均可以使用

[self.downloadTask suspend];
[self.downloadTask resume];

當然暫停和繼續還可以使用如下方式

[self.downloadTask cancelByProducingResumeData:^(NSData * _Nullable resumeData) {
    self.resumeData = resumeData;
}];
self.downloadTask = [self.downloadSession downloadTaskWithResumeData:self.resumeData];
[self.downloadTask resume];

注意 看到這裡一些小夥伴可能會有些疑惑,兩種下載方式和兩種暫停繼續的方式有何卻別,分別針對的是何種使用場景,改如何選擇,彆著急,下面的內容都會說明,這裡暫且有個印象就可以了。

後臺下載

眾所周知,自從NSURLSession釋出後,就可以輕鬆的實現後臺下載了,程式碼如下:

NSURLRequest *request = [NSURLRequest requestWithURL:[NSURL URLWithString:self.downloadUrl]];
NSURLSessionConfiguration *sessionCon = [NSURLSessionConfiguration backgroundSessionConfigurationWithIdentifier:self.downloadUrl];
self.downloadSession = [NSURLSession sessionWithConfiguration:sessionCon delegate:self delegateQueue:[NSOperationQueue mainQueue]];
self.downloadTask = [self.downloadSession downloadTaskWithRequest:request];
[self.downloadTask resume];

注意 你沒有看錯,就是上面的第二種下載方式,這裡也就是下載的2種方式的區別,第一種不支援後臺下載,而第二種支援後臺下載。

斷點續傳

適用於網路不中斷、APP不重啟、iOS9以及以前版本系統。

這裡也有2種形式可以實現斷點續傳,在iOS9及以前的系統中區別並不大
第一種:

[self.downloadTask cancelByProducingResumeData:^(NSData * _Nullable resumeData) {
    self.resumeData = resumeData;
}];
self.downloadTask = [self.downloadSession downloadTaskWithResumeData:self.resumeData];
[self.downloadTask resume];

第二種:

[self.downloadTask cancelByProducingResumeData:^(NSData * _Nullable resumeData) {
    
}];

在中斷下載後可以直接在block中獲取繼續下載需要使用的resumeData,還可以到代理方法中獲取

- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didCompleteWithError:(NSError *)error {
if (error) {
        if ([error.userInfo objectForKey:NSURLSessionDownloadTaskResumeData]){
            self.resumeData = [error.userInfo objectForKey:NSURLSessionDownloadTaskResumeData];
            self.downloadTask = [self.downloadSession downloadTaskWithResumeData:self.resumeData];
            [self.downloadTask resume];
    }
}

注意 1、在繼續下載的時候,需要有一個NSData形式的resumeData資料實現繼續下載,通過轉換可以看出,resumeData本質上是一個XML檔案,主要記錄的是當前下載的連結、已經下載的資料大小、總資料大小等恢復下載需要的資訊,如下:

如下是一個下載的resumeData的XML資料:

<?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://sbslive.cnrmobile.com/storage/storage2/18/01/18/46eeb50b3f21325a6f4bd0e8ba4d2357.3gp</string>
<key>NSURLSessionResumeBytesReceived</key>
<integer>68188</integer>
<key>NSURLSessionResumeCurrentRequest</key>
<data>
YnBsaXN0MDDUAQIDBAUGeXpYJHZlcnNpb25YJG9iamVjdHNZJGFyY2hpdmVyVCR0b3AS
AAGGoK8QGQcILEdNTlRVVlcrWDlZWmhpamtsbW5vcHVVJG51bGzfEB8JCgsMDQ4PEBES
ExQVFhcYGRobHB0eHyAhIiMkJSYnKCkpKywtLi8wMCkvNCspNjc4OTo7KSk+OykvQkMt
RVIkMV8QIF9fbnN1cmxyZXF1ZXN0X3Byb3RvX3Byb3Bfb2JqXzIwXxAgX19uc3VybHJl
cXVlc3RfcHJvdG9fcHJvcF9vYmpfMjFfEBBzdGFydFRpbWVvdXRUaW1lXxAecmVxdWly
ZXNTaG9ydENvbm5lY3Rpb25UaW1lb3V0XxAgX19uc3VybHJlcXVlc3RfcHJvdG9fcHJv
cF9vYmpfMTBWJGNsYXNzXxAgX19uc3VybHJlcXVlc3RfcHJvdG9fcHJvcF9vYmpfMTFf
ECBfX25zdXJscmVxdWVzdF9wcm90b19wcm9wX29ial8xMl8QIF9fbnN1cmxyZXF1ZXN0
X3Byb3RvX3Byb3Bfb2JqXzEzXxAaX19uc3VybHJlcXVlc3RfcHJvdG9fcHJvcHNfECBf
X25zdXJscmVxdWVzdF9wcm90b19wcm9wX29ial8xNF8QIF9fbnN1cmxyZXF1ZXN0X3By
b3RvX3Byb3Bfb2JqXzE1XxAacGF5bG9hZFRyYW5zbWlzc2lvblRpbWVvdXRfECBfX25z
dXJscmVxdWVzdF9wcm90b19wcm9wX29ial8xNl8QFGFsbG93ZWRQcm90b2NvbFR5cGVz
XxAgX19uc3VybHJlcXVlc3RfcHJvdG9fcHJvcF9vYmpfMTdfECBfX25zdXJscmVxdWVz
dF9wcm90b19wcm9wX29ial8xOFIkMF8QIF9fbnN1cmxyZXF1ZXN0X3Byb3RvX3Byb3Bf
b2JqXzE5XxAfX19uc3VybHJlcXVlc3RfcHJvdG9fcHJvcF9vYmpfOV8QH19fbnN1cmxy
ZXF1ZXN0X3Byb3RvX3Byb3Bfb2JqXzhfEB9fX25zdXJscmVxdWVzdF9wcm90b19wcm9w
X29ial83XxAfX19uc3VybHJlcXVlc3RfcHJvdG9fcHJvcF9vYmpfNl8QH19fbnN1cmxy
ZXF1ZXN0X3Byb3RvX3Byb3Bfb2JqXzVfEB9fX25zdXJscmVxdWVzdF9wcm90b19wcm9w
X29ial80XxAfX19uc3VybHJlcXVlc3RfcHJvdG9fcHJvcF9vYmpfM1IkMl8QH19fbnN1
cmxyZXF1ZXN0X3Byb3RvX3Byb3Bfb2JqXzFfEB9fX25zdXJscmVxdWVzdF9wcm90b19w
cm9wX29ial8wXxAfX19uc3VybHJlcXVlc3RfcHJvdG9fcHJvcF9vYmpfMhAJgACAACMA
AAAAAAAAAAiAAoAYgAeACoAKgACAB4ALgAAQAIAMgA0QAoAOgAiAAIAAgAmACIAAgAcQ
FoADgAKABgjTSA9JKUtMV05TLmJhc2VbTlMucmVsYXRpdmWAAIAFgARfEFtodHRwOi8v
c2JzbGl2ZS5jbnJtb2JpbGUuY29tL3N0b3JhZ2Uvc3RvcmFnZTIvMTgvMDEvMTgvNDZl
ZWI1MGIzZjIxMzI1YTZmNGJkMGU4YmE0ZDIzNTcuM2dw0k9QUVJaJGNsYXNzbmFtZVgk
Y2xhc3Nlc1VOU1VSTKJRU1hOU09iamVjdCNATgAAAAAAABAACRAEE///////////U0dF
VNNbXA9dYmdXTlMua2V5c1pOUy5vYmplY3RzpF5fYGGAD4AQgBGAEqRjZGVmgBOAFIAV
gBaAF1pVc2VyLUFnZW50VkFjY2VwdF8QD0FjY2VwdC1MYW5ndWFnZV8QD0FjY2VwdC1F
bmNvZGluZ18QL1pZTERvd25sb2FkZXIvMSBDRk5ldHdvcmsvODA4LjAuMiBEYXJ3aW4v
MTYuMC4wUyovKlVlbi11c11nemlwLCBkZWZsYXRl0k9QcXJfEBNOU011dGFibGVEaWN0
aW9uYXJ5o3N0U18QE05TTXV0YWJsZURpY3Rpb25hcnlcTlNEaWN0aW9uYXJ50k9Qdndc
TlNVUkxSZXF1ZXN0onhTXE5TVVJMUmVxdWVzdF8QD05TS2V5ZWRBcmNoaXZlctF7fF8Q
G05TS2V5ZWRBcmNoaXZlUm9vdE9iamVjdEtleYABAAgAEQAaACMALQAyADcAUwBZAJoA
nQDAAOMA9gEXAToBQQFkAYcBqgHHAeoCDQIqAk0CZAKHAqoCrQLQAvIDFAM2A1gDegOc
A74DwQPjBAUEJwQpBCsELQQ2BDcEOQQ7BD0EPwRBBEMERQRHBEkESwRNBE8EUQRTBFUE
VwRZBFsEXQRfBGEEYwRlBGcEaQRqBHEEeQSFBIcEiQSLBOkE7gT5BQIFCAULBRQFHQUf
BSAFIgUrBS8FNgU+BUkFTgVQBVIFVAVWBVsFXQVfBWEFYwVlBXAFdwWJBZsFzQXRBdcF
5QXqBgAGBAYaBicGLAY5BjwGSQZbBl4GfAAAAAAAAAIBAAAAAAAAAH0AAAAAAAAAAAAA
AAAAAAZ+
</data>
<key>NSURLSessionResumeEntityTag</key>
<string>"5534b35d-7c7be1"</string>
<key>NSURLSessionResumeInfoTempFileName</key>
<string>CFNetworkDownload_JhfLFD.tmp</string>
<key>NSURLSessionResumeInfoVersion</key>
<integer>2</integer>
<key>NSURLSessionResumeOriginalRequest</key>
<data>
YnBsaXN0MDDUAQIDBAUGUFFYJHZlcnNpb25YJG9iamVjdHNZJGFyY2hpdmVyVCR0b3AS
AAGGoKwHCCQ7QUJISUojS0xVJG51bGzfEBkJCgsMDQ4PEBESExQVFhcYGRobHB0eHyAh
IiMkJSYnKCgqJywjLS4vKionLyonNjclOVIkMV8QEHN0YXJ0VGltZW91dFRpbWVfEB5y
ZXF1aXJlc1Nob3J0Q29ubmVjdGlvblRpbWVvdXRfECBfX25zdXJscmVxdWVzdF9wcm90
b19wcm9wX29ial8xMFYkY2xhc3NfECBfX25zdXJscmVxdWVzdF9wcm90b19wcm9wX29i
al8xMV8QIF9fbnN1cmxyZXF1ZXN0X3Byb3RvX3Byb3Bfb2JqXzEyXxAgX19uc3VybHJl
cXVlc3RfcHJvdG9fcHJvcF9vYmpfMTNfEBpfX25zdXJscmVxdWVzdF9wcm90b19wcm9w
c18QIF9fbnN1cmxyZXF1ZXN0X3Byb3RvX3Byb3Bfb2JqXzE0XxAgX19uc3VybHJlcXVl
c3RfcHJvdG9fcHJvcF9vYmpfMTVfEBpwYXlsb2FkVHJhbnNtaXNzaW9uVGltZW91dF8Q
FGFsbG93ZWRQcm90b2NvbFR5cGVzUiQwXxAfX19uc3VybHJlcXVlc3RfcHJvdG9fcHJv
cF9vYmpfOV8QH19fbnN1cmxyZXF1ZXN0X3Byb3RvX3Byb3Bfb2JqXzhfEB9fX25zdXJs
cmVxdWVzdF9wcm90b19wcm9wX29ial83XxAfX19uc3VybHJlcXVlc3RfcHJvdG9fcHJv
cF9vYmpfNl8QH19fbnN1cmxyZXF1ZXN0X3Byb3RvX3Byb3Bfb2JqXzVfEB9fX25zdXJs
cmVxdWVzdF9wcm90b19wcm9wX29ial80XxAfX19uc3VybHJlcXVlc3RfcHJvdG9fcHJv
cF9vYmpfM1IkMl8QH19fbnN1cmxyZXF1ZXN0X3Byb3RvX3Byb3Bfb2JqXzFfEB9fX25z
dXJscmVxdWVzdF9wcm90b19wcm9wX29ial8wXxAfX19uc3VybHJlcXVlc3RfcHJvdG9f
cHJvcF9vYmpfMhAJIwAAAAAAAAAACIACgAuAB4AJgAmAAIAHgAoQABACgAiAAIAAgAeA
CIAAgAcQEIADgAKABgjTPA09Kj9AV05TLmJhc2VbTlMucmVsYXRpdmWAAIAFgARfEFto
dHRwOi8vc2JzbGl2ZS5jbnJtb2JpbGUuY29tL3N0b3JhZ2Uvc3RvcmFnZTIvMTgvMDEv
MTgvNDZlZWI1MGIzZjIxMzI1YTZmNGJkMGU4YmE0ZDIzNTcuM2dw0kNERUZaJGNsYXNz
bmFtZVgkY2xhc3Nlc1VOU1VSTKJFR1hOU09iamVjdCNATgAAAAAAABAACRP/////////
/9JDRE1OXE5TVVJMUmVxdWVzdKJPR1xOU1VSTFJlcXVlc3RfEA9OU0tleWVkQXJjaGl2
ZXLRUlNfEBtOU0tleWVkQXJjaGl2ZVJvb3RPYmplY3RLZXmAAQAIABEAGgAjAC0AMgA3
AEQASgB/AIIAlQC2ANkA4AEDASYBSQFmAYkBrAHJAeAB4wIFAicCSQJrAo0CrwLRAtQC
9gMYAzoDPANFA0YDSANKA0wDTgNQA1IDVANWA1gDWgNcA14DYANiA2QDZgNoA2oDbANu
A3ADcQN4A4ADjAOOA5ADkgPwA/UEAAQJBA8EEgQbBCQEJgQnBDAENQRCBEUEUgRkBGcE
hQAAAAAAAAIBAAAAAAAAAFQAAAAAAAAAAAAAAAAAAASH
</data>
<key>NSURLSessionResumeServerDownloadDate</key>
<string>Mon, 20 Apr 2015 08:05:49 GMT</string>
</dict>
</plist>

小夥伴們大概看一下就能從resumeData中看出每一項所表達的意思,其中NSURLSessionResumeOriginalRequest和NSURLSessionResumeCurrentRequest是二進位制的,轉換成字串後依然是XML檔案,同樣是繼續下載需要使用的,這裡不必深究,大概明白就可以。 2、小夥伴們可能會疑惑,2種獲取繼續下載的資料有何區別,嚴格來說在iOS9及之前版本肯定是沒有區別的,均可以實現繼續下載,但是在iOS10之後是有的,後面再說。並且第二種方式看似麻煩,後面也會講解這種方式的好處。 3、有些小夥伴可能會問使用下面的方式不是也可以實現繼續下載的功能嗎?這裡還是有很大的區別的,如字面所表達的一樣suspend是掛起的意思,而cancel是取消的意思,也就是說當呼叫suspend的時候當前的下載程式並沒有被銷燬,只是暫時停止下載而已,這個下載還佔用著系統的資源,而呼叫cancel時當前的下載程式被銷燬了,不佔用系統資源,再次呼叫resume是沒有作用的。這裡小夥伴們可以先了解這點區別,在模擬器和真機開發中若是不瞭解這一點區別會造成一些奇怪的bug,後面會有說明。 [self.downloadTask suspend]; [self.downloadTask resume]; 4、這裡一些小夥伴還會有一個疑問,前面說過下載有2種方式,一種支援後臺下載一種不支援,但是斷點續傳均可以使用同樣的方式,那麼是否有區別?其實我們在使用上沒有區別,但是系統在處理時是有區別的。 這裡先簡單的說明下區別,使用NSURLSession下載時系統會在本地加儲存2份資訊,一份資訊是我們要下載的檔案本身,另一份資訊是繼續下載資料時需要的resumeData,其中要下載的檔案本身我們是可以在沙盒目錄中找到的,而resumeData只能通過系統獲取。 從上面的resumeData的XML資訊中我們可以獲取想要下載的檔案儲存在沙盒目錄中的檔名,NSURLSessionResumeInfoTempFileName表示檔名,CFNetworkDownload_JhfLFD.tmp表示具體檔案的檔名,檔名均以“CFNetworkDownload_”開頭,以“.tmp”結尾,也就是說無論我們下載的是什麼型別的檔案,在下載完成前系統都會以.tmp的型別儲存檔案。那麼針對2中下載方式,系統又是如何處理的呢,這裡根據我的觀察和測試,當使用非後臺下載模式時,系統會將未下載完成的臨時檔案儲存在tmp資料夾下:/var/mobile/Containers/Data/Application/4BBAD185-327C-4BE5-8D9C-983DFDBAC133/tmp,這種情況下當APP被殺死時,tmp檔案被清空,再次進入APP後將無法獲取繼續下載的資料和已經下載的檔案本身。而使用後臺下載模式,下載的資料本身就不會被儲存到tmp資料夾下了,此時會儲存在Library目錄下,具體的檔案路徑是:/var/mobile/Containers/Data/Application/BDE5B81A-4E79-4210-BB7B-20C3B4035D63/Library/Caches/com.apple.nsurlsessiond/Downloads/com.zyldownloader.ZYLDownloaderTest/,其中com.zyldownloader.ZYLDownloaderTest資料夾是根據當前專案的bundleIdentifier命名的,這個資料夾裡儲存的就是我們未完成下載的檔案。當APP被殺死後,這個資料夾不會被清空,再次啟動APP後仍然可以獲取未完成下載的.tmp檔案。 5、這裡大家要注意,在iOS8中resumeData的XML資料與iOS9和iOS10不一樣,需要相容iOS8的專案這裡要單獨處理下,思路是一樣的,很簡單,我在這裡就不贅述了。
到這裡就可以實現一個簡易的支援後臺下載和斷點續傳的下載器了,只是要在比較理想的網路環境和iOS9及以前版本的系統下,若專案中的下載需求不高,到這裡其實足夠了,難度不大。但若想實現一個禁得起折騰的下載器,到這裡還只是個開始,需要繼續往下看。

APP被殺死後重啟的斷點續傳

適用於網路不中斷、iOS9以及以前版本系統。

上面我們實現了斷點續傳,但是當APP被殺死再重啟後就無法在繼續下載了,那麼這裡如何解決呢,系統其實也為我們做好了準備。當APP重新啟動後,我們如果想繼續下載就要獲取resumeData,這裡就需要通過代理方法獲取了,首先要啟用當前下載,程式碼如下:

NSURLSessionConfiguration *sessionCon = [NSURLSessionConfiguration backgroundSessionConfigurationWithIdentifier:self.downloadUrl];
_downloadSession = [NSURLSession sessionWithConfiguration:sessionCon delegate:self delegateQueue:[NSOperationQueue mainQueue]];

注意 這裡啟用下載的前提是,建立下載的時候使用的後臺下載模式,同時要為當前下載傳入一個ID以標識當前下載,比如我這裡直接使用的下載連線作為標識,只有這樣才能使用上面的程式碼啟用代理,獲取resumeData
在代理中獲取resumeData的程式碼如下:
- (void)URLSession:(NSURLSession )session task:(NSURLSessionTask )task didCompleteWithError:(NSError *)error {
if (error) {
if ([error.userInfo objectForKey:NSURLSessionDownloadTaskResumeData]){
self.resumeData = [error.userInfo objectForKey:NSURLSessionDownloadTaskResumeData];
self.downloadTask = [self.downloadSession downloadTaskWithResumeData:self.resumeData];
[self.downloadTask resume];
}
}

注意 獲取resumeData的方法與上面取消下載時獲取的方法一致,這裡就是這個方法的意義,是可以在APP重啟後獲取resumeData的。
在網路正常,建立下載和啟用下載正常的情況下,是可以正確獲取resumeData的,從而實現了殺死APP後斷點續傳的功能。

網路中斷後又恢復的斷點續傳

適用於iOS9以及以前版本系統

很多小夥伴也許會不解,網路中斷再恢復就繼續下載啊,這有什麼好說的,這似乎是理所當然的事情,我本來也是這麼認為的,畢竟太愛了,也太相信,但是萬萬沒想到當網路中斷後,無論是通過cancel還是代理方法都無法獲取resumeData,在代理方法中只能獲取這些報錯資訊:

Error Domain=NSURLErrorDomain Code=-1002 "unsupported URL"    UserInfo={NSLocalizedDescription=unsupported URL}

1、然而對於我們實現繼續下載沒有意義。到這裡我們似乎不知所措了,因為我們已經無法獲取繼續下載資料,這裡也是我遇到的第一個比較難以解決的坑,網上各種查資料也沒有找到解決辦法,似乎這種情況就應該重新下載,但是這明顯不符合使用者對於下載的需求,甚至可以說是一種很差的體驗。並且在網路中斷後,沙盒目錄下的未下載完成的檔案也會被刪除,然後替換成另外一組.tmp檔案,可是這些寫得.tmp檔案不是我們已經下載的資料,暫且不知道用處,無法使用。
2、在嘗試各種解決辦法不通的情況下,我通過對resumeData XML資料的分析,決定自己生成resumeData,當然我自己在網上查閱眾多資料沒有發現和我一致的方案,所以暫且認為是我自己發現的方法,若有小夥伴發現比我早的使用這個方案的,請附上鍊接,我會把剛才那句“是我自己發現的方法”刪除,所以不必太糾結這個,關鍵是我們的問題是否得到解決。
3、方案有了,那我們如何自己生成resumeData呢,通過上面轉換成XML形式的resumeData,不難看出,對於一個固定下載連結,有一些資訊是固定的,比如表示下載檔案連結的NSURLSessionDownloadURL、本地快取檔名NSURLSessionResumeInfoTempFileName等等,從直觀分析看,處於變化的資料似乎只有表示已經下載的檔案大小的NSURLSessionResumeBytesReceived,至於NSURLSessionResumeCurrentRequest和NSURLSessionResumeOriginalRequest,通過縱向分析同一個下載連結和橫向對比多個下載連結,發現基本都是一樣的,因此這裡暫且認為他們也是固定的。
4、大概分析出一個規律,我們需要設定一個合理的建立resumeData的方案,經過多種方案的測試,我這裡給出一種相對而言比較靠譜的方案,流程如下:

網路中斷恢復後斷點續傳流程

注意 這裡為了用自己建立的resumeData實現斷點續傳的功能,需要在沙盒目錄的Documents目錄下建立3個資料夾:ZYLDownloads、ZYLResumeDownloads、ZYLUnDownloads,ZYLDownloads用於儲存已經一下載好的檔案,ZYLUnDownloads用於儲存沒有下載好的檔案,ZYLResumeDownloads用於儲存resumeData資料。
下面逐步講解
①獲取系統提供的resumeData。這裡在下載進度的代理中獲取,程式碼如下:
- (void)URLSession:(NSURLSession )session downloadTask:(NSURLSessionDownloadTask )downloadTask didWriteData:(int64_t)bytesWritten totalBytesWritten:(int64_t)totalBytesWritten totalBytesExpectedToWrite:(int64_t)totalBytesExpectedToWrite {
if (self.isBeginDownload == NO) {
//還沒有開始下載
self.isBeginDownload = YES;
//在這裡取得繼續下載的資料
[self.downloadTask cancelByProducingResumeData:^(NSData * _Nullable resumeData) {
//到代理中獲取resumeData,此處獲取的resumeData在iOS10和Xcode8中有可能無法使用,shit!
}];
}
}

注意 這裡取消下載的時候使用的是cancelByProducingResumeData:^(NSData * _Nullable resumeData),在這裡其實是可以獲取resumeData的,但是在iOS10中發現會出現無法使用的情況,因此這裡到- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didCompleteWithError:(NSError *)error 代理中獲取,步驟和上面提到的一樣,獲取到resumeData,記錄下來即可。

②分析resumeData,程式碼如下:

- (void)parseResumeData:(NSData *)resumeData {
    NSString *XMLStr = [[NSString alloc] initWithData:resumeData encoding:NSUTF8StringEncoding];
    self.resumeString = [NSMutableString stringWithFormat:@"%@", XMLStr];
    NSRange tmpRange = [XMLStr rangeOfString:@"NSURLSessionResumeInfoTempFileName"];
    NSString *tmpStr = [XMLStr substringFromIndex:tmpRange.location + tmpRange.length];
    NSRange oneStringRange = [tmpStr rangeOfString:@"<string>"];
    NSRange twoStringRange = [tmpStr rangeOfString:@"</string>"];
    //記錄tmp檔名
    self.tmpFilename = [tmpStr substringWithRange:NSMakeRange(oneStringRange.location + oneStringRange.length, twoStringRange.location - oneStringRange.location - oneStringRange.length)];
    
    //有資料,儲存到本地
    //儲存資料,:self.resumeDirectoryStr就是儲存resumeData的路徑ZYLResumeDownloads
    BOOL isS = [resumeData writeToFile:self.resumeDirectoryStr atomically:NO];
    if (isS) {
        //繼續儲存資料成功
        NSLog(@"繼續儲存資料成功");
    } else {
        //繼續儲存資料失敗
        NSLog(@"繼續儲存資料失敗");
    }
    
}

③分析成功後使用獲取的resumeData繼續下載,程式碼如下:

self.downloadTask = [self.downloadSession downloadTaskWithResumeData:newData];
[self.downloadTask resume];

④快取下載的檔案
這裡採用的是每下載1M就快取一次,因為在網路斷開後這些資料會消失,並且我們無法準確及時的判斷網路何時中斷,所以只能採用這種看似笨拙的方法了,當然有一種可能就是,網路斷開時下載的檔案大小和網路恢復時下載的檔案大小會不一致,有大概不超過1M的誤差,還算在可以接受的範圍內。這裡在下載進度的代理方法中處理,程式碼如下:

- (void)URLSession:(NSURLSession *)session downloadTask:(NSURLSessionDownloadTask *)downloadTask didWriteData:(int64_t)bytesWritten totalBytesWritten:(int64_t)totalBytesWritten totalBytesExpectedToWrite:(int64_t)totalBytesExpectedToWrite {
    CGFloat addSize = (totalBytesWritten - self.lastDownloadSize) / 1024.0 / 1024.0;
    if (addSize >= 1.0) {
        //下載的量大於1M,遷移
        NSError *error = nil;
        if ([self.fileManager fileExistsAtPath:self.unDownloadStr]) {
            //存在則刪除
            [self.fileManager removeItemAtPath:self.unDownloadStr error:nil];
        }
        BOOL isS = [self.fileManager copyItemAtPath:self.libraryUnDownloadStr toPath:self.unDownloadStr error:&error];
        if (isS) {
            //NSLog(@"移動成功");
        } else {
            NSLog(@"移動失敗%@", error);
        }
        
        self.lastDownloadSize = totalBytesWritten;
    }
}

⑤網路中斷後自行建立resumeData
由於網路中斷我們是無法獲取系統提供的resumeData的,所以要根據上面獲取的資訊自行建立,同時將資料儲存到本地。程式碼如下:

- (void)updateLocalResumeData {
    //在這建立resumeData
    //首先取出沙盒目錄下的快取檔案
    NSData *libraryData = [NSData dataWithContentsOfFile:self.unDownloadStr];
    NSInteger libraryLength = libraryData.length;
    
    //計算當期表示resumeData資料大小的range
    //記錄tmp檔案大小範圍
    NSRange integerRange = [self.resumeString rangeOfString:@"NSURLSessionResumeBytesReceived"];
    NSString *integerStr = [self.resumeString substringFromIndex:integerRange.location + integerRange.length];
    NSRange oneIntegerRange = [integerStr rangeOfString:@"<integer>"];
    NSRange twonIntegerRange = [integerStr rangeOfString:@"</integer>"];
    self.libraryFilenameRange = NSMakeRange(oneIntegerRange.location + oneIntegerRange.length + integerRange.location + integerRange.length, twonIntegerRange.location - oneIntegerRange.location - oneIntegerRange.length);
    //用新的資料替換
    [self.resumeString replaceCharactersInRange:self.libraryFilenameRange withString:[NSString stringWithFormat:@"%ld", (long)libraryLength]];
    
    NSData *newResumeData = [self.resumeString dataUsingEncoding:NSUTF8StringEncoding];
    self.resumeData = newResumeData;
    
    //同時儲存在本地一份
    //獲取儲存路徑
    NSString *path = [[NSSearchPathForDirectoriesInDomains(NSDocumentDirectory,NSUserDomainMask,YES) objectAtIndex:0] stringByAppendingPathComponent:@"ZYLResumeDownloads"];
    //獲取檔名
    NSString *resumeFileName = [path stringByAppendingPathComponent:[@"resume_" stringByAppendingString:[ZYLTool encodeFilename:self.downloadUrl]]];
    //儲存資料
    BOOL isS = [self.resumeData writeToFile:resumeFileName atomically:NO];
    if (isS) {
        //繼續儲存資料成功
        NSLog(@"繼續儲存資料成功");
    } else {
        //繼續儲存資料失敗
        NSLog(@"繼續儲存資料失敗");
    }
}

注意 我這裡只對一開始獲取的系統提供的resumeData的NSURLSessionResumeBytesReceived資料進行了更新,經過多次測試,在不修改其他資料的情況下是可以繼續下載的。
⑥網路恢復後實現繼續下載
在用自己的資料實現繼續下載之前,要把library目錄下的系統快取檔案刪除,然後將自己快取的未下載完成的檔案移動到對應的資料夾下,然後再從本地讀取快取的resumeData,實現斷點續傳,程式碼如下:

- (void)resumeAtNoResumeData {
    [_downloadSession invalidateAndCancel];
    _downloadSession = nil;
    //去本地讀取繼續下載資料
    self.resumeData = [NSData dataWithContentsOfFile:self.resumeDirectoryStr];
    //將繼續下載的資料移動到對應的目錄下
    NSError *error = nil;
    if ([self.fileManager fileExistsAtPath:self.libraryUnDownloadStr]) {
        BOOL isS = [self.fileManager removeItemAtPath:self.libraryUnDownloadStr error:&error];
        if (!isS) {
            //移除失敗
            NSLog(@"移除library下的繼續下載資料對應的檔案失敗:%@", error);
        }
    }
    
    BOOL isS = [self.fileManager copyItemAtPath:self.unDownloadStr toPath:self.libraryUnDownloadStr error:&error];
    if (!isS) {
        //拷貝失敗
        NSLog(@"拷貝繼續下載檔案到library下失敗:%@", error);
    } else {
        //拷貝成功後開啟繼續下載
        //建立下載任務,繼續下載
        self.downloadTask = [self.downloadSession downloadTaskWithResumeData:self.resumeData];
        [self.downloadTask resume];
    }
}

注意 上面講一開始下載後,我們要在下載進度的代理中獲取系統提供的resumeData資料,但是這種在本地已經有resumeData資料的前提下就不需要獲取了。

到這裡網路中斷後實現斷點續傳的功能就完成了,在iOS9及以前的版本是沒有問題的,但是iOS10釋出後就失效了,解決辦法繼續看下文。

在iOS10下實現斷點續傳

把這一塊單獨拿出來講是我始料未及的,不過它確實發生了。

事情是這樣的,有一天iOS10釋出了,Xcode8也釋出了,廢了好大得勁更新後,再次執行下載器demo,忽然發現下載器完全無法使用了,而控制檯打出了一串串這樣的報錯資訊:

*** -[NSKeyedUnarchiver initForReadingWithData:]: data is NULL
*** -[NSKeyedUnarchiver initForReadingWithData:]: data is NULL
Invalid resume data for background download. Background downloads must use http or https and must download to an accessible file.

從報錯資訊來看,是在繼續下載時傳入的resumeData不可用,可是這裡用的是通過呼叫:cancelByProducingResumeData從系統獲取的resumeData,並且從代理中獲取的resumeData也是同樣的報錯資訊,均不可用。經過多番查詢,終於在stackoverflow找到了遇到相同問題的小夥伴,有大神給出了暫時的解決方案。問題確實出在resumeData中,其中NSURLSessionResumeCurrentRequest和NSURLSessionResumeOriginalRequest的解碼有問題,這就是為什麼-[NSKeyedUnarchiver initForReadingWithData:]: data is NULL會報錯2次,解決的方案就是我們在拿到系統的resumeData後要檢測資料是否可以正確解碼,若不可需要從resumeData的XML資料中取出上面2項再次進行正確的編碼,然後建立一個新的resumeData傳給系統,完成繼續下載,經測試可用,那位大神給的是swift版本的程式碼,我按照處理邏輯寫出了OC程式碼,如下:

- (NSData *)getCorrectResumeData:(NSData *)resumeData {
    NSData *newData = nil;
    NSString *kResumeCurrentRequest = @"NSURLSessionResumeCurrentRequest";
    NSString *kResumeOriginalRequest = @"NSURLSessionResumeOriginalRequest";
    //獲取繼續資料的字典
    NSMutableDictionary* resumeDictionary = [NSPropertyListSerialization propertyListWithData:resumeData options:NSPropertyListMutableContainers format:NULL error:nil];
    //重新編碼原始請求和當前請求
    resumeDictionary[kResumeCurrentRequest] = [self correctRequestData:resumeDictionary[kResumeCurrentRequest]];
    resumeDictionary[kResumeOriginalRequest] = [self correctRequestData:resumeDictionary[kResumeOriginalRequest]];
    newData = [NSPropertyListSerialization dataWithPropertyList:resumeDictionary format:NSPropertyListBinaryFormat_v1_0 options:NSPropertyListMutableContainers error:nil];
    
    return newData;
} 

- (NSData *)correctRequestData:(NSData *)data {
    NSData *resultData = nil;
    NSData *arData = [NSKeyedUnarchiver unarchiveObjectWithData:data];
    if (arData != nil) {
        return data;
    }
    
    NSMutableDictionary *archiveDict = [NSPropertyListSerialization propertyListWithData:data options:NSPropertyListMutableContainersAndLeaves format:nil error:nil];
    
    int k = 0;
    NSMutableDictionary *oneDict = [NSMutableDictionary dictionaryWithDictionary:archiveDict[@"$objects"][1]];
    while (oneDict[[NSString stringWithFormat:@"$%d", k]] != nil) {
        k += 1;
    }
    
    int i = 0;
    while (oneDict[[NSString stringWithFormat:@"__nsurlrequest_proto_prop_obj_%d", i]] != nil) {
        NSString *obj = oneDict[[NSString stringWithFormat:@"__nsurlrequest_proto_prop_obj_%d", i]];
        if (obj != nil) {
            [oneDict setObject:obj forKey:[NSString stringWithFormat:@"$%d", i + k]];
            [oneDict removeObjectForKey:obj];
            archiveDict[@"$objects"][1] = oneDict;
        }
        i += 1;
    }
    
    if (oneDict[@"__nsurlrequest_proto_props"] != nil) {
        NSString *obj = oneDict[@"__nsurlrequest_proto_props"];
        [oneDict setObject:obj forKey:[NSString stringWithFormat:@"$%d", i + k]];
        [oneDict removeObjectForKey:@"__nsurlrequest_proto_props"];
        archiveDict[@"$objects"][1] = oneDict;
    }
    
    NSMutableDictionary *twoDict = [NSMutableDictionary dictionaryWithDictionary:archiveDict[@"$top"]];
    if (twoDict[@"NSKeyedArchiveRootObjectKey"] != nil) {
        [twoDict setObject:twoDict[@"NSKeyedArchiveRootObjectKey"] forKey:[NSString stringWithFormat:@"%@", NSKeyedArchiveRootObjectKey]];
        [twoDict removeObjectForKey:@"NSKeyedArchiveRootObjectKey"];
        archiveDict[@"$top"] = twoDict;
    }
    
    resultData = [NSPropertyListSerialization dataWithPropertyList:archiveDict format:NSPropertyListBinaryFormat_v1_0 options:NSPropertyListMutableContainers error:nil];
    
    return resultData;
}

用法是將從系統獲取的resumeData傳給getCorrectResumeData:(NSData *)resumeData函式,獲取正確的resumeData。如果有小夥伴想要swift版本的程式碼,我的demo裡有。或者參考原貼

實現下載進度和下載速度

1、下載進度其實很容易實現,只需要在代理下載的代理方法中操作就可以了,程式碼如下:

- (void)URLSession:(NSURLSession *)session downloadTask:(NSURLSessionDownloadTask *)downloadTask didWriteData:(int64_t)bytesWritten totalBytesWritten:(int64_t)totalBytesWritten totalBytesExpectedToWrite:(int64_t)totalBytesExpectedToWrite {
    self.currentWriten = totalBytesWritten;
    self.downloaderProgress = (float)totalBytesWritten / (float)totalBytesExpectedToWrite;
}

2、在做下載速度時,我沒有參考網上的資料,似乎也很少,我沒有查閱不得而知。感覺可以靠自己的知識儲備實現,就按照自己小學學過的一個公式實現的:v = s / t,即速度 = 距離 / 時間,當然下載速度應該是:速度 = 下載量 / 時間,我們一般看到的下載速度都是1s內的下載量,因此我這開啟了一個定時器,每隔一秒計算一下下載量,從而計算出下載速度,程式碼如下:

//self.currentWriten表示當前的下載資料量實時在下載進度的代理中更新,self.lastWritten表示上一秒的資料下載量,每秒更新一次
self.speed = self.currentWriten - self.lastWritten ;

這裡直接使用的單位是b,使用者可讀的下載速度基本是kb/s、m/s,因此需要根據不同的情況轉化一下,參考程式碼如下:

NSString *speedStr = nil;
if (speed >= 0 && speed < 1024) {
    //B
    speedStr = [NSString stringWithFormat:@"下載速度為:%ldb/s", (long)speed];
} else if (speed >= 1024 && speed < 1024 * 1024) {
    //KB
    speedStr = [NSString stringWithFormat:@"下載速度為:%.2lfkb/s", (long)speed / 1024.0];
} else if (speed >= 1024 * 1024) {
    //MB
    speedStr = [NSString stringWithFormat:@"下載速度為:%.2lfmb/s", (long)speed / 1024.0 / 1024.0];
}

NSLog(@"檔案:%@的下載速度:%@", downloaderUrl,speedStr);

這個只是我的實現方案,小夥伴們若有更好地實現方案,還請指教,非常感激。

實現下載數量的控制

只有一個檔案需要下載時通常可以不用考慮對下載數量進行控制,但是我們遇到的基本是需要下載多個檔案的情況,在移動裝置資源有限的前提下,合理控制下載數量變得很重要,同時這裡也將回應上文中的一個疑點。

設定一個屬性來表示和控制最多同時下載幾個檔案

/**
 * 同時下載的最大的檔案數量
 */
@property (assign, nonatomic) NSInteger maxDownloaderNum;

同時宣告瞭3個方法分別控制下載的流程,
分別是新加入一個下載的方法

- (void)addDownloader:(ZYLSingleDownloader *)downloader isHand:(BOOL)isHand isControl:(BOOL)isControl{
    //首先判斷是不是手動開啟新的下載
    if (isHand) {
        //是手動,強行開啟下載
        //判斷是否達到最大下載數目
        if (self.downloadingArray.count < self.maxDownloaderNum) {
            //沒有
            if (![self.downloadingArray containsObject:downloader]) {
                [self.downloadingArray addObject:downloader];
                [self.waitingDownlodArray removeObject:downloader];
            }
        } else {
            if (![self.downloadingArray containsObject:downloader]) {
                [self.downloadingArray addObject:downloader];
                [self.waitingDownlodArray removeObject:downloader];
            }
            //達到了
            //暫停最前面的正在下載
            ZYLSingleDownloader *firstDownloader = [self.downloadingArray firstObject];
            [self removeDownloader:firstDownloader isHand:isHand isControl:YES];
        }
        
        //開啟下載
        if (isControl) {
            downloader.isHand = isHand;
            [downloader start];
        }
        
    } else {
        //不是手動
        if (self.downloadingArray.count < self.maxDownloaderNum) {
            //還沒有達到最大下載數
            if (![self.downloadingArray containsObject:downloader]) {
                [self.downloadingArray addObject:downloader];
                [self.waitingDownlodArray removeObject:downloader];
            }
            
            //開啟下載
            if (isControl) {
                downloader.isHand = isHand;
                [downloader start];
            }
            
        } else {
            //已經達到了最大的下載數
            //判斷正在正在下載的陣列中是否有此下載器
            if ([self.downloadingArray containsObject:downloader]) {
                
            } else {
                [self.waitingDownlodArray addObject:downloader];
                NSLog(@"達到最大下載數目,已經加入待下載陣列");
            }
        }
    }
}

注意:這裡有2個引數需要解釋下 1、一個是isHand,表示需要操作的當前下載器是否是執行強制操作,比如,當我設定最多同時下載3個檔案,此時有3個檔案正在下載,而這裡又新增了一個下載,此時有2種情況,一種情況是把新的下載器加入等待佇列,當前面的下載器下載完成後開啟下載,另一種情況是要首先下載新新增的下載器,這種情況就要移除一個正在下載的下載器了,所以需要這個參數列明如何操作當前下載器。 2、另一個是isControl,表示操作當前下載器後是否需要執行對應下載任務,比如新增一個下載器後,我們需要啟動下載,但是有可能需要在別處啟動下載,也有可能就在新增後啟動下載,所以這裡需要一個參數列示如何操作。

移除一個下載的方法

- (void)removeDownloader:(ZYLSingleDownloader *)downloader isHand:(BOOL)isHand isControl:(BOOL)isControl {
    __block BOOL isE = NO;
    [self.downloadingArray enumerateObjectsUsingBlock:^(ZYLSingleDownloader *  _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
        if ([obj.downloadUrl isEqualToString:downloader.downloadUrl]) {
            isE = YES;
            *stop = YES;
        }
    }];
    
    if (isE) {
        //存在
        if (downloader.downloaderProgress >= 1.0) {
            //已經下載完成
            [self.downloadingArray removeObject:downloader];
        } else {
            //還沒有下載完成
            if (![self.waitingDownlodArray containsObject:downloader]) {
                //在下載器沒有被刪除的時候新增到等待下載陣列
                if (downloader.downloaderState != ZYLDownloaderStateDeleted) {
                    [self.waitingDownlodArray addObject:downloader];
                } else if (downloader.downloaderState == ZYLDownloaderStateDeleted) {
                    if ([self.waitingDownlodArray containsObject:downloader]) {
                        [self.waitingDownlodArray removeObject:downloader];
                    }
                }
                
            }
            
            [self.downloadingArray removeObject:downloader];
        }
        
        if (isControl == YES) {
            downloader.isHand = isHand;
            [downloader cancelRorOtherDownloader];
            
        }
        
        if (isHand) {
            //是手動
            [self checkDownloadProgressExceptDownloader:downloader];
        } else {
            //不是手動
            //檢查下載流程
            [self checkDownloadProgressExceptDownloader:nil];
        }
        
    } else {
        //不存在
        NSLog(@"正在下載的檔案中不存在這個下載");
        if (downloader.downloaderState == ZYLDownloaderStateDeleted) {
            //檢測等待陣列中是否有此資料
            if ([self.waitingDownlodArray containsObject:downloader]) {
                [self.waitingDownlodArray removeObject:downloader];
            }
        }
    }
}

有了新增和移除還不夠,我們往往需要在移除一個下載後檢測等待佇列裡是否有需要下載的下載器,因此還需要一個檢查下載流程的方法

- (void)checkDownloadProgressExceptDownloader:(ZYLSingleDownloader *)downloader {
    //判斷正在下載的陣列中是否有空缺
    if (self.downloadingArray.count < self.maxDownloaderNum) {
        //有空缺
        //檢查等待下載的陣列中是否有資料
        if (self.waitingDownlodArray.count > 0) {
            //有
            //尋找第一個需要下載的資料
            __block ZYLSingleDownloader *firstDownloader = nil;
            if (downloader == nil) {
                [self.waitingDownlodArray enumerateObjectsUsingBlock:^(ZYLSingleDownloader *  _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
                    if (obj.isHand == NO) {
                        firstDownloader = obj;
                        *stop = YES;
                    }
                }];
            } else {
                [self.waitingDownlodArray enumerateObjectsUsingBlock:^(ZYLSingleDownloader *  _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
                    if (![obj.downloadUrl isEqualToString:downloader.downloadUrl] && obj.isHand == NO) {
                        firstDownloader = obj;
                        *stop = YES;
                    }
                }];
            }
            
            if (firstDownloader == nil) {
                NSLog(@"沒有找到需要開啟的下載的任務");
            } else {
                firstDownloader.isHand = NO;
                if (firstDownloader.downloaderProgress > 0) {
                    [firstDownloader resumeisHand:firstDownloader.isHand];
                } else {
                    [firstDownloader start];
                }
                
                if (![self.downloadingArray containsObject:firstDownloader]) {
                    [self.downloadingArray addObject:firstDownloader];
                    [self.waitingDownlodArray removeObject:firstDownloader];
                }
            }
            
        } else {
            //沒有
            NSLog(@"已經沒有等待下載的資料了");
        }
        
    } else {
        //沒有空缺
        NSLog(@"已經達到最大的同時下載數目");
    }
}

注意 1、以上3個方法中有一些地方看不明白,不必深究,這裡是從demo中擷取的片段,需要結合其他地方一起理解。大體明白每個方法的作用即可。 2、檢查下載流程的方法有一點需要解釋下,這裡的引數isHand還可以用於控制下載流程,比如一個下載的的isHand是YES表示這個下載器是使用者想讓他停止下載的,因此即使正在下載的數量沒有達到最大限制,也不應該自動開啟這個下載,同時剛剛停止的那個下載也不應該立刻就開啟,否則有可能出現無法停止某個下載的bug。 3、我這做這個功能是的時候一直在模擬器執行,沒有什麼問題,當我在真機執行時,發現同時下載的數量有可能不是我設定的最大數量,並且當暫停一個下載的時候,會出現不自動開啟另一個下載的問題,還經常會出現下載失敗的問題,網路明明是好的。反覆測試,發現在真機上系統最大允許同時開啟3個下載器,我們在暫停一個下載的時候不能使用suspend,前面說過,suspend只是將當前下載掛起,下載執行緒並沒有銷燬,還在佔用系統資源,因此當採用suspend暫停時,是有可能不會自動開啟下一個下載的,這裡全部換成使用cnacel暫停下載,繼續下載時使用resumeData。系統規定我們最多同時開啟3個下載執行緒,參考多個成熟且具有下載功能的APP,基本是單個下載,也就是同時只能下載一個,因此這裡也建議大家使用單個下載,如有需求開啟多個,不要超過3個。 4、還需要注意系統如果有應用正在下載也是會影響當前程式的,也就是說整個手機一共最多有3個下載同時進行,這裡需要做好處理。

下載器整體思路總結

前面分模組闡述了下載器的各個部分,在開發一個可用的下載器時,這些模組並不是獨立的,而是協同合作,因此這裡闡述和總結一下整個下載器的實現思路。

首先看分析圖:
下載器下載流程

1、ZYLDownloader是下載器的控制器,主要功能是協調各個單獨的下載器ZYLSingleDownloader,控制下載器的下載流程,本身不負責下載、暫停、繼續功能。而ZYLSingleDownloader負責下載、暫停和繼續下載;
2、新增一個下載只有一種情況,就是這個下載是沒有被新增過的新的下載,若新增過,執行繼續下載操作;
3、繼續下載略微複雜些,涉及的情況會多一些。第一種情況是執行常規的暫停,這是我們只需要在暫停的時候拿到resumeData,繼續的時候傳入即可。第二種情況是APP重啟後,我們需要通過啟用,下載的代理方法中獲取resumeData,然後繼續下載。第三種情況是網路中斷後,無法從系統獲取resumeData時,去本地讀取自行建立的resumeData完成繼續下載。這裡在判斷APP是哪種情況,應該如何繼續下載有些複雜,也很容易出錯,我的方法可以看demo,僅供參考,大家可以根據自己的情況自行判斷。
注意 在開發中發現,當處於沒有網路的情況下,APP重啟後啟用繼續下載,會損壞本地的繼續下載資料,導致即使獲取了resumeData也無法完成繼續下載,因此在程式裡對網路環境進行了判斷,採用的是AFN,當沒有網路時不啟用,網路恢復後才可以啟用。
綜上關於下載器的下載部分基本講完了,一些細枝末節的我並沒有提及或者比較少提及,大家看demo應該可以看明白,都比較簡單,看不明白也沒關係,明白了難點和關鍵點,完全可以自行封裝一個下載器。自己動手豐衣足食嘛,看別人的程式碼總有那麼一點無奈和辛酸。

儲存下載資訊

檔案下載了,最主要的還是應用,我們需要的不僅僅是檔案本身,還有檔案的名稱、型別、下載連結、下載進度等資訊,便於我們展示給使用者。我這裡採用的是目前移動端最為先進的資料資料庫realm,一方面由於realm簡單易用,另一方面realm高效免費,這裡就不贅述realm的使用了,相信很多小夥伴已經接觸過了,還不太瞭解的可以參考官方的介紹,寫得很詳細,也有中文版本,已經沒有太大必要去閱讀第三方的解讀了,官方對各種問題的解答也很詳細,還有專門的論壇提供技術支援,傳送門在此,一看便知:realm官方文件

常規的資料庫操作無非是增、刪、改、查,這裡也不例外,4中需求都有,我這裡單獨宣告瞭一個ZYLSingleDownloaderModel類用於資料庫操作。
1、首先看增加,程式碼如下:

- (void)saveDownloaderInfoWithSingleDownloader:(ZYLSingleDownloader *)singleDownloader {
    //建立儲存物件
    ZYLSingleDownloaderModel *model = [[ZYLSingleDownloaderModel alloc] init];
    model.downloadUrl = singleDownloader.downloadUrl;
    model.fileType = singleDownloader.fileType;
    model.filename = singleDownloader.filename;
    model.downloaderProgress = singleDownloader.downloaderProgress;
    //儲存到資料庫
    RLMRealm *realm = [RLMRealm defaultRealm];
    [realm beginWriteTransaction];
    [realm addOrUpdateObject:model];
    [realm commitWriteTransaction];
}

注意 這裡在快取資訊的時候,注意不要將本地檔案的下載路徑快取到資料庫,這個是沒有意義的,因為每次啟動APP,為了保證安全,沙盒目錄的檔案路徑都是變化的,也就是說你上次快取的檔案路徑這次是不可用的,所以我們只需要快取檔名和檔案所在的資料夾,每次使用時實時獲取沙盒目錄的路徑即可。

2、刪除,程式碼如下:

- (void)deleteDownloaderInfoWithDownloderUrl:(NSString *)downloaderUrl {
    __block BOOL isD = NO;
    __block ZYLSingleDownloader *downloaderModel = nil;
    
    [self.singleDownloaderArray enumerateObjectsWithOptions:NSEnumerationReverse usingBlock:^(ZYLSingleDownloader *  _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
        if ([downloaderUrl isEqualToString:obj.downloadUrl]) {
            //已經存在這個下載了
            isD = YES;
            downloaderModel = obj;
            *stop = YES;
        }
    }];
    
    if (isD) {
        //存在
        
        //判斷下載器的下載狀態,做出相應的處理
        [downloaderModel judgeDownloaderStateToHandel];
        
        //判斷是否在資料庫中
        if (downloaderModel.isExistInRealm == YES) {
            //存在
            //1️⃣資料來源中刪除資料
            __weak __typeof(self)(weakSelf) = self;
            
            [self.singleDownloaderArray enumerateObjectsWithOptions:NSEnumerationReverse usingBlock:^(ZYLSingleDownloader*  _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
                if ([obj.downloadUrl isEqualToString:downloaderModel.downloadUrl]) {
                    [weakSelf.singleDownloaderArray removeObject:obj];
                    *stop = YES;
                }
            }];
            
            //2️⃣資料庫中刪除資料
            [self deleteDownloaderFromReaml:downloaderModel];
            //3️⃣從沙盒目錄中刪除檔案(下載的檔案、繼續下載資料、未下載完成的資料)
            //①下載的檔案
            NSString *localUrl = [self.directoryStr stringByAppendingPathComponent:[NSString stringWithFormat:@"%@.%@", [ZYLTool encodeFilename:downloaderModel.downloadUrl], downloaderModel.fileType]];
            if ([self.fileManager fileExistsAtPath:localUrl]) {
                //存在則刪除
                if (![self.fileManager removeItemAtPath:localUrl error:nil]) {
                    NSLog(@"刪除下載的檔案失敗");
                }
            }
            //②繼續下載的資料
            NSString *resumeDataStr = [self.resumeDirectoryStr stringByAppendingPathComponent:[@"resume_" stringByAppendingString:[ZYLTool encodeFilename:downloaderModel.downloadUrl]]];
            if ([self.fileManager fileExistsAtPath:resumeDataStr]) {
                NSData *tempData = [NSData dataWithContentsOfFile:resumeDataStr];
                NSString *XMLStr = [[NSString alloc] initWithData:tempData encoding:NSUTF8StringEncoding];
                NSRange tmpRange = [XMLStr rangeOfString:@"NSURLSessionResumeInfoTempFileName"];
                NSString *tmpStr = [XMLStr substringFromIndex:tmpRange.location + tmpRange.length];
                NSRange oneStringRange = [tmpStr rangeOfString:@"<string>"];
                NSRange twoStringRange = [tmpStr rangeOfString:@"</string>"];
                //記錄tmp檔名
                downloaderModel.tmpFilename = [tmpStr substringWithRange:NSMakeRange(oneStringRange.location + oneStringRange.length, twoStringRange.location - oneStringRange.location - oneStringRange.length)];
                
                //存在則刪除
                if (![self.fileManager removeItemAtPath:resumeDataStr error:nil]) {
                    NSLog(@"刪除繼續下載的資料失敗");
                } else {
                    //刪除成功
                    //③刪除未下載完成的資料
                    NSString *unDownloaderStr = [[[NSSearchPathForDirectoriesInDomains(NSDocumentDirectory,NSUserDomainMask,YES) objectAtIndex:0] stringByAppendingPathComponent:@"ZYLUnDownloads"] stringByAppendingPathComponent:downloaderModel.tmpFilename];
                    if ([self.fileManager fileExistsAtPath:unDownloaderStr]) {
                        if (![self.fileManager removeItemAtPath:unDownloaderStr error:nil]) {
                            NSLog(@"刪除未下載完成的資料失敗");
                        }
                    }
                }
            }
            
        } else {
            //不存在
            __weak __typeof(self)(weakSelf) = self;
            [self.singleDownloaderArray enumerateObjectsUsingBlock:^(ZYLSingleDownloader* _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
                if ([obj.downloadUrl isEqualToString:downloaderModel.downloadUrl]) {
                    [weakSelf.singleDownloaderArray removeObject:obj];
                    
                    if ([self.waitingDownlodArray containsObject:obj]) {
                        [self.waitingDownlodArray removeObject:obj];
                    }
                    
                    *stop = YES;
                }
            }];
            NSLog(@"資料庫中不存在這個下載,無法在資料庫中刪除");
        }
        
    } else {
        //不存在
        NSLog(@"不存在這個下載,無法刪除");
    }
    
}


- (void)deleteDownloaderFromReaml:(ZYLSingleDownloader *)downloader {
    //建立儲存物件
    for (ZYLSingleDownloaderModel *model in self.allModels) {
        if ([model.downloadUrl isEqualToString:downloader.downloadUrl]) {
            //刪除物件
            RLMRealm *realm = [RLMRealm defaultRealm];
            [realm beginWriteTransaction];
            [realm deleteObject:model];
            [realm commitWriteTransaction];
        }
    }
}

注意 小夥伴看我的刪除是可能會覺得怎麼如此複雜,因為這裡需要判斷要刪除的資料是否在資料中存在、判斷當前的下載狀態、在資料庫中刪除的同時也要在資料來源和本地刪除關於當前下載的一切資訊,還有終止當前的下載執行緒,保證騰出資源讓下一個下載器可以開啟。

3、修改資料,程式碼如下:

- (void)updateDownloaderInfoWithDownloderUrl:(NSString *)downloaderUrl withFilename:(NSString *)filename fileType:(NSString *)fileType {
    //判斷資料來源中是否有此資料
    __block BOOL isE = NO;
    __block ZYLSingleDownloader *downloaderModel = nil;
    [self.singleDownloaderArray enumerateObjectsUsingBlock:^(ZYLSingleDownloader *  _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
        if ([obj.downloadUrl isEqualToString:downloaderUrl]) {
            isE = YES;
            downloaderModel = obj;
            *stop = YES;
        }
    }];
    
    if (isE) {
        //存在
        //判斷在資料庫中是否存在
        if (downloaderModel.isExistInRealm) {
            //存在
            
            NSString *localUrl = [self.directoryStr stringByAppendingPathComponent:[NSString stringWithFormat:@"%@.%@", [ZYLTool encodeFilename:downloaderModel.downloadUrl], downloaderModel.fileType]];
            
            //更新資料來源
            if (filename != nil && ![filename isEqualToString:@""]) {
                downloaderModel.filename = filename;
            }
            if (fileType != nil && ![fileType isEqualToString:@""]) {
                downloaderModel.fileType = fileType;
            }
            //更新資料庫
            [self saveDownloaderInfoWithSingleDownloader:downloaderModel];
            
            //判斷是否下載完成
            if (downloaderModel.downloaderProgress >= 1.0) {
                //下載完成了
                //更新本地的下載好的檔案的檔名
                //判斷本地檔案是否存在
                if ([self.fileManager fileExistsAtPath:localUrl]){
                    //根據新的檔案資訊更新檔名
                    NSString *newLocalUrl = [self.directoryStr stringByAppendingPathComponent:[NSString stringWithFormat:@"%@.%@", [ZYLTool encodeFilename:downloaderModel.downloadUrl], downloaderModel.fileType]];
                    NSError *error = nil;
                    BOOL isS = [self.fileManager moveItemAtPath:localUrl toPath:newLocalUrl error:&error];
                    if (!isS) {
                        NSLog(@"修改本地下載的檔案資訊失敗:%@", error);
                    }
                    
                } else {
                    //沒有本地檔案
                    NSLog(@"沒有本地快取檔案,無法本地檔案");
                }
                
            } else {
                //沒有下載完成
                
            }
            
        } else {
            //不存在
            //僅僅更新資料來源
            if (filename != nil && ![filename isEqualToString:@""]) {
                downloaderModel.filename = filename;
            }
            if (fileType != nil && ![fileType isEqualToString:@""]) {
                downloaderModel.fileType = fileType;
            }
        }
        
    } else {
        //不存在
        NSLog(@"不存在這個下載器,無法更新資料");
    }
}

注意 修改資料和增加資料最終呼叫的realm程式碼是一樣的,realm裡有addOrUpdateObject方法,既可以新增也可以更新,可以避免很多bug,建議使用此方法。

4、檢視資料,程式碼如下

- (ZYLSingleDownloaderModel *)getDownloaderInfoWithDownloaderUrl:(NSString *)downloaderUrl {
    //首先判斷下載連線是否在資料陣列中
    ZYLSingleDownloaderModel *targetModel = [[ZYLSingleDownloaderModel alloc] init];
    __block BOOL isD = NO;
    __block ZYLSingleDownloader *downloaderModel = nil;
    [self.singleDownloaderArray enumerateObjectsUsingBlock:^(ZYLSingleDownloader *_Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
        if ([downloaderUrl isEqualToString:obj.downloadUrl]) {
            //已經存在這個下載了
            downloaderModel = obj;
            isD = YES;
        }
    }];
    
    if (isD) {
        //存在
        //判斷是否存在於資料庫中
        if (downloaderModel.isExistInRealm == YES) {
            //存在
            //判斷這個檔案是否下載完成
            if (downloaderModel.downloaderProgress >= 1.0) {
                //下載完成
                //判斷沙盒目錄是否存在此檔案
                
                NSString *localUrl = [self.directoryStr stringByAppendingPathComponent:[NSString stringWithFormat:@"%@.%@", [ZYLTool encodeFilename:downloaderModel.downloadUrl], downloaderModel.fileType]];
                if ([self.fileManager fileExistsAtPath:localUrl]) {
                    //存在
                    targetModel.localUrl = localUrl;
                    targetModel.downloadUrl = downloaderModel.downloadUrl;
                    targetModel.filename = downloaderModel.filename;
                    targetModel.fileType = downloaderModel.fileType;
                    targetModel.downloaderProgress = downloaderModel.downloaderProgress;
                    targetModel.isExistInRealm = YES;
                    
                    return targetModel;
                } else {
                    //不存在
                    NSLog(@"沙盒目錄沒有對應的檔案");
                    targetModel.localUrl = nil;
                    targetModel.downloadUrl = downloaderModel.downloadUrl;
                    targetModel.filename = downloaderModel.filename;
                    targetModel.fileType = downloaderModel.fileType;
                    targetModel.downloaderProgress = downloaderModel.downloaderProgress;
                    targetModel.isExistInRealm = YES;
                    
                    return targetModel;
                }
                
            } else {
                //未下載完成
                NSLog(@"這個下載還沒有完成");
                targetModel.localUrl = nil;
                targetModel.downloadUrl = downloaderModel.downloadUrl;
                targetModel.filename = downloaderModel.filename;
                targetModel.fileType = downloaderModel.fileType;
                targetModel.downloaderProgress = downloaderModel.downloaderProgress;
                targetModel.isExistInRealm = YES;
                
                return targetModel;
            }
        } else {
            //不存在
            NSLog(@"這個下載還沒有開始");
            targetModel.localUrl = nil;
            targetModel.downloadUrl = downloaderModel.downloadUrl;
            targetModel.filename = downloaderModel.filename;
            targetModel.fileType = downloaderModel.fileType;
            targetModel.downloaderProgress = downloaderModel.downloaderProgress;
            targetModel.isExistInRealm = NO;
            
            return targetModel;
        }
        
    } else {
        //不存在
        NSLog(@"不存在這個下載");
        
        return nil;
    }
}

上面就是關於資料庫的操作,比較簡單,小夥伴們看看就明白了。

專案檔案截圖:

9rFc5OQzeg40LMDAePQ.jpg

下載器剛寫好,還是會有一些問題,不過在大多數情況下是可以正常執行的。小夥伴若發現什麼問題,還請及時指正,感激不盡.
基於iOS 10、realm封裝的下載器

程式碼地址如下:
http://www.demodashi.com/demo/11653.html

注:本文著作權歸作者,由demo大師代發,拒絕轉載,轉載需要作者授權

相關文章