自己動手寫一個 iOS 網絡請求庫(一)—— NSURLSession 初探
自己動手寫一個 iOS 網絡請求庫(二)——封裝接口
自己動手寫一個 iOS 網絡請求庫(三)——降低耦合
代碼示例:https://github.com/johnlui/Swift-On-iOS/blob/master/BuildYourHTTPRequestLibrary
開源項目:Pitaya,適合大文件上傳的 HTTP 請求庫:https://github.com/johnlui/Pitaya
本篇文章是此系列文章的終結篇,我們將一起給我們的網絡請求庫增加“快速文件上傳”的功能。
HTTP 協議解析
找資料
我翻出了以前買的《圖解 HTTP》:
找到第 46-47 頁,“發送多種數據的多部分對象集合”:
multipart/form-data
// HTTP 頭 開始 Content-Type: multipart/form-data; boundary=PitayaUGl0YXlh // HTTP 頭 結束 // HTTP Body 開始 --PitayaUGl0YXlh Content-Disposition: form-data; name="field1" John Lui --PitayaUGl0YXlh Content-Disposition: form-data; name="text"; filename="file1.txt" ···[file1.txt 的數據]··· --PitayaUGl0YXlh-- // HTTP Body 結束
詳解
HTTP 協議是一種非常基礎的“字符串格式化約定”,本質上傳輸的依然是一堆字符,只是由於遵守了標准協議,後端的 HTTP 服務軟件(Apache、nginx)和前端的浏覽器、NSData、NSURLSession 等接口可以順暢地交流。
在 HTTP 協議中,上傳文件可以進行如下設置:
設定 Content-Type 頭字段如下:
Content-Type: multipart/form-data; boundary=PitayaUGl0YXlh
boundary 是我們自己指定的間隔符。
之後設定 HTTP Body 如下:
--PitayaUGl0YXlh Content-Disposition: form-data; name="field1" John Lui --PitayaUGl0YXlh Content-Disposition: form-data; name="text"; filename="file1.txt" ···[file1.txt 的數據]··· --PitayaUGl0YXlh--
每個字段以 “--間隔符” 開頭,最後總體以 “--間隔符--” 結尾。
換行
HTTP 協議中,換行必須用 \r\n,我嘗試過只使用 \n 換行,系統會直接原封不動地發送這個換行,如果後端的 HTTP 服務器不支持這種容錯的話,可能就會出問題,所以建議大家還是要遵守標准協議。
代碼實現
構建 File 結構體
上傳文件也是表單,也需要一個 name,所以我們需要構造一個 File 結構體,來描述要上傳的文件:
struct File { let name: String! let url: NSURL! init(name: String, url: NSURL) { self.name = name self.url = url } }
上面代碼中,我們使用 NSURL 來描述文件地址。
增加 files 類成員變量並初始化
class NetworkManager { let method: String! let params: Dictionary let callback: (data: NSData!, response: NSURLResponse!, error: NSError!) -> Void // add files var files: Array let session = NSURLSession.sharedSession() let url: String! var request: NSMutableURLRequest! var task: NSURLSessionTask! // add files init(url: String, method: String, params: Dictionary = Dictionary(), files: Array = Array(), callback: (data: NSData!, response: NSURLResponse!, error: NSError!) -> Void) { self.url = url self.request = NSMutableURLRequest(URL: NSURL(string: url)!) self.method = method self.params = params self.callback = callback // add files self.files = files } ...... }
增加 boundary 類成員常量
class NetworkManager { let boundary = "PitayaUGl0YXlh" ...... }
更改 Content-Type
if self.files.count > 0 { request.addValue("multipart/form-data; boundary=" + self.boundary, forHTTPHeaderField: "Content-Type") } else if self.params.count > 0 { request.addValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") }
修改 buildBody 函數
func buildBody() { let data = NSMutableData() if self.files.count > 0 { if self.method == "GET" { NSLog("\n\n------------------------\nThe remote server may not accept GET method with HTTP body. But Pitaya will send it anyway.\n------------------------\n\n") } for (key, value) in self.params { data.appendData("--\(self.boundary)\r\n".nsdata) data.appendData("Content-Disposition: form-data; name=\"\(key)\"\r\n\r\n".nsdata) data.appendData("\(value.description)\r\n".nsdata) } for file in self.files { data.appendData("--\(self.boundary)\r\n".nsdata) data.appendData("Content-Disposition: form-data; name=\"\(file.name)\"; filename=\"\(file.url.description.lastPathComponent)\"\r\n\r\n".nsdata) if let a = NSData(contentsOfURL: file.url) { data.appendData(a) data.appendData("\r\n".nsdata) } } data.appendData("--\(self.boundary)--\r\n".nsdata) } else if self.params.count > 0 && self.method != "GET" { data.appendData(buildParams(self.params).nsdata) } request.HTTPBody = data }
.nsdata 屬性是我對 String 做的一個擴展,代碼在:https://github.com/johnlui/Swift-On-iOS/blob/master/BuildYourHTTPRequestLibrary/BuildYourHTTPRequestLibrary/Network.swift#L46-L50
調整 Network.request 接口群,增加上傳文件 API
static func request(method: String, url: String, callback: (data: NSData!, response: NSURLResponse!, error: NSError!) -> Void) { let manager = NetworkManager(url: url, method: method, callback: callback) manager.fire() } static func request(method: String, url: String, params: Dictionary, callback: (data: NSData!, response: NSURLResponse!, error: NSError!) -> Void) { let manager = NetworkManager(url: url, method: method, params: params, callback: callback) manager.fire() } static func request(method: String, url: String, files: Array, callback: (data: NSData!, response: NSURLResponse!, error: NSError!) -> Void) { let manager = NetworkManager(url: url, method: method, files: files, callback: callback) manager.fire() } static func request(method: String, url: String, params: Dictionary, files: Array, callback: (data: NSData!, response: NSURLResponse!, error: NSError!) -> Void) { let manager = NetworkManager(url: url, method: method, params: params, files: files, callback: callback) manager.fire() }
檢驗成果
增加一張圖片用於上傳文件測試:
測試代碼如下:
let file = File(name: "file", url: NSURL(fileURLWithPath: NSBundle.mainBundle().pathForResource("Pitaya", ofType: "png")!)!) Network.request("POST", url: "http://pitayaswift.sinaapp.com/pitaya.php", files: [file]) { (data, response, error) -> Void in let string = NSString(data: data, encoding: NSUTF8StringEncoding) as! String if string == "1" { println("上傳文件成功!") } }
http://pitayaswift.sinaapp.com/pitaya.php 會在收到 name="file" 的文件之後,輸出 1。
運行項目,點擊按鈕,輸出結果,成功!
快在哪裡?
Alamofire 並不支持表單文件上傳,似乎只支持流文件上傳(不確定),故我之前在使用 Alamofire 的時候,是把二進制文件讀出來之後進行 base64 編碼,然後當做字符串字段傳輸的,除了體積會增大三分之一外,最嚴重的問題在於非常長的 HTTP 准備時間(開始發送數據包之前的處理時間),這期間還是阻塞的。實際測試,無論是 A5 處理器的 touch5 還是 A8 處理器的 iPhone6,500KB 的語音文件都需要接近 30S 的預處理時間。阻塞問題可以通過超線程方式解決,但是總體上傳時間依然是非常長的,500 KB 的語音文件的預處理時間和網絡傳輸時間幾乎都一樣長了。
快在哪裡?采用 NSData 方式直接賦值給 HTTP Body,這種方式不會消耗任何預處理時間,當然也不會對主線程造成阻塞。而且傳輸的字符串的長度減少 25%,實際測試 500KB 語音文件上傳速度從 57S 縮短為 21S,增速十分可觀。
《自己動手寫一個 iOS 網絡請求庫》系列文章到此結束,謝謝大家!