Swift:用NSURLSession下载iTunes歌曲
在 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(_:)
等,都需要做相应调整。