Swift:用NSURLSession下載iTunes歌曲

在 swift 中使用 NSURLSession 時,看到了一篇 文章 使用 NSURLSession 從 iTunes 下載歌曲,也包含暫停、繼續(xù)下載、模擬進度、取消下載的功能。但文章中一些技術(shù)細節(jié)稍微老舊了些,故,在這里重新整理一下,方便日后學習。
完整項目地址 TracksDownload-iTunes

準備工作
  • Xcode 版本要求 7.3 及以上,我用的Xcode7.3,OS X 版本要求 10.11.0 及以上
  • 這里 下載基礎工程。解壓縮,運行程序,會看到一個基本的界面,界面上有個 SearchBar 和空的 TableView,如下圖
NSRULSession 簡介

NSRULSession 在技術(shù)上不僅是一個類,而且也是一套處理基于 HTTP/HTTPS 請求的類。通過下圖來了解一下它的構(gòu)成

NSRULSession 是收、發(fā) HTTP 請求的關(guān)鍵對象,它可以通過一個配置體 NSURLSessionConfiguration 創(chuàng)建。這個配置體可以設置 session 的超時時間,緩存策略,以及 HTTP headers ,它可以由三種方式創(chuàng)建:

  • defaultSessionConfiguration:通過這個方法生成的對象,會用默認的方式管理上傳和下載的任務 ,并本地持久化 cache,cookie 和 信任證書
  • ephemeralSessionConfiguration:和上面的方法類似,區(qū)別在于它會把會話相關(guān)的數(shù)據(jù)最優(yōu)化的存儲在內(nèi)存中,并從內(nèi)存中取這些數(shù)據(jù)
  • backgroundSessionConfiguration:系統(tǒng)會把上傳或下載任務放在單獨的進程,允許這些任務在后臺進行,及時這個 app 被后臺掛起或終止,session 的傳輸也不會停止(如果你雙擊home鍵,向上滑動 app 進行關(guān)閉,那么所有的 session 都會中斷)

NSRULSession 的所有的任務都需要關(guān)聯(lián)一個任務 NSURLSessionTask對象,這個對象是任務的實際執(zhí)行者,進行數(shù)據(jù)的獲取,下載或上傳文件。這個對象有三種類型:

  • NSURLSessionDataTask:用這種類型的對象做 HTTP GET 請求,從服務器檢索數(shù)據(jù),并存到內(nèi)存中
  • NSURLSessionUploadTask:用這種類型的對象把磁盤中的文件上傳到服務器,典型地,通過 HTTP POST 或 PUT 方法
  • NSURLSessionDownloadTask:用這種類型的對象從服務器下載文件,并存到一個臨時的文件地址

你可以暫停、繼續(xù)和取消一個任務。NSURLSessionDownloadTask 支持任務暫停,并在以后繼續(xù)下載

一般地,NSURLSession 通過兩種方式返回數(shù)據(jù):一. 任務完成或失敗后,通過一個 completionHandler 塊返回數(shù)據(jù);二. 在創(chuàng)建 session 時,指定一個代理方法,任務結(jié)束后通過回調(diào)方法返回數(shù)據(jù)

了解了 NSURLSession 的基本知識后,接下來開始實際操作

查詢歌曲

要查詢歌曲,需要借助 iTunes Search API ,在 UISearchBar 中,輸入關(guān)鍵字,然后點擊回車,進行搜索。

首先,在 SearchViewController.swift 中,在

var searchResults = [TrackModel]()

下面 添加以下代碼:

// 歌曲查詢 session 和 task
let session_queryTracks = NSURLSession(configuration: NSURLSessionConfiguration.defaultSessionConfiguration())
var task_queryTracks: NSURLSessionTask?
  • 第一句,我們通過默認的 configuration 生成了一個 NSURLSession 對象
  • 第二句,聲明一個 NSURLSessionTask 類型變量,用它進行 HTTP GET 請求,從 iTunes 的服務器查詢歌曲。每次用戶發(fā)起新的查詢時,這個變量都會被重新初始化并循環(huán)使用

然后,需要借助 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 設置允許包含在搜索關(guān)鍵詞中的字符
        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 生成查詢?nèi)蝿諏ο?        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 是否已經(jīng)初始化,如果初始化了,那么就取消上一次搜索任務,以便開始新的任務搜索,并重新利用 task_queryTracks
  • //2. 在狀態(tài)欄顯示小菊花,告訴用戶,系統(tǒng)正在進行網(wǎng)絡任務
  • //3. 搜索的關(guān)鍵字被傳入 URL 前,把一些不被允許的字符過濾掉
  • //4. 根據(jù) iTunes Search API ,把處理過的內(nèi)容當做 GET 請求的參數(shù),生成一個 NSURL 對象
  • //5. 初始化一個 NSURLSessionDataTask 對象,來處理 HTTP GET 請求,任務完成后,數(shù)據(jù)會在 completionHandler 塊中返回
  • //6. 在主線程隱藏狀態(tài)欄的菊花,表明網(wǎng)絡任務結(jié)束
  • //7. 如果成功了,則調(diào)用方法 updateSearchResults(_:) 來處理收到的 NSData 數(shù)據(jù),并更新 TableView
  • //8. 調(diào)用 resume() 開始搜索任務

運行 app,可以搜索任意一首歌,比如輸入 Swift,回車搜索,會出現(xiàn)下圖的效果:

準備下載歌曲

下載歌曲時,為了允許用戶暫停、繼續(xù)、取消下載,并且能顯示下載進度,我們建立一個下載的 Model ,來保存下載狀態(tài)。在 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:暫停時,得到的恢復數(shù)據(jù),包含繼續(xù)下載的信息(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,來追蹤歌曲的下載狀態(tài)
  • 第二句生成下載歌曲的 Session,這個 Session 只用于生成下載歌曲用的 NSURLSessionDownloadTask。其中,設置了代理,來處理與 Session 相關(guān)的事件,比如可以在代理方法中得到下載的進度,數(shù)據(jù)等。我們設置 delegateQueue 為 nil,默認的,系統(tǒng)會在一個串行隊列中進行代理方法的調(diào)用以及執(zhí)行的結(jié)果方法調(diào)用
  • 使用 lazy 關(guān)鍵字,系統(tǒng)不立刻生成 session_downloadTracks 這個對象,而是我們使用它時,系統(tǒng)才去創(chuàng)建

接下來,來實現(xiàn) NSURLSession 的代理方法。在文件 SearchViewController.swift 的最底部,加入以下代碼:

extension SearchTracksViewController: NSURLSessionDownloadDelegate {
    
    func URLSession(session: NSURLSession, downloadTask: NSURLSessionDownloadTask, didFinishDownloadingToURL location: NSURL) {
        
        print("下載結(jié)束")
    }
}
  • NSURLSessionDownloadDelegate 定義了使用 NSURLSession 下載某些任務時,用到的代理方法。一個下載任務結(jié)束的時候,方法 URLSession(_:downloadTask:didFinishDownloadingToURL:) 都會被調(diào)用。

我們來觸發(fā)下載任務。當用戶點擊 Download 按鈕時,會調(diào)用方法 startDownload(_:),在此方法中執(zhí)行下載任務,找到這個方法,更新為以下代碼:

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 對象,保存了下載中的歌曲狀態(tài),并映射到 字典trackDownload。

運行這個 app ,搜索任意一首歌,點擊下載,過一會就會收到一條打印信息:"下載結(jié)束"。表示下載結(jié)束。

保存并播放歌曲

歌曲下載完之后,會調(diào)用方法 URLSession(_:downloadTask:didFinishDownloadingToURL:) 。方法里有個參數(shù) URL,是文件的臨時存放地址,我們要做到的就是把這個文件拷貝一個指定的地址(本地持久化)。然后,我們需要把已經(jīng)完成的任務從字典 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)
                })
            }
        }
    }

對于上面代碼標注的關(guān)鍵步驟,做一個簡單說明:

  • //1. 我們提取出下載任務的原始 URL,然后找到 app 的 Documents 路徑,在這個路徑后拼接原始 URL的lastPathComponent,得到一個新的路徑,就是我們需要的目標路徑
  • //2. 把文件從臨時路徑 location 拷貝到目標路徑之前,先使用 NSFileManager 清理目標路徑下的數(shù)據(jù),然后再執(zhí)行拷貝
  • //3. 從數(shù)據(jù)結(jié)構(gòu)中刪除這個不再需要的 downloadTask 對象
  • //4. 根據(jù)索引,更新相應的 tableviewCell

運行 app,搜索一首歌,點擊下載,稍等片刻就會收到一條打印信息:


下載按鈕也會消失,點擊已經(jīng)下載的歌曲,就會彈出 MPMoviePlayerViewController 進行播放,如圖:

模擬下載進度

模擬下載進度時,我們需要知道兩點:

  • 已接收的數(shù)據(jù)量
  • 總數(shù)據(jù)量

協(xié)議 NSURLSessionDownloadDelegate 的代理方法中,有一個方法帶有我們需要的這兩個參數(shù),在文件 SearchViewController.swift 中,找到對這個協(xié)議的擴展,添加下面的方法:

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. 使用參數(shù) downloadTask,提取其中的 URL,然后根據(jù) URL 找到對應的下載 Model
  • //2. 這一步是關(guān)鍵,參數(shù) totalBytesWritten 代表已經(jīng)接收并寫入臨時文件的數(shù)據(jù),參數(shù) totalBytesExpectedToWrite 代表總數(shù)據(jù)量,兩個值求商就是當前的下載比例。然后保存到下載 Model 的 downloadProgress 屬性中
  • //3. NSByteCountFormatter 可以把數(shù)據(jù)量轉(zhuǎn)換為人們易懂的字節(jié)數(shù),比如轉(zhuǎn)換后變?yōu)?50 KB
  • //4. 最后找到這首歌曲對應的 cell,然后更新 cell 上的進度等

為了在 cell 上正確的顯示下載狀態(tài),找到方法 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”,并且根據(jù)下載狀態(tài)隱藏or顯示 v_progress 和 lb_progress。對于正在下載的歌曲,下載按鈕也要隱藏,所以,這句代碼

cell.btn_download.hidden = trackHaveDownloaded

更新為

cell.btn_download.hidden = trackHaveDownloaded || showDownloadControls

運行 app,下載一首歌,看一下下載效果,如圖所示:


暫停、繼續(xù)、取消下載

......

暫停

......

暫停時,會產(chǎn)生恢復數(shù)據(jù) resume data,根據(jù)這里面的數(shù)據(jù),可以在以后繼續(xù)下載,前提是服務器支持斷點下載。

并且不是所有的條件下都可以繼續(xù)下載的,具體哪些情況可以繼續(xù)下載,請參考 文檔

找到方法 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
            }
        }
    }

上面的代碼中,通過調(diào)用方法 cancelByProducingResumeData(_:),得到了 resume data,然后把這個 data 保存到相應的下載 Model 中,方便以后繼續(xù)下載。并更新 Model 中的屬性 isDownloading,表示停止下載。

......

繼續(xù)

......

找到方法 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,那么調(diào)用方法 downloadTaskWithResumeData(_:) 來繼續(xù)下載。如果沒有,就重新下載 。兩種情況下,都更新下載狀態(tài)為 true。

......

取消

......

取消下載就比較簡單了,找到方法 cancelDownload(_:) ,更新為以下代碼:

func cancelDownload(track: TrackModel) {
        
        if let url = track.trackPreviewUrl, download = trackDownload[url] {
            
            download.downloadTask?.cancel()
            trackDownload[url] = nil
        }
    }

在這個方法中,找到需要取消的下載任務,然后調(diào)用方法 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

整個工作到此結(jié)束,運行 app,下載幾首歌,并進行暫停,恢復,取消,效果如下圖所示:

總結(jié)

在建立 DownloadModel 的時候,里面的 DownloadModel 最好是個 class 類型,而不要聲明為 struct 類型,正如本項目中建立的一樣。因為 struct 類型是 value type,class 類型是 reference type

它們之間的區(qū)別請查看 Swift: 概念解釋

本項目中,會對 DownloadModel 的對象所持有的屬性,比如 isDownloading 等進行多次的修改。如果 DownloadModel 是 struct 類型,那么每次修改過之后,都需要再更新一遍字典 trackDownload 中對應的 model,因為 struct 類型的對象在傳遞的過程中,是重新拷貝一份的,拷貝后得到的數(shù)據(jù)并不指向原始地址。而 class 類型是 引用類型,故在傳遞過程中,這個對象都是指向原始地址的,對它的修改,也會影響原始數(shù)據(jù)。

我們可以對比一下 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
        }
    }

注意區(qū)分上面兩種情況下,使用 struct 類型會方便很多,不然,類似的還有方法 pauseDownload(_:) 、resumeDownload(_:)等,都需要做相應調(diào)整。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容