NSURLSession最全攻略

該文章屬于劉小壯原創,轉載請注明:劉小壯


NSURLSession

NSURLSessioniOS7中推出,NSURLSession的推出旨在替換之前的NSURLConnectionNSURLSession的使用相對于之前的NSURLConnection更簡單,而且不用處理Runloop相關的東西。

2015年RFC 7540標準發布了http 2.0版本,http 2.0版本中包含很多新的特性,在傳輸速度上也有很明顯的提升。NSURLSessioniOS9.0開始,對http 2.0提供了支持。

NSURLSession由三部分構成:

  • NSURLSession:請求會話對象,可以用系統提供的單例對象,也可以自己創建。
  • NSURLSessionConfiguration:對session會話進行配置,一般都采用default
  • NSURLSessionTask:負責執行具體請求的task,由session創建。

NSURLSession有三種方式創建:

sharedSession

系統維護的一個單例對象,可以和其他使用這個sessiontask共享連接和請求信息。

sessionWithConfiguration:

在NSURLSession初始化時傳入一個NSURLSessionConfiguration,這樣可以自定義請求頭、cookie等信息。

sessionWithConfiguration:delegate:delegateQueue:

如果想更好的控制請求過程以及回調線程,需要上面的方法進行初始化操作,并傳入delegate來設置回調對象和回調的線程。

通過NSURLSession發起一個網絡請求也比較簡單。

  1. 創建一個NSURLSessionConfiguration配置請求。
  2. 通過Configuration創建NSURLSession對象。
  3. 通過session對象發起網絡請求,并獲取task對象。
  4. 調用[task resume]方法發起網絡請求。
NSURLSessionConfiguration *config = [NSURLSessionConfiguration defaultSessionConfiguration];
NSURLSession *session = [NSURLSession sessionWithConfiguration:config
                                                      delegate:self
                                                 delegateQueue:[NSOperationQueue mainQueue]];
NSURLSessionDataTask *task = [session dataTaskWithURL:[NSURL URLWithString:@"http://www.baidu.com"]];
[task resume];

NSURLSessionTask

通過NSURLSession發起的每個請求,都會被封裝為一個NSURLSessionTask任務,但一般不會直接是NSURLSessionTask類,而是基于不同任務類型,被封裝為其對應的子類。

  • NSURLSessionDataTask:處理普通的GetPost請求。
  • NSURLSessionUploadTask:處理上傳請求,可以傳入對應的上傳文件或路徑。
  • NSURLSessionDownloadTask:處理下載地址,提供斷點續傳功能的cancel方法。

主要方法都定義在父類NSURLSessionTask中,下面是一些關鍵方法或屬性。

currentRequest
當前正在執行的任務,一般和originalRequest是一樣的,除非發生重定向才會有所區別。
originalRequest
主要用于重定向操作,用來記錄重定向前的請求。
taskIdentifier
當前session下,task的唯一標示,多個session之間可能存在相同的標識。
priority
task中可以設置優先級,但這個屬性并不代表請求的優先級,而是一個標示。官方已經說明,NSURLSession并沒有提供API可以改變請求的優先級。
state
當前任務的狀態,可以通過KVO的方式監聽狀態的改變。
- resume
開始或繼續請求,創建后的task默認是掛起的,需要手動調用resume才可以開始請求。
- suspend
掛起當前請求。主要是下載請求用的多一些,普通請求掛起后都會重新開始請求。下載請求掛起后,只要不超過NSURLRequest設置的timeout時間,調用resume就是繼續請求。
- cancel
取消當前請求。任務會被標記為取消,并在未來某個時間調用URLSession:task:didCompleteWithError:方法。

NSURLSession提供有普通創建task的方式,創建后可以通過重寫代理方法,獲取對應的回調和參數。這種方式對于請求過程比較好控制。

- (NSURLSessionDataTask *)dataTaskWithRequest:(NSURLRequest *)request;
- (NSURLSessionUploadTask *)uploadTaskWithRequest:(NSURLRequest *)request fromFile:(NSURL *)fileURL;
- (NSURLSessionDownloadTask *)downloadTaskWithRequest:(NSURLRequest *)request;

除此之外,NSURLSession也提供了block的方式創建task,創建方式簡單如AFN,直接傳入URLNSURLRequest,即可直接在block中接收返回數據。和普通創建方式一樣,block的創建方式創建后默認也是suspend的狀態,需要調用resume開始任務。

completionHandlerdelegate是互斥的,completionHandler的優先級大于delegate。相對于普通創建方法,block方式更偏向于面向結果的創建,可以直接在completionHandler中獲取返回結果,但不能控制請求過程。

- (NSURLSessionDataTask *)dataTaskWithURL:(NSURL *)url completionHandler:(void (^)(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error))completionHandler;
- (NSURLSessionUploadTask *)uploadTaskWithRequest:(NSURLRequest *)request fromData:(nullable NSData *)bodyData completionHandler:(void (^)(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error))completionHandler;
- (NSURLSessionDownloadTask *)downloadTaskWithURL:(NSURL *)url completionHandler:(void (^)(NSURL * _Nullable location, NSURLResponse * _Nullable response, NSError * _Nullable error))completionHandler;

可以通過下面的兩個方法,獲取當前session對應的所有task,方法區別在于回調的參數不同。以getTasksWithCompletionHandler為例,在AFN中的應用是用來獲取當前sessiontask,并將AFURLSessionManagerTaskDelegate的回調都置為nil,以防止崩潰。

- (void)getTasksWithCompletionHandler:(void (^)(NSArray<NSURLSessionDataTask *> *dataTasks, NSArray<NSURLSessionUploadTask *> *uploadTasks, NSArray<NSURLSessionDownloadTask *> *downloadTasks))completionHandler;

- (void)getAllTasksWithCompletionHandler:(void (^)(NSArray<__kindof NSURLSessionTask *> *tasks))completionHandler);

delegateQueue

在初始化NSURLSession時可以指定線程,如果不指定線程,則completionHandlerdelegate的回調方法,都會在子線程中執行。

如果初始化NSURLSession時指定了delegateQueue,則回調會在指定的隊列中執行,如果指定的是mainQueue,則回調在主線程中執行,這樣就避免了切換線程的問題。

[NSURLSession sessionWithConfiguration:config delegate:self delegateQueue:nil];

delegate

對于NSURLSession的代理方法這里就不詳細列舉了,方法命名遵循蘋果一貫見名知意的原則,用起來很簡單。這里介紹一下NSURLSession的代理繼承結構。

代理繼承關系

NSURLSession中定義了一系列代理,并遵循上面的繼承關系。根據繼承關系和代理方法的聲明,如果執行某項任務,只需要遵守其中的某個代理即可。

例如執行上傳或普通Post請求,則遵守NSURLSessionDataDelegate,執行下載任務則遵循NSURLSessionDownloadDelegate,父級代理定義的都是公共方法。

請求重定向

HTTP協議中定義了例如301等重定向狀態碼,通過下面的代理方法,可以處理重定向任務。發生重定向時可以根據response創建一個新的request,也可以直接用系統生成的request,并在completionHandler回調中傳入,如果想終止這次重定向,在completionHandler傳入nil即可。

- (void)URLSession:(NSURLSession *)session
              task:(NSURLSessionTask *)task
willPerformHTTPRedirection:(NSHTTPURLResponse *)response
        newRequest:(NSURLRequest *)request
 completionHandler:(void (^)(NSURLRequest *))completionHandler
{
    NSURLRequest *redirectRequest = request;

    if (self.taskWillPerformHTTPRedirection) {
        redirectRequest = self.taskWillPerformHTTPRedirection(session, task, response, request);
    }

    if (completionHandler) {
        completionHandler(redirectRequest);
    }
}

NSURLSessionConfiguration


創建方式

NSURLSessionConfiguration負責對NSURLSession初始化時進行配置,通過NSURLSessionConfiguration可以設置請求的Cookie、密鑰、緩存、請求頭等參數,將網絡請求的一些配置參數從NSURLSession中分離出來。

NSURLSessionConfiguration *config = [NSURLSessionConfiguration defaultSessionConfiguration];
NSURLSession *session = [NSURLSession sessionWithConfiguration:config
                                                      delegate:self
                                                 delegateQueue:[NSOperationQueue mainQueue]];

NSURLSessionConfiguration提供三種初始化方法,下面是請求的方法的一些解釋。

@property (class, readonly, strong) NSURLSessionConfiguration *defaultSessionConfiguration;

NSURLSessionConfiguration提供defaultSessionConfiguration的方式創建,但這并不是單例方法,而是類方法,創建的是不同對象。通過這種方式創建的configuration,并不會共享cookiecache、密鑰等,而是不同configuration都需要單獨設置。

這塊網上很多人理解都是錯的,并沒有真的在項目里使用或者沒有留意過,如和其他人有出入,以我為準。

@property (class, readonly, strong) NSURLSessionConfiguration *ephemeralSessionConfiguration;

創建臨時的configuration,通過這種方式創建的對象,和普通的對象主要區別在于URLCacheURLCredentialStorageHTTPCookieStorage上面。同樣的,Ephemeral也不是單例方法,而只是類方法。

URLCredentialStorage
Ephemeral <__NSCFMemoryURLCredentialStorage: 0x600001bc8320>

HTTPCookieStorage
Ephemeral <NSHTTPCookieStorage cookies count:0>

如果對Ephemeral方式創建的config進行打印的話,可以看到變量類型明顯區別于其他類型,并且在打印信息前面會有Ephemeral的標示。通過Ephemeral的方式創建的config,不會產生持久化信息,可以很好保護請求的數據安全性。

+ (NSURLSessionConfiguration *)backgroundSessionConfigurationWithIdentifier:(NSString *)identifier;

identifier方式一般用于恢復之前的任務,主要用于下載。如果一個下載任務正在進行中,程序被kill調,可以在程序退出之前保存identifier。下次進入程序后通過identifier恢復之前的任務,系統會將NSURLSessionNSURLSessionConfiguration和之前的下載任務進行關聯,并繼續之前的任務。

timeout

timeoutIntervalForRequest

設置session請求間的超時時間,這個超時時間并不是請求從開始到結束的時間,而是兩個數據包之間的時間間隔。當任意請求返回后這個值將會被重置,如果在超時時間內未返回則超時。單位為秒,默認為60秒。

timeoutIntervalForResource

資源超時時間,一般用于上傳或下載任務,在上傳或下載任務開始后計時,如果到達時間任務未結束,則刪除資源文件。單位為秒,默認時間是七天。

資源共享

如果是相同的NSURLSessionConfiguration對象,會共享請求頭、緩存、cookieCredential,通過Configuration創建的NSURLSession,也會擁有對應的請求信息。

@property (nullable, copy) NSDictionary *HTTPAdditionalHeaders;

公共請求頭,默認是空的,設置后所有經Confuguration配置的NSURLSession,請求頭都會帶有設置的信息。

@property (nullable, retain) NSHTTPCookieStorage *HTTPCookieStorage;

HTTP請求的Cookie管理器。如果是通過sharedSessionbackgroundConfiguration創建的NSURLSession,默認使用sharedHTTPCookieStorageCookie數據。如果不想使用Cookie,則直接設置為nil即可,也可以手動設置為自己的CookieStorage

@property (nullable, retain) NSURLCredentialStorage *URLCredentialStorage;

證書管理器。如果是通過sharedSessionbackgroundConfiguration創建的NSURLSession,默認使用sharedCredentialStorage的證書。如果不想使用證書,可以直接設置為nil,也可以自己創建證書管理器。

@property (nullable, retain) NSURLCache *URLCache;

請求緩存,如果不手動設置的話為nil,對于NSURLCache這個類我沒有研究過,不太了解。

緩存處理

NSURLRequest中可以設置cachePolicy請求緩存策略,這里不對具體值做詳細描述,默認值為NSURLRequestUseProtocolCachePolicy使用緩存。

NSURLSessionConfiguration可以設置處理緩存的對象,我們可以手動設置自定義的緩存對象,如果不設置的話,默認使用系統的sharedURLCache單例緩存對象。經過configuration創建的NSURLSession發出的請求,NSURLRequest都會使用這個NSURLCache來處理緩存。

@property (nullable, retain) NSURLCache *URLCache;

NSURLCache提供了MemoryDisk的緩存,在創建時需要為其分別指定MemoryDisk的大小,以及存儲的文件位置。使用NSURLCache不用考慮磁盤空間不夠,或手動管理內存空間的問題,如果發生內存警告系統會自動清理內存空間。但是NSURLCache提供的功能非常有限,項目中一般很少直接使用它來處理緩存數據,還是用數據庫比較多。

[[NSURLCache alloc] initWithMemoryCapacity:30 * 1024 * 1024 
                              diskCapacity:30 * 1024 * 1024 
                              directoryURL:[NSURL URLWithString:filePath]];

使用NSURLCache還有一個好處,就是可以由服務端來設置資源過期時間,在請求服務端后,服務端會返回Cache-Control來說明文件的過期時間。NSURLCache會根據NSURLResponse來自動完成過期時間的設置。

最大連接數

限制NSURLSession的最大連接數,通過此方法創建的NSURLSession和服務端的最大連接數量不會超出這里設置的數量。蘋果為我們設置的iOS端默認為4,Mac端默認為6。

@property NSInteger HTTPMaximumConnectionsPerHost;

連接復用


HTTP是基于傳輸層協議TCP的,通過TCP發送網絡請求都需要先進行三次握手,建立網絡請求后再發送數據,請求結束時再經歷四次揮手。HTTP1.0開始支持keep-alivekeep-alive可以保持已經建立的鏈接,如果是相同的域名,在請求連接建立后,后面的請求不會立刻斷開,而是復用現有的連接。從HTTP1.1開始默認開啟keep-alive

請求是在請求頭中設置下面的參數,服務器如果支持keep-alive的話,響應客戶端請求時,也會在響應頭中加上相同的字段。

Connection: Keep-Alive

如果想斷開keep-alive,可以在請求頭中加上下面的字段,但一般不推薦這么做。

Connection: Close

如果通過NSURLSession來進行網絡請求的話,需要使用同一個NSURLSession對象,如果創建新的session對象則不能復用之前的鏈接。keep-alive可以保持請求的連接,蘋果允許在iOS上最大保持有4個連接,Mac則是6個連接。

pipeline

pipeline

HTTP1.1中,基于keep-alive,還可以將請求進行管線化。和相同后端服務,TCP層建立的鏈接,一般都需要前一個請求返回后,后面的請求再發出。但pipeline就可以不依賴之前請求的響應,而發出后面的請求。

pipeline依賴客戶端和服務器都有實現,服務端收到客戶端的請求后,要按照先進先出的順序進行任務處理和響應。pipeline依然存在之前非pipeline的問題,就是前面的請求如果出現問題,會阻塞當前連接影響后面的請求。

pipeline對于請求大文件并沒有提升作用,只是對于普通請求速度有提升。在NSURLSessionConfiguration中可以設置HTTPShouldUsePipeliningYES,開啟管線化,此屬性默認為NO

NSURLSessionTaskMetrics


在日常開發過程中,經常遇到頁面加載太慢的問題,這很大一部分原因都是因為網絡導致的。所以,查找網絡耗時的原因并解決,就是一個很重要的任務了。蘋果對于網絡檢查提供了NSURLSessionTaskMetrics類來進行檢查,NSURLSessionTaskMetrics是對應NSURLSessionTaskDelegate的,每個task結束時都會回調下面的方法,并且可以獲得一個metrics對象。

- (void)URLSession:(NSURLSession *)session 
              task:(NSURLSessionTask *)task 
didFinishCollectingMetrics:(NSURLSessionTaskMetrics *)metrics;

NSURLSessionTaskMetrics可以很好的幫助我們分析網絡請求的過程,以找到耗時原因。除了這個類之外,NSURLSessionTaskTransactionMetrics類中承載了更詳細的數據。

@property (copy, readonly) NSArray<NSURLSessionTaskTransactionMetrics *> *transactionMetrics;

transactionMetrics數組中每一個元素都對應著當前task的一個請求,一般數組中只會有一個元素,如果發生重定向等情況,可能會存在多個元素。

@property (copy, readonly) NSDateInterval *taskInterval;

taskInterval記錄了當前task從開始請求到最后完成的總耗時,NSDateInterval中包含了startDateendDateduration耗時時間。

@property (assign, readonly) NSUInteger redirectCount;

redirectCount記錄了重定向次數,在進行下載請求時一般都會進行重定向,來保證下載任務能由后端最合適的節點來處理。

NSURLSessionTaskTransactionMetrics

NSURLSessionTaskTransactionMetrics中的屬性都是用來做統計的,功能都是記錄某個值,并沒有邏輯上的意義。所以這里就對一些主要的屬性做一下解釋,基本涵蓋了大部分屬性,其他就不管了。

這張圖是我從網上扒下來的,標示了NSURLSessionTaskTransactionMetrics的屬性在請求過程中處于什么位置。

請求耗時細節
// 請求對象
@property (copy, readonly) NSURLRequest *request;
// 響應對象,請求失敗可能會為nil
@property (nullable, copy, readonly) NSURLResponse *response;
// 請求開始時間
@property (nullable, copy, readonly) NSDate *fetchStartDate;
// DNS解析開始時間
@property (nullable, copy, readonly) NSDate *domainLookupStartDate;
// DNS解析結束時間,如果解析失敗可能為nil
@property (nullable, copy, readonly) NSDate *domainLookupEndDate;
// 開始建立TCP連接時間
@property (nullable, copy, readonly) NSDate *connectStartDate;
// 結束建立TCP連接時間
@property (nullable, copy, readonly) NSDate *connectEndDate;
// 開始TLS握手時間
@property (nullable, copy, readonly) NSDate *secureConnectionStartDate;
// 結束TLS握手時間
@property (nullable, copy, readonly) NSDate *secureConnectionEndDate;
// 開始傳輸請求數據時間
@property (nullable, copy, readonly) NSDate *requestStartDate;
// 結束傳輸請求數據時間
@property (nullable, copy, readonly) NSDate *requestEndDate;
// 接收到服務端響應數據時間
@property (nullable, copy, readonly) NSDate *responseStartDate;
// 服務端響應數據傳輸完成時間
@property (nullable, copy, readonly) NSDate *responseEndDate;
// 網絡協議,例如http/1.1
@property (nullable, copy, readonly) NSString *networkProtocolName;
// 請求是否使用代理
@property (assign, readonly, getter=isProxyConnection) BOOL proxyConnection;
// 是否復用已有連接
@property (assign, readonly, getter=isReusedConnection) BOOL reusedConnection;
// 資源標識符,表示請求是從Cache、Push、Network哪種類型加載的
@property (assign, readonly) NSURLSessionTaskMetricsResourceFetchType resourceFetchType;
// 本地IP
@property (nullable, copy, readonly) NSString *localAddress;
// 本地端口號
@property (nullable, copy, readonly) NSNumber *localPort;
// 遠端IP
@property (nullable, copy, readonly) NSString *remoteAddress;
// 遠端端口號
@property (nullable, copy, readonly) NSNumber *remotePort;
// TLS協議版本,如果是http則是0x0000
@property (nullable, copy, readonly) NSNumber *negotiatedTLSProtocolVersion;
// 是否使用蜂窩數據
@property (readonly, getter=isCellular) BOOL cellular;

下面是我發起一個http的下載請求,統計得到的數據。設備是Xcode模擬器,網絡環境是WiFi

(Request) <NSURLRequest: 0x600000c80380> { URL: http://vfx.mtime.cn/Video/2017/03/31/mp4/170331093811717750.mp4 }
(Response) <NSHTTPURLResponse: 0x600000ed9420> { URL: http://vfx.mtime.cn/Video/2017/03/31/mp4/170331093811717750.mp4 } { Status Code: 200, Headers {
    "Accept-Ranges" =     (
        bytes
    );
    Age =     (
        1063663
    );
    "Ali-Swift-Global-Savetime" =     (
        1575358696
    );
    Connection =     (
        "keep-alive"
    );
    "Content-Length" =     (
        20472584
    );
    "Content-Md5" =     (
        "YM+JxIH9oLH6l1+jHN9pmQ=="
    );
    "Content-Type" =     (
        "video/mp4"
    );
    Date =     (
        "Tue, 03 Dec 2019 07:38:16 GMT"
    );
    EagleId =     (
        dbee142415764223598843838e
    );
    Etag =     (
        "\"60CF89C481FDA0B1FA975FA31CDF6999\""
    );
    "Last-Modified" =     (
        "Fri, 31 Mar 2017 01:41:36 GMT"
    );
    Server =     (
        Tengine
    );
    "Timing-Allow-Origin" =     (
        "*"
    );
    Via =     (
        "cache39.l2et2[0,200-0,H], cache6.l2et2[3,0], cache16.cn548[0,200-0,H], cache16.cn548[1,0]"
    );
    "X-Cache" =     (
        "HIT TCP_MEM_HIT dirn:-2:-2"
    );
    "X-M-Log" =     (
        "QNM:xs451;QNM3:71"
    );
    "X-M-Reqid" =     (
        "m0AAAP__UChjzNwV"
    );
    "X-Oss-Hash-Crc64ecma" =     (
        12355898484621380721
    );
    "X-Oss-Object-Type" =     (
        Normal
    );
    "X-Oss-Request-Id" =     (
        5DE20106F3150D38305CE159
    );
    "X-Oss-Server-Time" =     (
        130
    );
    "X-Oss-Storage-Class" =     (
        Standard
    );
    "X-Qnm-Cache" =     (
        Hit
    );
    "X-Swift-CacheTime" =     (
        2592000
    );
    "X-Swift-SaveTime" =     (
        "Sun, 15 Dec 2019 15:05:37 GMT"
    );
} }
(Fetch Start) 2019-12-15 15:05:59 +0000
(Domain Lookup Start) 2019-12-15 15:05:59 +0000
(Domain Lookup End) 2019-12-15 15:05:59 +0000
(Connect Start) 2019-12-15 15:05:59 +0000
(Secure Connection Start) (null)
(Secure Connection End) (null)
(Connect End) 2019-12-15 15:05:59 +0000
(Request Start) 2019-12-15 15:05:59 +0000
(Request End) 2019-12-15 15:05:59 +0000
(Response Start) 2019-12-15 15:05:59 +0000
(Response End) 2019-12-15 15:06:04 +0000
(Protocol Name) http/1.1
(Proxy Connection) NO
(Reused Connection) NO
(Fetch Type) Network Load
(Request Header Bytes) 235
(Request Body Transfer Bytes) 0
(Request Body Bytes) 0
(Response Header Bytes) 866
(Response Body Transfer Bytes) 20472584
(Response Body Bytes) 20472584
(Local Address) 192.168.1.105
(Local Port) 63379
(Remote Address) 219.238.20.101
(Remote Port) 80
(TLS Protocol Version) 0x0000
(TLS Cipher Suite) 0x0000
(Cellular) NO
(Expensive) NO
(Constrained) NO
(Multipath) NO

FAQ


NSURLSession的delegate為什么是強引用?

在初始化NSURLSession對象并設置代理后,代理對象將會被強引用。根據蘋果官方的注釋來看,這個強持有并不會一直存在,而是在調用URLSession:didBecomeInvalidWithError:方法后,會將delegate釋放。

通過調用NSURLSessioninvalidateAndCancelfinishTasksAndInvalidate方法,即可將強引用斷開并執行didBecomeInvalidWithError:代理方法,執行完成后session就會無效不可以使用。也就是只有在session無效時,才可以解除強引用的關系。

有時候為了保證連接復用等問題,一般不會輕易將session會話invalid,所以最好不要直接使用NSURLSession,而是要對其進行一次二次封裝,使用AFN3.0的原因之一也在于此。

NSURLSession文件上傳


表單上傳

客戶端有時候需要給服務端上傳大文件,進行大文件肯定不能全都加載到內存里,一口氣都傳給服務器。進行大文件上傳時,一般都會對需要上傳的文件進行分片,分片后逐個文件進行上傳。需要注意的是,分片上傳和斷點續傳并不是同一個概念,上傳并不支持斷點續傳。

進行分片上傳時,需要對本地文件進行讀取,我們使用NSFileHandle來進行文件讀取。NSFileHandle提供了一個偏移量的功能,我們可以將handle的當前讀取位置seek到上次讀取的位置,并設置本次讀取長度,讀取的文件就是我們指定文件的字節。

- (NSData *)readNextBuffer {
    if (self.maxSegment <= self.currentIndex) {
        return nil;
    }
    
    if(!self.fileHandler){
        NSString *filePath = [self uploadFile];
        NSFileHandle *fileHandle = [NSFileHandle fileHandleForReadingAtPath:filePath];
        self.fileHandler = fileHandle;
    }
    [self.fileHandler seekToFileOffset:(self.currentIndex) * self.segmentSize];
    NSData *data = [self.fileHandler readDataOfLength:self.segmentSize];
    return data;
}

上傳文件現在主流的方式,都是采取表單上傳的方式,也就是multipart/from-dataAFNetworking對表單上傳也有很有的支持。表單上傳需要遵循下面的格式進行上傳,boundary是一個16進制字符串,可以是任何且唯一的。boundary的功能用來進行字段分割,區分開不同的參數部分。

multipart/from-data規范定義在rfc2388,詳細字段可以看一下規范。

--boundary
 Content-Disposition: form-data; name="參數名"
 參數值
 --boundary
 Content-Disposition:form-data;name=”表單控件名”;filename=”上傳文件名”
 Content-Type:mime type
 要上傳文件二進制數據
 --boundary--

拼接上傳文件基本上可以分為下面三部分,上傳參數、上傳信息、上傳文件。并且通過UTF-8格式進行編碼,服務端也采用相同的解碼方式,則可以獲得上傳文件和信息。需要注意的是,換行符數量是固定的,這都是固定的協議格式,不要多或者少,會導致服務端解析失敗。

- (NSData *)writeMultipartFormData:(NSData *)data 
                        parameters:(NSDictionary *)parameters {
    if (data.length == 0) {
        return nil;
    }
    
    NSMutableData *formData = [NSMutableData data];
    NSData *lineData = [@"\r\n" dataUsingEncoding:NSUTF8StringEncoding];
    NSData *boundary = [kBoundary dataUsingEncoding:NSUTF8StringEncoding];
    
    // 拼接上傳參數
    [parameters enumerateKeysAndObjectsUsingBlock:^(id key, id obj, BOOL *stop) {
        [formData appendData:boundary];
        [formData appendData:lineData];
        NSString *thisFieldString = [NSString stringWithFormat:@"Content-Disposition: form-data; name=\"%@\"\r\n\r\n%@", key, obj];
        [formData appendData:[thisFieldString dataUsingEncoding:NSUTF8StringEncoding]];
        [formData appendData:lineData];
    }];
    
    // 拼接上傳信息
    [formData appendData:boundary];
    [formData appendData:lineData];
    NSString *thisFieldString = [NSString stringWithFormat:@"Content-Disposition: form-data; name=\"%@\"; filename=\"%@\"\r\nContent-Type: %@", @"name", @"filename", @"mimetype"];
    [formData appendData:[thisFieldString dataUsingEncoding:NSUTF8StringEncoding]];
    [formData appendData:lineData];
    [formData appendData:lineData];
    
    // 拼接上傳文件
    [formData appendData:data];
    [formData appendData:lineData];
    [formData appendData: [[NSString stringWithFormat:@"--%@--\r\n", kBoundary] dataUsingEncoding:NSUTF8StringEncoding]];
    
    return formData;
}

除此之外,表單提交還需要設置請求頭的Content-TypeContent-Length,否則會導致請求失敗。其中Content-Length并不是強制要求的,要看后端的具體支持情況。

設置請求頭時,一定要加上boundary,這個boundary和拼接上傳文件的boundary需要是同一個。服務端從請求頭拿到boundary,來解析上傳文件。

NSString *headerField = [NSString stringWithFormat:@"multipart/form-data; charset=utf-8; boundary=%@", kBoundary];
[request setValue:headerField forHTTPHeaderField:@"Content-Type"];

NSUInteger size = [[[NSFileManager defaultManager] attributesOfItemAtPath:uploadPath error:nil] fileSize];
headerField = [NSString stringWithFormat:@"%lu", size];
[request setValue:headerField forHTTPHeaderField:@"Content-Length"];

隨后我們通過下面的代碼創建NSURLSessionUploadTask,并調用resume發起請求,實現對應的代理回調即可。

// 發起網絡請求
NSURLSessionUploadTask *uploadTask = [self.backgroundSession uploadTaskWithRequest:request fromData:fromData];
[uploadTask resume];
    
// 請求完成后調用,無論成功還是失敗
- (void)URLSession:(NSURLSession *)session
              task:(NSURLSessionTask *)task
didCompleteWithError:(NSError *)error {
    
}

// 更新上傳進度,會回調多次
- (void)URLSession:(NSURLSession *)session
              task:(NSURLSessionTask *)task
   didSendBodyData:(int64_t)bytesSent
    totalBytesSent:(int64_t)totalBytesSent
totalBytesExpectedToSend:(int64_t)totalBytesExpectedToSend {
    
}

// 數據接收完成回調
- (void)URLSession:(NSURLSession *)session
          dataTask:(NSURLSessionDataTask *)dataTask
    didReceiveData:(NSData *)data {
    
}

// 處理后臺上傳任務,當前session的上傳任務結束后會回調此方法。
- (void)URLSessionDidFinishEventsForBackgroundURLSession:(NSURLSession *)session {
    
}

但是,如果你認為這就完成一個上傳功能了,too young too simple~

后臺上傳

如果通過fromData的方式進行上傳,并不支持后臺上傳。如果想實現后臺上傳,需要通過fromFile的方式上傳文件。不止如此,fromData還有其他坑。

- (NSURLSessionUploadTask *)uploadTaskWithRequest:(NSURLRequest *)request fromData:(NSData *)bodyData;
- (NSURLSessionUploadTask *)uploadTaskWithRequest:(NSURLRequest *)request fromFile:(NSURL *)fileURL;

內存占用

我們發現通過fromData:的方式上傳文件,內存漲上去之后一直不能降下來,無論是直接使用NSURLSession還是AFNetworking,都是這樣的。小文件還好,不是很明顯,如果是幾百MB的大文件很明顯就會有一個內存峰值,而且漲上去就不會降下來。WTF?

上傳有兩種方式上傳,如果我們把fromData:的上傳改為fromFile:,就可以解決內存不下降的問題。所以,我們可以把fromData:的上傳方式,理解為UIImageimageNamed的方法,上傳后NSData文件會保存在內存中,不會被回收。而fromFile:的方式是從本地加載文件,并且上傳完成后可以被回收。而且如果想支持后臺上傳,就必須用fromFile:的方式進行上傳。

OK,那找到問題我們就開干,改變之前的上傳邏輯,改為fromFile:的方式上傳。

// 將分片寫入到本地
NSString *filePath = [NSString stringWithFormat:@"%@/%ld", [self segmentDocumentPath], currentIndex];
BOOL write = [formData writeToFile:filePath atomically:YES];

// 創建分片文件夾
- (NSString *)segmentDocumentPath {
    NSString *documentName = [fileName md5String];
    NSString *filePath = [[SVPUploadCompressor compressorPath] stringByAppendingPathComponent:documentName];
    BOOL needCreateDirectory = YES;
    BOOL isDirectory = NO;
    if ([[NSFileManager defaultManager] fileExistsAtPath:filePath isDirectory:&isDirectory]) {
        if (isDirectory) {
            needCreateDirectory = NO;
        } else {
            [[NSFileManager defaultManager] removeItemAtPath:filePath error:nil];
        }
    }
    
    if (needCreateDirectory) {
        [[NSFileManager defaultManager] createDirectoryAtPath:filePath
                                  withIntermediateDirectories:YES
                                                   attributes:nil
                                                        error:nil];
    }
    return filePath;
}

因為要通過fromFile:方法傳一個本地分片的路徑進去,所以需要預先對文件進行分片,并保存在本地。在分片的同時,還需要拼接boundary信息。

所以我們在上傳任務開始前,先對文件進行分片并拼接信息,然后將分片文件寫入到本地。為了方便管理,我們基于具有唯一性的文件名進行MD5來創建分片文件夾,分片文件命名通過下標來命名,并寫入到本地。文件上傳完成后,直接刪除整個文件夾即可。當然,這些文件操作都是在異步線程中完成的,防止影響UI線程。

內存占用

我們用一個400MB的視頻測試上傳,我們可以從上圖看出,圈紅部分是我們上傳文件的時間。將上傳方式改為fromFile:后,上傳文件的峰值最高也就是在10MB左右徘徊,這對于iPhone6這樣的低內存老年機來說,是相當友好的,不會導致低端設備崩潰或者卡頓。

動態分片

用戶在上傳時網絡環境會有很多情況,WiFi、4G、弱網等很多情況。如果上傳分片太大可能會導致失敗率上升,分片文件太小會導致網絡請求太多,產生太多無用的boundaryheader、數據鏈路等資源的浪費。

為了解決這個問題,我們采取的是動態分片大小的策略。根據特定的計算策略,預先使用第一個分片的上傳速度當做測速分片,測速分片的大小是固定的。根據測速的結果,對其他分片大小進行動態分片,這樣可以保證分片大小可以最大限度的利用當前網速。

if ([Reachability reachableViaWiFi]) {
    self.segmentSize = 500 * 1024;
} else if ([Reachability reachableViaWWAN]) {
    self.segmentSize = 300 * 1024;
}

當然,如果覺得這種分片方式太過復雜,也可以采取一種閹割版的動態分片策略。即根據網絡情況做判斷,如果是WiFi就固定某個分片大小,如果是流量就固定某個分片大小。然而這種策略并不穩定,因為現在很多手機的網速比WiFi還快,我們也不能保證WiFi都是百兆光纖。

并行上傳

上傳的所有任務如果使用的都是同一個NSURLSession的話,是可以保持連接的,省去建立和斷開連接的消耗。在iOS平臺上,NSURLSession支持對一個Host保持4個連接,所以,如果我們采取并行上傳,可以更好的利用當前的網絡。

并行上傳的數量在iOS平臺上不要超過4個,最大連接數是可以通過NSURLSessionConfiguration設置的,而且數量最好不要寫死。同樣的,應該基于當前網絡環境,在上傳任務開始的時候就計算好最大連接數,并設置給Configuration

經過我們的線上用戶數據分析,在線上環境使用并行任務的方式上傳,上傳速度相較于串行上傳提升四倍左右。計算方式是每秒文件上傳的大小。

iPhone串行上傳:715 kb/s
iPhone并行上傳:2909 kb/s

隊列管理

分片上傳過程中可能會因為網速等原因,導致上傳失敗。失敗的任務應該由單獨的隊列進行管理,并且在合適的時機進行失敗重傳。

例如對一個500MB的文件進行分片,每片是300KB,就會產生1700多個分片文件,每一個分片文件就對應一個上傳任務。如果在進行上傳時,一口氣創建1700多個uploadTask,盡管NSURLSession是可以承受的,也不會造成一個很大的內存峰值。但是我覺得這樣并不太好,實際上并不會同時有這么多請求發出。

/// 已上傳成功片段數組
@property (nonatomic, strong) NSMutableArray *successSegments;
/// 待上傳隊列的數組
@property (nonatomic, strong) NSMutableArray *uploadSegments;

所以在創建上傳任務時,我設置了一個最大任務數,就是同時向NSURLSession發起的請求不會超過這個數量。需要注意的是,這個最大任務數是我創建uploadTask的任務數,并不是最大并發數,最大并發數由NSURLSession來控制,我不做干預。

我將待上傳任務都放在uploadSegments中,上傳成功后我會從待上傳任務數組中取出一條或多條,并保證同時進行的任務始終不超過最大任務數。失敗的任務理論上來說也是需要等待上傳的,所以我把失敗任務也放在uploadSegments中,插入到隊列最下面,這樣就保證了待上傳任務完成后,繼續重試失敗任務。

成功的任務我放在successSegments中,并且始終保持和uploadSegments沒有交集。兩個隊列中保存的并不是uploadTask,而是分片的索引,這也就是為什么我給分片命名的時候用索引當做名字的原因。當successSegments等于分片數量時,就表示所有任務上傳完成。

NSURLSession文件下載


NSURLSession是在單獨的進程中運行,所以通過此類發起的網絡請求,是獨立于應用程序運行的,即使App掛起、kill也不會停止請求。在下載任務時會比較明顯,即便App被kill下載任務仍然會繼續,并且允許下次啟動App使用這次的下載結果或繼續下載。

和上傳代碼一樣,創建下載任務很簡單,通過NSURLSession創建一個downloadTask,并調用resume即可開啟一個下載任務。

NSURLSessionConfiguration *config = [NSURLSessionConfiguration defaultSessionConfiguration];
NSURLSession *session = [NSURLSession sessionWithConfiguration:config 
                                                      delegate:self 
                                                 delegateQueue:[NSOperationQueue mainQueue]];

NSURL *url = [NSURL URLWithString:@"http://vfx.mtime.cn/Video/2017/03/31/mp4/170331093811717750.mp4"];
NSURLRequest *request = [[NSURLRequest alloc] initWithURL:url];
NSURLSessionDownloadTask *downloadTask = [session downloadTaskWithRequest:request];
[downloadTask resume];

我們可以調用suspend將下載任務掛起,隨后調用resume方法繼續下載任務,suspendresume需要是成對的。但是suspend掛起任務是有超時的,默認為60s,如果超時系統會將TCP連接斷開,我們再調用resume是失效的。可以通過NSURLSessionConfigurationtimeoutIntervalForResource來設置上傳和下載的資源耗時。suspend只針對于下載任務,其他任務掛起后將會重新開始。

下面兩個方法是下載比較基礎的方法,分別用來接收下載進度和下載完的臨時文件地址。didFinishDownloadingToURL:方法是required,當下載結束后下載文件被寫入在Library/Caches下的一個臨時文件,我們需要將此文件移動到自己的目錄,臨時目錄在未來的一個時間會被刪掉。

// 從服務器接收數據,下載進度回調
- (void)URLSession:(NSURLSession *)session downloadTask:(NSURLSessionDownloadTask *)downloadTask
             didWriteData:(int64_t)bytesWritten
        totalBytesWritten:(int64_t)totalBytesWritten
totalBytesExpectedToWrite:(int64_t)totalBytesExpectedToWrite {
    CGFloat progress = (CGFloat)totalBytesWritten / (CGFloat)totalBytesExpectedToWrite;
    self.progressView.progress = progress;
}

// 下載完成后回調
- (void)URLSession:(NSURLSession *)session downloadTask:(NSURLSessionDownloadTask *)downloadTask
didFinishDownloadingToURL:(NSURL *)location {
    
}

斷點續傳

HTTP協議支持斷點續傳操作,在開始下載請求時通過請求頭設置Range字段,標示從什么位置開始下載。

Range:bytes=512000-

服務端收到客戶端請求后,開始從512kb的位置開始傳輸數據,并通過Content-Range字段告知客戶端傳輸數據的起始位置。

Content-Range:bytes 512000-/1024000

downloadTask任務開始請求后,可以調用cancelByProducingResumeData:方法可以取消下載,并且可以獲得一個resumeDataresumeData中存放一些斷點下載的信息。可以將resumeData寫到本地,后面通過這個文件可以進行斷點續傳。

NSString *library = NSSearchPathForDirectoriesInDomains(NSLibraryDirectory, NSUserDomainMask, YES).firstObject;
NSString *resumePath = [library stringByAppendingPathComponent:[self.downloadURL md5String]];
[self.downloadTask cancelByProducingResumeData:^(NSData * _Nullable resumeData) {
    [resumeData writeToFile:resumePath atomically:YES];
}];

在創建下載任務前,可以判斷當前任務有沒有之前待恢復的任務,如果有的話調用downloadTaskWithResumeData:方法并傳入一個resumeData,可以恢復之前的下載,并重新創建一個downloadTask任務。

NSString *library = NSSearchPathForDirectoriesInDomains(NSLibraryDirectory, NSUserDomainMask, YES).firstObject;
NSString *resumePath = [library stringByAppendingPathComponent:[self.downloadURL md5String]];
NSData *resumeData = [[NSData alloc] initWithContentsOfFile:resumePath];
self.downloadTask = [self.session downloadTaskWithResumeData:resumeData];
[self.downloadTask resume];

通過suspendresume這種方式掛起的任務,downloadTask是同一個對象,而通過cancel然后resumeData恢復的任務,會創建一個新的downloadTask任務。

當調用downloadTaskWithResumeData:方法恢復下載后,會回調下面的方法。回調參數fileOffset是上次文件的下載大小,expectedTotalBytes是預估的文件總大小。

- (void)URLSession:(NSURLSession *)session downloadTask:(NSURLSessionDownloadTask *)downloadTask
 didResumeAtOffset:(int64_t)fileOffset
expectedTotalBytes:(int64_t)expectedTotalBytes;

后臺下載

通過backgroundSessionConfigurationWithIdentifier方法創建后臺上傳或后臺下載類型的NSURLSessionConfiguration,并且設置一個唯一標識,需要保證這個標識在不同的session之間的唯一性。后臺任務只支持httphttps的任務,其他協議的任務并不支持。

NSURLSessionConfiguration *config = [NSURLSessionConfiguration backgroundSessionConfigurationWithIdentifier:@"identifier"];
[NSURLSession sessionWithConfiguration:config delegate:self delegateQueue:[NSOperationQueue mainQueue]];

通過backgroundSessionConfigurationWithIdentifier方法創建的NSURLSession,請求任務將會在系統的單獨進程中進行,因此即使App進程被kill也不受影響,依然可以繼續執行請求任務。如果程序被系統kill調,下次啟動并執行didFinishLaunchingWithOptions可以通過相同的identifier創建NSURLSessionNSURLSessionConfiguration,系統會將新創建的NSURLSession和單獨進程中正在運行的NSURLSession進行關聯。

在程序啟動并執行didFinishLaunchingWithOptions方法時,按照下面方法創建NSURLSession即可將新創建的Session和之前的Session綁定,并自動開始執行之前的下載任務。恢復之前的任務后會繼續執行NSURLSession的代理方法,并執行后面的任務。

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
    
    NSURLSessionConfiguration *config = [NSURLSessionConfiguration backgroundSessionConfigurationWithIdentifier:@"identifier"];
    [NSURLSession sessionWithConfiguration:config delegate:self delegateQueue:[NSOperationQueue mainQueue]];
    
    return YES;
}

當應用進入到后臺時,可以繼續下載,如果客戶端沒有開啟Background Mode,則不會回調客戶端進度。下次進入前臺時,會繼續回調新的進度。

如果在后臺下載完成,則會通過AppDelegate的回調方法通知應用來刷新UI。由于下載是在一個單獨的進程中完成的,即便業務層代碼會停止執行,但下載的回調依然會被調用。在回調時,允許用戶處理業務邏輯,以及刷新UI。

調用此方法后可以開始刷新UI,調用completionHandler表示刷新結束,所以上層業務要做一些控制邏輯。didFinishDownloadingToURL的調用時機會比此方法要晚,依然在那個方法里可以判斷下載文件。由于項目中可能會存在多個下載任務,所以需要通過identifier對下載任務進行區分。

- (void)application:(UIApplication *)application handleEventsForBackgroundURLSession:(NSString *)identifier completionHandler:(void (^)(void))completionHandler {
    ViewController *vc = (ViewController *)self.window.rootViewController;
    vc.completionHandler = completionHandler;
}

需要注意的是,如果存在多個相同名字的identifier任務,則創建的session會將同名的任務都繼續執行。NSURLSessionConfiguration還提供下面的屬性,在session下載任務完成時是否啟動App,默認為YES,如果設置為NO則后臺下載會受到影響。

@property BOOL sessionSendsLaunchEvents;

后臺下載過程中會設計到一系列的代理方法調用,下面是調用順序。

后臺下載時序圖

視頻文件下載

現在很多視頻類App都有視頻下載的功能,視頻下載肯定不會是單純的把一個mp4下載下來就可以,這里就講一下視頻下載相關的知識。

  1. 視頻地址一般都是從服務端獲取的,所以需要先請求接口獲取下載地址。這個地址可以是某個接口就已經請求下來的,也可以是某個固定格式拼接的。
  2. 現在有很多視頻App都是有免流服務的,例如騰訊大王卡、螞蟻寶卡之類的,免流服務的本質就是對m3u8tsmp4地址重新包一層,請求數據的時候直接請求運營商給的地址,運營商對數據做了一個中轉操作。
  3. 以流視頻m3u8為例,有了免流地址,先下載m3u8文件。這個文件一般都是加密的,下載完成后客戶端會對m3u8文件進行decode,獲取到真正的m3u8文件。
  4. m3u8文件本質上是ts片段的集合,視頻播放播的還是ts片段。隨后對m3u8文件進行解析,獲取到ts片段地址,并將ts下載地址轉成免流地址后逐個下載,也可以并行下載。
  5. m3u8文件下載后會以固定格式存在文件夾下,文件夾對應被緩存的視頻。ts片命名以數字命名,例如0.ts,下標從0開始。
  6. 所有ts片段下載完成后,生成本地m3u8文件。
  7. m3u8文件分為遠端和本地兩種,遠端的就是正常下載的地址,本地m3u8文件是在播放本地視頻的時候傳入。格式和普通m3u8文件差不多,區別在于ts地址是本地地址,例如下面的地址。
#EXTM3U
#EXT-X-TARGETDURATION:30
#EXT-X-VERSION:3
#EXTINF:9.28,
0.ts
#EXTINF:33.04,
1.ts
#EXTINF:30.159,
2.ts
#EXTINF:23.841,
3.ts
#EXT-X-ENDLIST
m3u8文件

HLS(Http Live Streaming)是蘋果推出的流媒體協議,其中包含兩部分,m3u8文件和ts文件。使用ts文件的原因是因為多個ts可以無縫拼接,并且單個ts可以單獨播放。而mp4由于格式原因,被分割的mp4文件單獨播放會導致畫面撕裂或者音頻缺失的問題。如果單獨下載多個mp4文件,播放時會導致間斷的問題。

m3u8Unicode版本的m3u,是蘋果推出的一種視頻格式,是一個基于HTTP的流媒體傳輸協議。m3u8協議將一個媒體文件切為多個小文件,并利用HTTP協議進行數據傳輸,小文件所在的資源服務器路徑存儲在.m3u8文件中。客戶端拿到m3u8文件,即可根據文件中資源文件的路徑,分別下載不同的文件。

m3u8文件必須是utf-8格式編碼的,在文件中以#EXT開頭的是標簽,并且大小寫敏感。以#開頭的其他字符串則都會被認為是注釋。m3u8分為點播和直播,點播在第一次請求.m3u8文件后,將下載下來的ts片段進行順序播放即可。直播則需要過一段時間對.m3u8文件進行一個增量下載,并繼續下載后續的ts文件。

m3u8中有很多標簽,下面是項目中用到的一些標簽或主要標簽。將mp4或者flv文件進行切片很簡單,直接用ffmpeg命令切片即可。

  • 起始標簽,此標簽必須在整個文件的開頭。

#EXTM3U

  • 結束標簽,此標簽必須在整個文件的末尾。

#EXT-X-ENDLIST

  • 當前文件版本,如果不指定則默認為1

#EXT-X-VERSION

  • 所有ts片段最大時長。

#EXT-X-TARGETDURATION

  • 當前ts片段時長。

#EXTINF

如果沒有#EXT或#開頭的,一般都是ts片段下載地址。路徑可以是絕對路徑,也可以是相對路徑,我們項目里使用的是絕對路徑。但相對路徑數據量會相對比較小,只不過看視頻的人網速不會太差。

下面是相對路徑地址,文件中只有segment1.ts,則表示相對于m3u8的路徑,也就是下面的路徑。

https://data.vod.itc.cn/m3u8
https://data.vod.itc.cn/segment1.ts

常見錯誤

A background URLSession with identifier backgroundSession already exists

如果重復后臺已經存在的下載任務,會提示這個錯誤。需要在頁面退出或程序退出時,調用finishTasksAndInvalidate方法將任務invalidate

[[NSNotificationCenter defaultCenter] addObserver:self
                                             selector:@selector(willTerminateNotification)
                                                 name:UIApplicationWillTerminateNotification
                                               object:nil];
                                               
- (void)willTerminateNotification {
    [self.session getAllTasksWithCompletionHandler:^(NSArray<__kindof NSURLSessionTask *> * _Nonnull tasks) {
        if (tasks.count) {
            [self.session finishTasksAndInvalidate];
        }
    }];
}
最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。