該文章屬于劉小壯原創,轉載請注明:劉小壯
NSURLSession
NSURLSession
在iOS7
中推出,NSURLSession
的推出旨在替換之前的NSURLConnection
,NSURLSession
的使用相對于之前的NSURLConnection
更簡單,而且不用處理Runloop
相關的東西。
2015年RFC 7540
標準發布了http 2.0
版本,http 2.0
版本中包含很多新的特性,在傳輸速度上也有很明顯的提升。NSURLSession
從iOS9.0
開始,對http 2.0
提供了支持。
NSURLSession
由三部分構成:
- NSURLSession:請求會話對象,可以用系統提供的單例對象,也可以自己創建。
- NSURLSessionConfiguration:對
session
會話進行配置,一般都采用default
。 - NSURLSessionTask:負責執行具體請求的
task
,由session
創建。
NSURLSession
有三種方式創建:
sharedSession
系統維護的一個單例對象,可以和其他使用這個session
的task
共享連接和請求信息。
sessionWithConfiguration:
在NSURLSession初始化時傳入一個NSURLSessionConfiguration,這樣可以自定義請求頭、cookie等信息。
sessionWithConfiguration:delegate:delegateQueue:
如果想更好的控制請求過程以及回調線程,需要上面的方法進行初始化操作,并傳入delegate
來設置回調對象和回調的線程。
通過NSURLSession
發起一個網絡請求也比較簡單。
- 創建一個NSURLSessionConfiguration配置請求。
- 通過Configuration創建NSURLSession對象。
- 通過session對象發起網絡請求,并獲取task對象。
- 調用[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:處理普通的
Get
、Post
請求。 - 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
,直接傳入URL
或NSURLRequest
,即可直接在block
中接收返回數據。和普通創建方式一樣,block
的創建方式創建后默認也是suspend
的狀態,需要調用resume
開始任務。
completionHandler
和delegate
是互斥的,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
中的應用是用來獲取當前session
的task
,并將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
時可以指定線程,如果不指定線程,則completionHandler
和delegate
的回調方法,都會在子線程中執行。
如果初始化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
,并不會共享cookie
、cache
、密鑰等,而是不同configuration
都需要單獨設置。
這塊網上很多人理解都是錯的,并沒有真的在項目里使用或者沒有留意過,如和其他人有出入,以我為準。
@property (class, readonly, strong) NSURLSessionConfiguration *ephemeralSessionConfiguration;
創建臨時的configuration
,通過這種方式創建的對象,和普通的對象主要區別在于URLCache
、URLCredentialStorage
、HTTPCookieStorage
上面。同樣的,Ephemeral
也不是單例方法,而只是類方法。
URLCredentialStorage
Ephemeral <__NSCFMemoryURLCredentialStorage: 0x600001bc8320>
HTTPCookieStorage
Ephemeral <NSHTTPCookieStorage cookies count:0>
如果對Ephemeral
方式創建的config
進行打印的話,可以看到變量類型明顯區別于其他類型,并且在打印信息前面會有Ephemeral
的標示。通過Ephemeral
的方式創建的config
,不會產生持久化信息,可以很好保護請求的數據安全性。
+ (NSURLSessionConfiguration *)backgroundSessionConfigurationWithIdentifier:(NSString *)identifier;
identifier
方式一般用于恢復之前的任務,主要用于下載。如果一個下載任務正在進行中,程序被kill
調,可以在程序退出之前保存identifier
。下次進入程序后通過identifier
恢復之前的任務,系統會將NSURLSession
及NSURLSessionConfiguration
和之前的下載任務進行關聯,并繼續之前的任務。
timeout
timeoutIntervalForRequest
設置session
請求間的超時時間,這個超時時間并不是請求從開始到結束的時間,而是兩個數據包之間的時間間隔。當任意請求返回后這個值將會被重置,如果在超時時間內未返回則超時。單位為秒,默認為60秒。
timeoutIntervalForResource
資源超時時間,一般用于上傳或下載任務,在上傳或下載任務開始后計時,如果到達時間任務未結束,則刪除資源文件。單位為秒,默認時間是七天。
資源共享
如果是相同的NSURLSessionConfiguration
對象,會共享請求頭、緩存、cookie
、Credential
,通過Configuration
創建的NSURLSession
,也會擁有對應的請求信息。
@property (nullable, copy) NSDictionary *HTTPAdditionalHeaders;
公共請求頭,默認是空的,設置后所有經Confuguration
配置的NSURLSession
,請求頭都會帶有設置的信息。
@property (nullable, retain) NSHTTPCookieStorage *HTTPCookieStorage;
HTTP
請求的Cookie
管理器。如果是通過sharedSession
或backgroundConfiguration
創建的NSURLSession
,默認使用sharedHTTPCookieStorage
的Cookie
數據。如果不想使用Cookie
,則直接設置為nil
即可,也可以手動設置為自己的CookieStorage
。
@property (nullable, retain) NSURLCredentialStorage *URLCredentialStorage;
證書管理器。如果是通過sharedSession
或backgroundConfiguration
創建的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
提供了Memory
和Disk
的緩存,在創建時需要為其分別指定Memory
和Disk
的大小,以及存儲的文件位置。使用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-alive
,keep-alive
可以保持已經建立的鏈接,如果是相同的域名,在請求連接建立后,后面的請求不會立刻斷開,而是復用現有的連接。從HTTP1.1
開始默認開啟keep-alive
。
請求是在請求頭中設置下面的參數,服務器如果支持keep-alive
的話,響應客戶端請求時,也會在響應頭中加上相同的字段。
Connection: Keep-Alive
如果想斷開keep-alive
,可以在請求頭中加上下面的字段,但一般不推薦這么做。
Connection: Close
如果通過NSURLSession
來進行網絡請求的話,需要使用同一個NSURLSession
對象,如果創建新的session
對象則不能復用之前的鏈接。keep-alive
可以保持請求的連接,蘋果允許在iOS
上最大保持有4個連接,Mac
則是6個連接。
pipeline
在HTTP1.1
中,基于keep-alive
,還可以將請求進行管線化。和相同后端服務,TCP
層建立的鏈接,一般都需要前一個請求返回后,后面的請求再發出。但pipeline
就可以不依賴之前請求的響應,而發出后面的請求。
pipeline
依賴客戶端和服務器都有實現,服務端收到客戶端的請求后,要按照先進先出的順序進行任務處理和響應。pipeline
依然存在之前非pipeline
的問題,就是前面的請求如果出現問題,會阻塞當前連接影響后面的請求。
pipeline
對于請求大文件并沒有提升作用,只是對于普通請求速度有提升。在NSURLSessionConfiguration
中可以設置HTTPShouldUsePipelining
為YES
,開啟管線化,此屬性默認為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
中包含了startDate
、endDate
和duration
耗時時間。
@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
釋放。
通過調用NSURLSession
的invalidateAndCancel
或finishTasksAndInvalidate
方法,即可將強引用斷開并執行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-data
,AFNetworking
對表單上傳也有很有的支持。表單上傳需要遵循下面的格式進行上傳,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-Type
和Content-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:
的上傳方式,理解為UIImage
的imageNamed
的方法,上傳后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、弱網等很多情況。如果上傳分片太大可能會導致失敗率上升,分片文件太小會導致網絡請求太多,產生太多無用的boundary
、header
、數據鏈路等資源的浪費。
為了解決這個問題,我們采取的是動態分片大小的策略。根據特定的計算策略,預先使用第一個分片的上傳速度當做測速分片,測速分片的大小是固定的。根據測速的結果,對其他分片大小進行動態分片,這樣可以保證分片大小可以最大限度的利用當前網速。
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
方法繼續下載任務,suspend
和resume
需要是成對的。但是suspend
掛起任務是有超時的,默認為60s,如果超時系統會將TCP
連接斷開,我們再調用resume
是失效的。可以通過NSURLSessionConfiguration
的timeoutIntervalForResource
來設置上傳和下載的資源耗時。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:
方法可以取消下載,并且可以獲得一個resumeData
,resumeData
中存放一些斷點下載的信息。可以將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];
通過suspend
和resume
這種方式掛起的任務,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
之間的唯一性。后臺任務只支持http
和https
的任務,其他協議的任務并不支持。
NSURLSessionConfiguration *config = [NSURLSessionConfiguration backgroundSessionConfigurationWithIdentifier:@"identifier"];
[NSURLSession sessionWithConfiguration:config delegate:self delegateQueue:[NSOperationQueue mainQueue]];
通過backgroundSessionConfigurationWithIdentifier
方法創建的NSURLSession
,請求任務將會在系統的單獨進程中進行,因此即使App進程被kill
也不受影響,依然可以繼續執行請求任務。如果程序被系統kill
調,下次啟動并執行didFinishLaunchingWithOptions
可以通過相同的identifier
創建NSURLSession
和NSURLSessionConfiguration
,系統會將新創建的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下載下來就可以,這里就講一下視頻下載相關的知識。
- 視頻地址一般都是從服務端獲取的,所以需要先請求接口獲取下載地址。這個地址可以是某個接口就已經請求下來的,也可以是某個固定格式拼接的。
- 現在有很多視頻App都是有免流服務的,例如騰訊大王卡、螞蟻寶卡之類的,免流服務的本質就是對
m3u8
、ts
、mp4
地址重新包一層,請求數據的時候直接請求運營商給的地址,運營商對數據做了一個中轉操作。 - 以流視頻
m3u8
為例,有了免流地址,先下載m3u8
文件。這個文件一般都是加密的,下載完成后客戶端會對m3u8
文件進行decode
,獲取到真正的m3u8
文件。 -
m3u8
文件本質上是ts
片段的集合,視頻播放播的還是ts
片段。隨后對m3u8
文件進行解析,獲取到ts
片段地址,并將ts
下載地址轉成免流地址后逐個下載,也可以并行下載。 -
m3u8
文件下載后會以固定格式存在文件夾下,文件夾對應被緩存的視頻。ts
片命名以數字命名,例如0.ts
,下標從0開始。 - 所有
ts
片段下載完成后,生成本地m3u8
文件。 -
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
文件,播放時會導致間斷的問題。
m3u8
是Unicode
版本的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];
}
}];
}