最近工作中遇到一個需求,從系統相冊中選擇圖片和視頻,使用HTTP上傳到服務器端。在這個過程中也踩了一些坑,在這里和大家分享一下,共同進步。
選擇圖片和視頻
首先是同系統相冊選擇圖片和視頻。iOS系統自帶有UIImagePickerController
,可以選擇或拍攝圖片視頻,但是最大的問題是只支持單選,由于項目要求需要支持多選,只能自己自定義。獲取系統圖庫的框架有兩個,一個是ALAssetsLibrary
,兼容iOS低版本,但是在iOS9中是不建議使用的;另一個是PHAsset
,但最低要求iOS8以上。我們的項目需要兼容到iOS7,所以選擇了ALAssetsLibrary
。具體的實現可以參考我之前寫的仿微信iOS相冊選擇 MTImagePicker,github地址https://github.com/luowenxing/MTImagePicker ,這里就不再贅述啦。
HTTP上傳
接下來就是使用HTTP上傳到服務器端。通常來說,文件服務器一般會有兩種實現的方式。
- 一種是純的二進制文件上傳,對應的HTTP
Content-Type
可以是application/octet-stream
等application
開頭的MIME-Type
,即HTTP報文的Body
的內容就是文件的二進制內容,其他的文件名、鑒權等附加信息則放在cookie
或HTTP Header
里。 - 另一種就是
HTML
表單傳輸,對應的HTTPContent-Type
是multipart/form-data
,HTTP報文的Body
內容除了文件的二進制內容,還多了附加的表單字段信息和分割符等。表單上傳文件瀏覽器有原生的支持,如果iOS端需要使用這種方式就需要按照報文格式去拼裝你的HTTP Body,具體的報文格式可以參考iOS里實現multipart/form-data格式上傳文件。主流的網絡庫比如AFNetworking
就已經有了這類功能的封裝,比較方便。
我們的服務器端這兩種方式都支持,所以這里就直接使用二進制上傳的方式。在沒有第三方的網絡庫的情況下,使用NSURLConnection
或NSURLSession
發起網絡請求前,我們都需要一個NSURLRequest
對象,在這個對象上完成請求初始化。
let request = NSMutableURLRequest(URL: url, cachePolicy: .UseProtocolCachePolicy, timeoutInterval: 10)
request.HTTPMethod = "POST"
request.setValue("application/octet-stream", forHTTPHeaderField: "Content-Type")
設置好相關的HTTP Header
之后,設置HTTP Body的內容有兩種方式
request.HTTPBodyStream = NSInputStream()
-
request.HTTPBody = NSData()
這兩者設置其中任何一個都會使得另一個失效。
大文件處理
通常,對于小文件,我們可以任意選擇其中任何一種方式進行設置。對于比較大的文件,處理的原則是,不能把文件直接裝入內存中,否則會造成內存不足而使得App崩潰。具體的做法是:
- 對于沙箱內的文件,推薦使用
NSInputStream(fileAtPath: fileUrl)
初始化為文件流,不占內存。也可以使用NSData(contentsOfFile: String>, options: NSDataReadingOptions.DataReadingMappedAlways)
,使用內存映射的方式獲取NSData,在StackOverflow上有對這個問題的解釋。
Memory-mapped files copy data from disk into memory a page at a time. Unused pages are free to be swapped out, the same as any other virtual memory, unless they have been wired into physical memory using mlock(2). Memory mapping leaves the determination of what to copy from disk to memory and when to the OS.
類似虛擬內存的技術,簡單來說就是一次拷貝一頁的內存大小(頁是內存映射的最小單位),而不是整個拷貝到內存中。
- 對于系統相冊的文件,在此處具體來說就是一個
ALAsset
對象,我們能夠通過ALAssetRepresentation
的getBytes
方法獲取到文件的內容到一段緩沖區,繼而生成NSData
,但是這個NSData
并不是內存映射的,所以文件多大,就會占用多少內存。
let rept = asset.defaultRepresentation()
let imageBuffer = UnsafeMutablePointer<UInt8>.alloc(Int(rept.size()))
let bufferSize = rept.getBytes(imageBuffer, fromOffset: Int64(0),length: Int(rept.size()), error: nil)
let data = NSData(bytesNoCopy:imageBuffer ,length:bufferSize, freeWhenDone:true)
此時我們需要把ALAsset
轉化為NSInputStream
,通過CFStreamCreateBoundPair
這個類。在蘋果的官方文檔上有對這個類的使用場景介紹,但是沒有官方例子。
For large blocks of constructed data, call CFStreamCreateBoundPair to create a pair of streams, then call the setHTTPBodyStream: method to tell NSMutableURLRequest to use one of those streams as the source for its body content. By writing into the other stream, you can send the data a piece at a time.
其他的參考資料也很少,我找到的對我有幫助的資料之一就是StackOverflow上的這個問題:ios-how-to-upload-a-large-asset-file-into-sever-by-streaming
根據官方文檔,以及我收集的資料,具體的做法是使用CFStreamCreateBoundPair
創建一對readStream/writeStream
,readStream
就作為HTTPBodyStream
,設置NSStream
的代理,writeStream
加入Runloop
,監測其NSStreamEvent
是HasSpaceAvailable
時,調用getBytes
方法獲取一段NSData
,寫入到writeStream
中。主要的代碼如下。
func stream(aStream: NSStream, handleEvent eventCode: NSStreamEvent) {
switch (eventCode) {
case NSStreamEvent.None:
break
case NSStreamEvent.OpenCompleted:
break
case NSStreamEvent.HasBytesAvailable:
break
case NSStreamEvent.HasSpaceAvailable:
self.write()
break
case NSStreamEvent.ErrorOccurred :
self.finish()
break
case NSStreamEvent.EndEncountered:
// weird error: the output stream is full or closed prematurely, or canceled.
self.finish()
break
default:
break
}
}
func write() {
let rept = asset.defaultRepresentation()
let length = self.assetSize - self.offset > self.bufferSize ? self.bufferSize : self.assetSize - self.offset
if length > 0 {
let writeSize = rept.getBytes(assetBuffer, fromOffset: self.offset ,length: length, error:nil)
let written = self.writeStream.write(assetBuffer, maxLength: writeSize)
self.offset += written
} else {
self.finish()
}
}
func finish() {
self.writeStream.close()
self.writeStream.removeFromRunLoop(NSRunLoop.currentRunLoop(), forMode: NSRunLoopCommonModes)
self.strongSelf = nil
}
完整的代碼我上傳在了github,ALAssetToNSInputStream,把ALAssetToNSInputStream.swift
加入工程即可使用,Demo暫時還沒有,有時間會補上??垂賯冸S手給個Star唄 ~