
自己動手寫一個 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 網絡請求庫》系列文章到此結束,謝謝大家!