在 swift 中使用 NSURLSession 時,看到了一篇 文章 使用 NSURLSession 從 iTunes 下載歌曲,也包含暫停、繼續下載、模擬進度、取消下載的功能。但文章中一些技術細節稍微老舊了些,故,在這里重新整理一下,方便日后學習。
完整項目地址 TracksDownload-iTunes
準備工作
- Xcode 版本要求 7.3 及以上,我用的Xcode7.3,OS X 版本要求 10.11.0 及以上
- 從 這里 下載基礎工程。解壓縮,運行程序,會看到一個基本的界面,界面上有個 SearchBar 和空的 TableView,如下圖
NSRULSession 簡介
NSRULSession
在技術上不僅是一個類,而且也是一套處理基于 HTTP/HTTPS 請求的類。通過下圖來了解一下它的構成
NSRULSession
是收、發 HTTP 請求的關鍵對象,它可以通過一個配置體 NSURLSessionConfiguration
創建。這個配置體可以設置 session 的超時時間,緩存策略,以及 HTTP headers ,它可以由三種方式創建:
-
defaultSessionConfiguration
:通過這個方法生成的對象,會用默認的方式管理上傳和下載的任務 ,并本地持久化 cache,cookie 和 信任證書 -
ephemeralSessionConfiguration
:和上面的方法類似,區別在于它會把會話相關的數據最優化的存儲在內存中,并從內存中取這些數據 -
backgroundSessionConfiguration
:系統會把上傳或下載任務放在單獨的進程,允許這些任務在后臺進行,及時這個 app 被后臺掛起或終止,session 的傳輸也不會停止(如果你雙擊home鍵,向上滑動 app 進行關閉,那么所有的 session 都會中斷)
NSRULSession
的所有的任務都需要關聯一個任務 NSURLSessionTask
對象,這個對象是任務的實際執行者,進行數據的獲取,下載或上傳文件。這個對象有三種類型:
-
NSURLSessionDataTask
:用這種類型的對象做 HTTP GET 請求,從服務器檢索數據,并存到內存中 -
NSURLSessionUploadTask
:用這種類型的對象把磁盤中的文件上傳到服務器,典型地,通過 HTTP POST 或 PUT 方法 -
NSURLSessionDownloadTask
:用這種類型的對象從服務器下載文件,并存到一個臨時的文件地址
你可以暫停、繼續和取消一個任務。NSURLSessionDownloadTask
支持任務暫停,并在以后繼續下載
一般地,NSURLSession
通過兩種方式返回數據:一. 任務完成或失敗后,通過一個 completionHandler
塊返回數據;二. 在創建 session 時,指定一個代理方法,任務結束后通過回調方法返回數據
了解了 NSURLSession
的基本知識后,接下來開始實際操作
查詢歌曲
要查詢歌曲,需要借助 iTunes Search API ,在 UISearchBar 中,輸入關鍵字,然后點擊回車,進行搜索。
首先,在 SearchViewController.swift 中,在
var searchResults = [TrackModel]()
下面 添加以下代碼:
// 歌曲查詢 session 和 task
let session_queryTracks = NSURLSession(configuration: NSURLSessionConfiguration.defaultSessionConfiguration())
var task_queryTracks: NSURLSessionTask?
- 第一句,我們通過默認的 configuration 生成了一個
NSURLSession
對象 - 第二句,聲明一個
NSURLSessionTask
類型變量,用它進行 HTTP GET 請求,從 iTunes 的服務器查詢歌曲。每次用戶發起新的查詢時,這個變量都會被重新初始化并循環使用
然后,需要借助 UISearchBar 的代理方法 searchBarSearchButtonClicked(_:)
,來捕獲用戶的搜索行為。在 SearchViewController.swift 中找到這個代理方法,更新為如下代碼:
func searchBarSearchButtonClicked(searchBar: UISearchBar) {
dismissKeyboard()
let searchString = searchBar.text!.stringByTrimmingCharactersInSet(NSCharacterSet.whitespaceAndNewlineCharacterSet())
if !searchString.isEmpty {
// 1
if task_queryTracks != nil {
task_queryTracks?.cancel()
}
// 2
UIApplication.sharedApplication().networkActivityIndicatorVisible = true
// 3 設置允許包含在搜索關鍵詞中的字符
let expectedCharSet = NSCharacterSet.URLQueryAllowedCharacterSet()
let searchTerm = searchString.stringByAddingPercentEncodingWithAllowedCharacters(expectedCharSet)
// 4
let urlString = "http://itunes.apple.com/search?media=music&entity=song&term=\(searchTerm!)"
let url = NSURL(string: urlString)
// 5 生成查詢任務對象
task_queryTracks = session_queryTracks.dataTaskWithURL(url!, completionHandler: { [unowned self](data, response, error) in
// 6
dispatch_async(dispatch_get_main_queue(), {
UIApplication.sharedApplication().networkActivityIndicatorVisible = false
})
// 7
if let error = error {
print(error.localizedDescription)
}
else if let httpResponse = response as? NSHTTPURLResponse {
if httpResponse.statusCode == 200 {
self.updateSearchResults(data)
}
}
})
// 8 開始查詢
task_queryTracks?.resume()
}
}
按著上面的注釋標號,依次說明一下:
- //1. 每次用戶查詢時,都會檢查 task_queryTracks 是否已經初始化,如果初始化了,那么就取消上一次搜索任務,以便開始新的任務搜索,并重新利用 task_queryTracks
- //2. 在狀態欄顯示小菊花,告訴用戶,系統正在進行網絡任務
- //3. 搜索的關鍵字被傳入 URL 前,把一些不被允許的字符過濾掉
- //4. 根據 iTunes Search API ,把處理過的內容當做 GET 請求的參數,生成一個 NSURL 對象
- //5. 初始化一個 NSURLSessionDataTask 對象,來處理 HTTP GET 請求,任務完成后,數據會在 completionHandler 塊中返回
- //6. 在主線程隱藏狀態欄的菊花,表明網絡任務結束
- //7. 如果成功了,則調用方法
updateSearchResults(_:)
來處理收到的NSData
數據,并更新 TableView - //8. 調用
resume()
開始搜索任務
運行 app,可以搜索任意一首歌,比如輸入 Swift
,回車搜索,會出現下圖的效果:
準備下載歌曲
下載歌曲時,為了允許用戶暫停、繼續、取消下載,并且能顯示下載進度,我們建立一個下載的 Model ,來保存下載狀態。在 Model 文件夾下,新建類文件,命名為 DownloadModel 如圖:
在文件 DownloadModel.swift 中,添加以下代碼:
class DownloadModel {
var downloadUrl: String
var isDownloading = false
var downloadProgress = 0.0
var downloadTask: NSURLSessionDownloadTask?
var downloadResumeData: NSData?
init(downloadUrl: String) {
self.downloadUrl = downloadUrl
}
}
簡單介紹一下這些屬性:
-
downloadUrl
:歌曲的下載地址,唯一標識一個DownloadModel
-
isDownloading
: 歌曲是否正在下載 -
downloadProgress
:歌曲下載進度,0.0~1.0 -
downloadTask
:歌曲下載的一個 Task 對象 -
downloadResumeData
:暫停時,得到的恢復數據,包含繼續下載的信息(iTunes 服務器支持斷點下載)
建立下載任務
有了這個 Model 之后,為了追蹤每一個下載任務,切換到 SearchViewController.swift 文件,找到
var searchResults = [TrackModel]()
在它下面一行,添加以下代碼:
var trackDownload = [String: DownloadModel]()
lazy var session_downloadTracks: NSURLSession = {
let configuration = NSURLSessionConfiguration.defaultSessionConfiguration()
let session = NSURLSession(configuration: configuration, delegate: self, delegateQueue: nil)
return session
}()
- 第一句是做了一個下載的映射,唯一的 url 對應一個下載 Model,來追蹤歌曲的下載狀態
- 第二句生成下載歌曲的 Session,這個 Session 只用于生成下載歌曲用的
NSURLSessionDownloadTask
。其中,設置了代理,來處理與 Session 相關的事件,比如可以在代理方法中得到下載的進度,數據等。我們設置 delegateQueue 為 nil,默認的,系統會在一個串行隊列中進行代理方法的調用以及執行的結果方法調用 - 使用
lazy
關鍵字,系統不立刻生成session_downloadTracks
這個對象,而是我們使用它時,系統才去創建
接下來,來實現 NSURLSession 的代理方法。在文件 SearchViewController.swift 的最底部,加入以下代碼:
extension SearchTracksViewController: NSURLSessionDownloadDelegate {
func URLSession(session: NSURLSession, downloadTask: NSURLSessionDownloadTask, didFinishDownloadingToURL location: NSURL) {
print("下載結束")
}
}
- NSURLSessionDownloadDelegate 定義了使用 NSURLSession 下載某些任務時,用到的代理方法。一個下載任務結束的時候,方法
URLSession(_:downloadTask:didFinishDownloadingToURL:)
都會被調用。
我們來觸發下載任務。當用戶點擊 Download 按鈕時,會調用方法 startDownload(_:)
,在此方法中執行下載任務,找到這個方法,更新為以下代碼:
func startDownload(track: TrackModel) {
if let urlString = track.trackPreviewUrl, url = NSURL(string:urlString) {
let download = DownloadModel(downloadUrl: urlString)
download.downloadTask = session_downloadTracks.downloadTaskWithURL(url)
download.downloadTask?.resume()
download.isDownloading = true
trackDownload[urlString] = download
}
}
- 當用戶點擊下載的時候,在此方法中生成一個
DownloadModel
對象,保存了下載中的歌曲狀態,并映射到 字典trackDownload
。
運行這個 app ,搜索任意一首歌,點擊下載,過一會就會收到一條打印信息:"下載結束"
。表示下載結束。
保存并播放歌曲
歌曲下載完之后,會調用方法 URLSession(_:downloadTask:didFinishDownloadingToURL:)
。方法里有個參數 URL,是文件的臨時存放地址,我們要做到的就是把這個文件拷貝一個指定的地址(本地持久化)。然后,我們需要把已經完成的任務從字典 trackDownload
中移除,并更新相應的 tableViewCell。
為了方便找到對應的 cell,我們在 SearchViewController.swift 文件中添加一個輔助方法,用來返回 cell 所在的索引 index。代碼如下:
func cellIndexOfDownloadTrack(downloadTrack:NSURLSessionDownloadTask) -> Int? {
if let url = downloadTrack.originalRequest?.URL?.absoluteString {
for (index, track) in searchResults.enumerate() {
if url == track.trackPreviewUrl {
return index
}
}
}
return nil
}
下一步就要開始把文件拷貝到我們指定的地址。更新代理方法 URLSession(_:downloadTask:didFinishDownloadingToURL:)
如下:
func URLSession(session: NSURLSession, downloadTask: NSURLSessionDownloadTask, didFinishDownloadingToURL location: NSURL) {
// 1
let originalURL: String? = downloadTask.originalRequest?.URL?.absoluteString
if let url = originalURL, destinationURL = localFilePathForUrl(url) {
print(destinationURL)
// 2
let fileManager = NSFileManager.defaultManager()
do {
try fileManager.removeItemAtURL(destinationURL)
} catch {
//
}
do {
try fileManager.copyItemAtURL(location, toURL: destinationURL)
} catch let error as NSError {
print("Could not copy file to disk:\(error.localizedDescription)")
}
}
// 3
if let url = originalURL {
trackDownload[url] = nil
// 4
if let index = cellIndexOfDownloadTrack(downloadTask) {
dispatch_async(dispatch_get_main_queue(), {
[unowned self] in
self.tableView.reloadRowsAtIndexPaths([NSIndexPath(forRow: index,inSection: 0)], withRowAnimation: .None)
})
}
}
}
對于上面代碼標注的關鍵步驟,做一個簡單說明:
- //1. 我們提取出下載任務的原始 URL,然后找到 app 的 Documents 路徑,在這個路徑后拼接原始 URL的lastPathComponent,得到一個新的路徑,就是我們需要的目標路徑
- //2. 把文件從臨時路徑 location 拷貝到目標路徑之前,先使用 NSFileManager 清理目標路徑下的數據,然后再執行拷貝
- //3. 從數據結構中刪除這個不再需要的 downloadTask 對象
- //4. 根據索引,更新相應的 tableviewCell
運行 app,搜索一首歌,點擊下載,稍等片刻就會收到一條打印信息:
下載按鈕也會消失,點擊已經下載的歌曲,就會彈出
MPMoviePlayerViewController
進行播放,如圖:
模擬下載進度
模擬下載進度時,我們需要知道兩點:
- 已接收的數據量
- 總數據量
協議 NSURLSessionDownloadDelegate
的代理方法中,有一個方法帶有我們需要的這兩個參數,在文件 SearchViewController.swift 中,找到對這個協議的擴展,添加下面的方法:
func URLSession(session: NSURLSession, downloadTask: NSURLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) {
// 1
if let url = downloadTask.originalRequest?.URL?.absoluteString, trackDownload = trackDownload[url] {
// 2
trackDownload.downloadProgress = Float(totalBytesWritten)/Float(totalBytesExpectedToWrite)
// 3
let totalSize = NSByteCountFormatter.stringFromByteCount(totalBytesExpectedToWrite, countStyle: .Binary)
// 4
if let index = cellIndexOfDownloadTrack(downloadTask), trackCell = tableView.cellForRowAtIndexPath(NSIndexPath(forRow: index, inSection: 0)) as? TrackCell {
dispatch_async(dispatch_get_main_queue(), {
trackCell.v_progress.progress = trackDownload.downloadProgress
trackCell.lb_progress.text = String(format: "%.1f%% of %@", trackDownload.downloadProgress*100,totalSize)
})
}
}
}
接下來一步步分析代碼中的標注:
- //1. 使用參數 downloadTask,提取其中的 URL,然后根據 URL 找到對應的下載 Model
- //2. 這一步是關鍵,參數 totalBytesWritten 代表已經接收并寫入臨時文件的數據,參數 totalBytesExpectedToWrite 代表總數據量,兩個值求商就是當前的下載比例。然后保存到下載 Model 的 downloadProgress 屬性中
- //3. NSByteCountFormatter 可以把數據量轉換為人們易懂的字節數,比如轉換后變為 50 KB
- //4. 最后找到這首歌曲對應的 cell,然后更新 cell 上的進度等
為了在 cell 上正確的顯示下載狀態,找到方法 tableView(_:cellForRowAtIndexPath:)
,在
let track = searchResults[indexPath.row]
下面添加代碼:
var showDownloadControls = false
if let download = trackDownload[track.trackPreviewUrl!] {
showDownloadControls = true
cell.v_progress.progress = download.downloadProgress
cell.lb_progress.text = download.isDownloading ? "Downloading..." : "Paused"
}
cell.v_progress.hidden = !showDownloadControls
cell.lb_progress.hidden = !showDownloadControls
對于將要下載的歌曲,顯示 “Downloading...”,暫停的顯示 “Paused”,并且根據下載狀態隱藏or顯示 v_progress 和 lb_progress。對于正在下載的歌曲,下載按鈕也要隱藏,所以,這句代碼
cell.btn_download.hidden = trackHaveDownloaded
更新為
cell.btn_download.hidden = trackHaveDownloaded || showDownloadControls
運行 app,下載一首歌,看一下下載效果,如圖所示:
暫停、繼續、取消下載
......
暫停
......
暫停時,會產生恢復數據 resume data
,根據這里面的數據,可以在以后繼續下載,前提是服務器支持斷點下載。
并且不是所有的條件下都可以繼續下載的,具體哪些情況可以繼續下載,請參考 文檔
找到方法 pauseDownload(_:)
,更新為以下代碼:
func pauseDownload(track: TrackModel) {
if let url = track.trackPreviewUrl, download = trackDownload[url] {
if download.isDownloading {
download.downloadTask?.cancelByProducingResumeData({ (data) in
if data != nil {
download.downloadResumeData = data
}
})
download.isDownloading = false
}
}
}
上面的代碼中,通過調用方法 cancelByProducingResumeData(_:)
,得到了 resume data
,然后把這個 data 保存到相應的下載 Model 中,方便以后繼續下載。并更新 Model 中的屬性 isDownloading
,表示停止下載。
......
繼續
......
找到方法 resumeDownload(_:)
,更新為以下代碼:
func resumeDownload(track: TrackModel) {
if let previewUrl = track.trackPreviewUrl, download = trackDownload[previewUrl] {
if let resumeData = download.downloadResumeData {
download.downloadTask = session_downloadTracks.downloadTaskWithResumeData(resumeData)
download.downloadTask!.resume()
download.isDownloading = true
}
else if let url = NSURL(string: download.downloadUrl) {
download.downloadTask = session_downloadTracks.downloadTaskWithURL(url)
download.downloadTask!.resume()
download.isDownloading = true
}
}
}
在這個方法中,我們判斷如果有 resume data
,那么調用方法 downloadTaskWithResumeData(_:)
來繼續下載。如果沒有,就重新下載 。兩種情況下,都更新下載狀態為 true。
......
取消
......
取消下載就比較簡單了,找到方法 cancelDownload(_:)
,更新為以下代碼:
func cancelDownload(track: TrackModel) {
if let url = track.trackPreviewUrl, download = trackDownload[url] {
download.downloadTask?.cancel()
trackDownload[url] = nil
}
}
在這個方法中,找到需要取消的下載任務,然后調用方法 cancel()
就會取消下載,并從字典中刪掉這個任務。
最后要做的就是更新 cell 的工作了。回到方法 tableView(_:cellForRowAtIndexPath:)
,在 if
塊中,添加下面的代碼:
let title = download.isDownloading ? "Pause" : "Resume"
cell.btn_pause.setTitle(title, forState: .Normal)
在
cell.lb_progress.hidden = !showDownloadControls
下面添加以下代碼:
cell.btn_pause.hidden = !showDownloadControls
cell.btn_cancel.hidden = !showDownloadControls
整個工作到此結束,運行 app,下載幾首歌,并進行暫停,恢復,取消,效果如下圖所示:
總結
在建立 DownloadModel 的時候,里面的 DownloadModel 最好是個 class
類型,而不要聲明為 struct
類型,正如本項目中建立的一樣。因為 struct
類型是 value type
,class
類型是 reference type
它們之間的區別請查看 Swift: 概念解釋
本項目中,會對 DownloadModel 的對象所持有的屬性,比如 isDownloading
等進行多次的修改。如果 DownloadModel 是 struct
類型,那么每次修改過之后,都需要再更新一遍字典 trackDownload
中對應的 model,因為 struct
類型的對象在傳遞的過程中,是重新拷貝一份的,拷貝后得到的數據并不指向原始地址。而 class
類型是 引用類型,故在傳遞過程中,這個對象都是指向原始地址的,對它的修改,也會影響原始數據。
我們可以對比一下 DownloadModl 為 class
類型和 struct
類型兩種情況下,代碼的差異性:
- DownloadModl 為
class
類型:
func URLSession(session: NSURLSession, downloadTask: NSURLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) {
// 1
if let url = downloadTask.originalRequest?.URL?.absoluteString, trackDownload = trackDownload[url] {
// 2
trackDownload.downloadProgress = Float(totalBytesWritten)/Float(totalBytesExpectedToWrite)
// 3
let totalSize = NSByteCountFormatter.stringFromByteCount(totalBytesExpectedToWrite, countStyle: .Binary)
// 4
if let index = cellIndexOfDownloadTrack(downloadTask), trackCell = tableView.cellForRowAtIndexPath(NSIndexPath(forRow: index, inSection: 0)) as? TrackCell {
dispatch_async(dispatch_get_main_queue(), {
trackCell.v_progress.progress = trackDownload.downloadProgress
trackCell.lb_progress.text = String(format: "%.1f%% of %@", trackDownload.downloadProgress*100,totalSize)
})
}
}
}
- DownloadModl 為
struct
類型:
func URLSession(session: NSURLSession, downloadTask: NSURLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) {
// 1
if let url = downloadTask.originalRequest?.URL?.absoluteString {
download = trackDownload[url]! as DownloadModl
// 2
download.downloadProgress = Float(totalBytesWritten)/Float(totalBytesExpectedToWrite)
// 3
let totalSize = NSByteCountFormatter.stringFromByteCount(totalBytesExpectedToWrite, countStyle: .Binary)
// 4
if let index = cellIndexOfDownloadTrack(downloadTask), trackCell = tableView.cellForRowAtIndexPath(NSIndexPath(forRow: index, inSection: 0)) as? TrackCell {
dispatch_async(dispatch_get_main_queue(), {
trackCell.v_progress.progress = download.downloadProgress
trackCell.lb_progress.text = String(format: "%.1f%% of %@", download.downloadProgress*100,totalSize)
})
}
trackDownload[url] = download
}
}
注意區分上面兩種情況下,使用 struct
類型會方便很多,不然,類似的還有方法 pauseDownload(_:)
、resumeDownload(_:)
等,都需要做相應調整。