In the previous tutorial, we started creating a simple podcast client to put what we‘ve learned about NSURLSession
into
practice. So far, our podcast client can query the iTunes Search API, download a podcast feed, and display a list of episodes. In this tutorial, we zoom in on another interesting aspect of NSURLSession
,
out-of-process downloads. Let me show you how this works.
在前一篇教程中,我们创建了一个简单的播客客户端,对 NSURLSession 的所学进行了实践。目前,我们的播客客户端可以 query the iTunes Search API, download a podcast feed, 和 display a list of episodes。在这篇教程中,将探究 NSURLSession 另一个有趣的部分:out-of-process download。下面开始吧!
Introduction
In this fourth and final tutorial about NSURLSession
, we‘ll
take a closer look at out-of-process tasks, download tasks in particular. Our podcast client is already able to show a list of episodes, but it currently lacks the ability to download individual episodes. That‘ll be the focus of this tutorial.
在这第四篇也是最后一篇有关 NSURLSession 的教程,我们将深入了解 out-of-process task 尤其是 download task。目前播客客户端可以 show a list of episodes,但是还不能下载 individual episodes,这将是本篇教程的关注点。
Background Uploads and Downloads
Adding support for background uploads and downloads is surprisingly easy withNSURLSession
.
Apple refers to them as out-of-process uploads and downloads as the tasks are managed by a background daemon, not your application. Even if your application crashes during an upload or download task, the task continues in the background.
对于 NSURLSession ,是支持后台下载和上传的。Apple 将其称之 进程之外(out-of-process)的上传和下载,因为这些任务都是由后台的守护进程完成的,而非应用程序本身。即使应用程序奔溃了,上传或者下载任务都可以在后台进行执行。
Overview
I‘d like to take a few moments to take a closer look at how out-of-process tasks work. It‘s pretty simple once you have a complete picture of the process. Enabling background uploads and downloads is nothing more than flipping a switch in your session‘s configuration. With a properly configured session object, you are ready to schedule upload and download tasks in the background.
先看看 out-of-process 的任务是如何工作的。一旦你对整个过程有了全面的了解,这将变得很简单。启动后台上传和下载无非就是在会话配置中进行简单的设置(如同扳开开关),通过会话配置对象中的属性设置,你就可以在后台执行上传和下载的任务。
When an upload or download is initiated, a background daemon comes into existence. The daemon takes care of the task and sends updates to the application through the delegate protocols declared in the NSURLSession
API.
If your application stops running for some reason, the task continues in the background as it‘s the daemon managing the task. The moment the task finishes, the application that created the task is notified. It reconnects with the background session that created
the task and the daemon managing the task informs the session that the task finished and, in the case of a download task, hands the file over to the session. The session then invokes the appropriate delegate methods to make sure your application can take the
appropriate actions, such as moving the file to a more permanent location. That‘s enough theory for now. Let‘s see what we need to do to implement out-of-process downloads in Singlecast.
当一个上传或者下载的任务启动,一个后台守护进程就存在了。通过 NSURLSession 委托协议中的 API ,守护进程关注维护这个任务,并且发送更新消息给应用程序。如果由于某些原因,应用程序停止运行,守护进程会在后台管理这个任务的继续执行。一旦任务执行结束,就会通知创建该任务的应用程序。它(应用程序)会和后台这个任务的会话重新连接;同时,后台守护进程通知会话对象,任务执行结束,如果是一个下载任务,会将下载文件提交给会话对象。会话对象接着会调用相应的委托方法,确保应用程序执行恰当的动作,例如,将文件移动到某一持久化存储位置。这些理论解释差不多就是这样了,下面看看如何在 Singlecast 项目中实现 out-of-process 的下载。
1. Subclass UITableViewCell
Step 1: Update Main Storyboard
At the moment, we are using prototype cells to populate the table view. To give us a bit more flexibility, we need to create a UITableViewCell
subclass.
Open the main storyboard, select the table view of the MTViewController
instance
and set the number of prototype cells to 0
.
目前,在table view中我们使用的是原型的表单元。为了处理的灵活性,我们自定义创建 UITableViewCell 的子类。打开 storyboard,选中MTViewController 的 table view,设置其 prototype cells 数量为0.
Step 2: Create Subclass
Open Xcode‘s File menu and choose New > File.... Create a new Objective-C class, name it MTEpisodeCell
,
and make sure it inherits from UITableViewCell
. Tell Xcode
where you‘d like to store the class files and hit Create.
新建一个 Objective-C 类文件,名为 MTEpisodeCell ,继承自UITableViewCell。
Step 3: Update Class Interface
The interface of MTEpisodeCell
is simple
as you can see in the code snippet below. All we do is declare a property progress
of
type float
. We‘ll use this to update and display the progress
of the download task that we‘ll use for downloading an episode.
MTEpisodeCell 类的interface 是比较简单的,代码片段如下。在其中声明了一个 float 类型的属性 progress 。这个属性将用来更新和显示下载任务的进度。
#import <UIKit/UIKit.h> @interface MTEpisodeCell : UITableViewCell @property (assign, nonatomic) float progress; @end
Step 4: Implement Class
The implementation of MTEpisodeCell
is
a bit more involved, but it isn‘t complicated. Instead of using an instance of UIProgressView
,
we‘ll fill the cell‘s content view with a solid color to show the progress of the download task. We do this by adding a subview to the cell‘s content view and updating its width whenever the cell‘s progress
property
changes. Start by declaring a private property progressView
of
type UIView
.
MTEpisodeCell 类的 implementation 是有些复杂,但不难实现。我们用纯色填充表单元视图以显示下载任务的进度,而不使用进度条 UIProgressView。在表单元中添加一个子视图更新显示 progress 属性的变化,添加一个 UIView 类型的私有属性 progressView (在实现文件的 interface 中添加 property 即是私有属性)。
#import "MTEpisodeCell.h" @interface MTEpisodeCell () @property (strong, nonatomic) UIView *progressView; @end
We override the class‘s designated initializer as shown below. Note how we ignore thestyle
argument
and pass UITableViewCellStyleSubtitle
to
the superclass‘s designated initializer. This is important, because the table view will passUITableViewCellStyleDefault
as
the cell‘s style when we ask it for a new cell.
覆盖该类的指定初始化方法如下。注意到style 参数,传递UITableViewCellStyleSubtitle 给超类的初始化方法。这一点很重要,因为创建一个新的cell的时候,table view 会传递UITableViewCellStyleDefault 给cell 的类型。
In the initializer, we set the background color of the text and detail text labels to [UIColor
clearColor]
and create the progress view. Two details are especially important. First, we insert the progress view as a subview of the cell‘s content view at index 0
to
make sure that it‘s inserted below the text labels. Second, we invoke updateView
to
make sure that the frame of the progress view is updated to reflect the value of progress
,
which is set to 0
during the cell‘s initialization.
在初始化方法中,设置文本(text label)和详细文本(detail text label)的背景颜色为[UIColor clearColor],并创建一个进度视图。有两个细节比较重要,首先,插入一个进度显示视图作为表单元的子视图,并置 index 为 0 使得进度显示位于 text label 之下;其次,我们调用updateView 方法确保进度显示视图及时更新反映progress 的值,在表单元初始化的时候设置progress 的值为 0.
- (id)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(NSString *)reuseIdentifier { self = [super initWithStyle:UITableViewCellStyleSubtitle reuseIdentifier:reuseIdentifier]; if (self) { // Helpers CGSize size = self.contentView.bounds.size; // Configure Labels [self.textLabel setBackgroundColor:[UIColor clearColor]]; [self.detailTextLabel setBackgroundColor:[UIColor clearColor]]; // Initialize Progress View self.progressView = [[UIView alloc] initWithFrame:CGRectMake(0.0, 0.0, size.width, size.height)]; // Configure Progress View [self.progressView setAutoresizingMask:(UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleWidth)]; [self.progressView setBackgroundColor:[UIColor colorWithRed:0.678 green:0.886 blue:0.557 alpha:1.0]]; [self.contentView insertSubview:self.progressView atIndex:0]; // Update View [self updateView]; } return self; }
Before we take a look at the implementation of updateView
,
we need to override the setter method of the progress
property.
The only change we make to the default implementation of setProgress:
is
invoke updateView
when the _progress
instance
variable is updated. This ensures that the progress view is updated whenever we update the cell‘s progress
property.
在实现 updateView 方法之前,我们需要实现 progress 属性的 setter 方法。和默认的 setProgress: 实现方法,唯一有所不同的是,当 _progress 实例变量更新时,调用 updateView 方法。这样当表单元的 属性发生更新变化时,进度显示视图可以及时更新显示。
- (void)setProgress:(CGFloat)progress { if (_progress != progress) { _progress = progress; // Update View [self updateView]; } }
In updateView
, we calculate the new width of the progress
view based on the value of the cell‘s progress
property.
在 updateView 方法中,我们基于表单元的 progress 属性计算进度显示视图的宽度值。
- (void)updateView {
// Helpers
CGSize size = self.contentView.bounds.size;
// Update Frame Progress View
CGRect frame = self.progressView.frame;
frame.size.width = size.width * self.progress;
self.progressView.frame = frame;
}
Step 5: Use MTEpisodeCell
To make use of the MTEpisodeCell
, we need
to make a few changes in theMTViewController
class. Start
by adding an import statement for MTEpisodeCell
.
为了在 MTViewController 类中使用MTEpisodeCell ,首先需要添加 import 语句。
#import "MTViewController.h" #import "MWFeedParser.h" #import "SVProgressHUD.h" #import "MTEpisodeCell.h" @interface MTViewController () <MWFeedParserDelegate> @property (strong, nonatomic) NSDictionary *podcast; @property (strong, nonatomic) NSMutableArray *episodes; @property (strong, nonatomic) MWFeedParser *feedParser; @end
In the view controller‘s viewDidLoad
method, invoke setupView
,
a helper method we‘ll implement next.
在
viewDidLoad
方法中,调用setupView 方法,这个方法接着会实现它。
- (void)viewDidLoad { [super viewDidLoad]; // Setup View [self setupView]; // Load Podcast [self loadPodcast]; // Add Observer [[NSUserDefaults standardUserDefaults] addObserver:self forKeyPath:@"MTPodcast" options:NSKeyValueObservingOptionNew context:NULL]; }
In setupView
, we invoke setupTableView
,
another helper method in which we tell the table view to use the MTEpisodeCell
class
whenever it needs a cell with a reuse identifier of EpisodeCell
.
在 setupView 方法中,调用 setupTableView 方法,这个方法通知 table view 使用 MTEpisodeCell 类创建 cell ,并使用 EpisodeCell 作为 identifier。
- (void)setupView { // Setup Table View [self setupTableView]; } - (void)setupTableView { // Register Class for Cell Reuse [self.tableView registerClass:[MTEpisodeCell class] forCellReuseIdentifier:EpisodeCell]; }
Before we build the project and run the application, we need to update our implementation of tableView:cellForRowAtIndexPath:
as
shown below.
在编译运行项目之前,还要更新实现 tableView:cellForRowAtIndexPath: 方法如下:
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath { MTEpisodeCell *cell = (MTEpisodeCell *)[tableView dequeueReusableCellWithIdentifier:EpisodeCell forIndexPath:indexPath]; // Fetch Feed Item MWFeedItem *feedItem = [self.episodes objectAtIndex:indexPath.row]; // Configure Table View Cell [cell.textLabel setText:feedItem.title]; [cell.detailTextLabel setText:[NSString stringWithFormat:@"%@", feedItem.date]]; return cell; }
Step 6: Build and Run
Run your application in the iOS Simulator or on a test device to see the result. If nothing has changed, then you‘ve followed the steps correctly. All that we‘ve done so far is replacing the prototype cells with instances of MTEpisodeCell
.
在模拟器或者真机上运行该程序查看结果,如果没有异常,则说明以上内容都顺利的完成了。诚然,所有的这些都只是将原型表单元替换为自定义的表单元 MTEpisodeCell 。
2. Create Background Session
To enable out-of-process downloads, we need a session that is configured to support out-of-process downloads. This is surprisingly easy to do with the NSURLSession
API.
There a few gotchas though.
为了实现 out-of-process 下载,我们需要一个会话对象,其配置是支持 out-of-process 下载的。对于使用 NSURLSession 的 API 这是很容易实现的,几步即可。
Step 1: Create session
Property
Start by declaring a new property session
of
type NSURLSession
in theMTViewController
class
and make the class conform to the NSURLSessionDelegate
andNSURLSessionDownloadDelegate
protocols.
在 MTViewController 中声明一个NSURLSession 属性 session ,还有使这个类遵循 NSURLSessionDelegate 和 NSURLSessionDownloadDelegate 协议。
#import "MTViewController.h" #import "MWFeedParser.h" #import "SVProgressHUD.h" #import "MTEpisodeCell.h" @interface MTViewController () <NSURLSessionDelegate, NSURLSessionDownloadDelegate, MWFeedParserDelegate> @property (strong, nonatomic) NSDictionary *podcast; @property (strong, nonatomic) NSMutableArray *episodes; @property (strong, nonatomic) MWFeedParser *feedParser; @property (strong, nonatomic) NSURLSession *session; @end
In viewDidLoad
, we set the session
property
by invoking backgroundSession
on the view controller instance.
This is one of the gotchas I was talking about.
在 viewDidLoad 方法中,调用 backgroundSession 方法设置 session 属性,下面将介绍这个方法:
- (void)viewDidLoad { [super viewDidLoad]; // Setup View [self setupView]; // Initialize Session [self setSession:[self backgroundSession]]; // Load Podcast [self loadPodcast]; // Add Observer [[NSUserDefaults standardUserDefaults] addObserver:self forKeyPath:@"MTPodcast" options:NSKeyValueObservingOptionNew context:NULL]; }
Let‘s take a look at the implementation of backgroundSession
.
In backgroundSession
, we statically declare a session
variable
and use dispatch_once
(Grand Central Dispatch) to instantiate
the background session. Even though this isn‘t strictly necessary, it emphasizes the fact that we only need one background session at any time. This is a best practice that‘s also mentioned in the WWDC
session on the NSURLSession
API.
下面看看 backgroundSession 方法的实现。在 backgroundSession 方法中,声明一个静态变量 session ,使用dispatch_once (GCD)实例化这个后台会话对象。虽然这并不是绝对需要的,但是在只需要一个后台会话对象的时候是有必要的这么处理的( dispatch_once)。这也是在WWDC 中提到的最佳实践。(原话:So, I do this inside of a dispatch once here to emphasize the fact that you should only be creating a session with a given identifier once.)
In the dispatch_once
block, we start by creating a NSURLSessionConfiguration
object
by invoking backgroundSessionConfiguration:
and passing
a string as an identifier. The identifier we pass uniquely identifies the background session, which is key as we‘ll see a bit later. We then create a session instance by invokingsessionWithConfiguration:delegate:delegateQueue:
and
passing the session configuration object, setting the session‘s delegate
property,
and passing nil
as the third argument.
在 dispatch_once block 中,通过调用 backgroundSessionConfiguration: 方法,传递一个字符串作为 identifier(标识符) 创建一个 NSURLSessionConfiguration 对象。这个标识符是唯一的。然后通过调用 sessionWithConfiguration:delegate:delegateQueue: 方法传递会话配置对象作为参数创建一个会话对象实例,同时设置会话对象的 delegate 属性为 self ,传递 nil 给第三个参数。
- (NSURLSession *)backgroundSession { static NSURLSession *session = nil; static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ // Session Configuration NSURLSessionConfiguration *sessionConfiguration = [NSURLSessionConfiguration backgroundSessionConfiguration:@"com.mobiletuts.Singlecast.BackgroundSession"]; // Initialize Session session = [NSURLSession sessionWithConfiguration:sessionConfiguration delegate:self delegateQueue:nil]; }); return session; }
nil
as
the third argument ofsessionWithConfiguration:delegate:delegateQueue:
,
the session creates a serial operation queue (串行操作队列)for us. This operation queue is used for performing the delegate method calls and completion handler calls.
3. Download Episode
Step 1: Create Download Task
It‘s time to make use of the background session we created and put the MTEpisodeCell
to
use. Let‘s start by implementing tableView:didSelectRowAtIndexPath:
,
a method of the UITableViewDelegate
protocol. Its implementation
is straightforward as you can see below. We fetch the correct MWFeedItem
instance
from the episodes
array and pass it todownloadEpisodeWithFeedItem:
.
是时候开始使用刚创建的后台会话对象和 MTEpisodeCell 类了。先实现 UITableViewDelegate 的委托方法:tableView:didSelectRowAtIndexPath: 。实现过程是简单的,从 episodes 数组中获取到对应的 MWFeedItem 实例对象,传递给downloadEpisodeWithFeedItem: 方法。
- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath { [tableView deselectRowAtIndexPath:indexPath animated:YES]; // Fetch Feed Item MWFeedItem *feedItem = [self.episodes objectAtIndex:indexPath.row]; // Download Episode with Feed Item [self downloadEpisodeWithFeedItem:feedItem]; }
In downloadEpisodeWithFeedItem:
, we extract the remote URL
from the feed item by invoking urlForFeedItem:
, create
a download task by calling downloadTaskWithURL:
on the
background session, and send it a message of resume
to
start the download task.
在 downloadEpisodeWithFeedItem: 方法中,调用 urlForFeedItem: 方法提取出 URL ,然后后台会话对象调用 downloadTaskWithURL: 方法创建一个下载任务,调用 resume 启动下载任务。
- (void)downloadEpisodeWithFeedItem:(MWFeedItem *)feedItem { // Extract URL for Feed Item NSURL *URL = [self urlForFeedItem:feedItem]; if (URL) { // Schedule Download Task [[self.session downloadTaskWithURL:URL] resume]; } }
As you may have guessed, urlForFeedItem:
is a convenience
method that we use. We‘ll use it a few more times in this project. We obtain a reference to the feed item‘senclosures
array,
extract the first enclosure, and pull out the object for the url
key.
We create and return an NSURL
instance.
正如你所猜测的那样,urlForFeedItem: 方法是一个辅助方法,我们已经在项目中多次使用到了。在该方法中,先获取到feed item的enclosures 数组,取出第一项,根据 key 值获取 value ,然后创建一个 NSURL 实例,并返回。
- (NSURL *)urlForFeedItem:(MWFeedItem *)feedItem { NSURL *result = nil; // Extract Enclosures NSArray *enclosures = [feedItem enclosures]; if (!enclosures || !enclosures.count) return result; NSDictionary *enclosure = [enclosures objectAtIndex:0]; NSString *urlString = [enclosure objectForKey:@"url"]; result = [NSURL URLWithString:urlString]; return result; }
We‘re not done yet. Is the compiler giving you three warnings? That‘s not surprising as we haven‘t implemented the required methods of the NSURLSessionDelegate
andNSURLSessionDownloadDelegate
protocols
yet. We also need to implement these methods if we want to show the progress of the download tasks.
是不是编译器给出三个警告吧,所以任务还没有完成。这并不奇怪,因为我们还没有实现 NSURLSessionDelegate 和 NSURLSessionDownloadDelegate 协议中的方法,所以为了显示下载任务的进度,需要实现这些方法。
Step 2: Implementing Protocol(s)
The first method we need to implement is URLSession:downloadTask:didResumeAtOffset:
.
This method is invoked if a download task is resumed. Because this is something we won‘t cover in this tutorial, we simply log a message to Xcode‘s console.
第一个需要实现的是 URLSession:downloadTask:didResumeAtOffset: 方法,下载任务重新启动的时候就会调用到该方法,这里只是简单的在Xcode终端中输出一个消息。
- (void)URLSession:(NSURLSession *)session downloadTask:(NSURLSessionDownloadTask *)downloadTask didResumeAtOffset:(int64_t)fileOffset expectedTotalBytes:(int64_t)expectedTotalBytes { NSLog(@"%s", __PRETTY_FUNCTION__); }
More interesting is the implementation ofURLSession:downloadTask:didWriteData:totalBytesWritten:totalBytesExpectedToWrite:
.
This method is invoked every time a few bytes have been downloaded by the session. In this delegate method, we calculate the progress, fetch the correct cell, and update the cell‘s progress property, which in turn updates the cell‘s progress view. Have you
spotted the dispatch_async
call? There‘s no guarantee that
the delegate method is invoked on the main thread. Since we update the user interface by setting the cell‘s progress, we need to update the cell‘s progress
property
on the main thread.
对于 URLSession:downloadTask:didWriteData:totalBytesWritten:totalBytesExpectedToWrite: 方法,当会话对象下载到数据时就会调用到。在这个委托方法中,先计算进度,获取到正确的表单元,更新这个表单元的进度属性,同时更新表单元的进度显示视图。注意到 dispatch_async 了吗?我们无法保证这个委托方法一定会在主线程中调用,但一旦该委托方法被调用到,那么就一定要对表单元的progress 属性在主线程中进行更新。
- (void)URLSession:(NSURLSession *)session downloadTask:(NSURLSessionDownloadTask *)downloadTask didWriteData:(int64_t)bytesWritten totalBytesWritten:(int64_t)totalBytesWritten totalBytesExpectedToWrite:(int64_t)totalBytesExpectedToWrite { // Calculate Progress double progress = (double)totalBytesWritten / (double)totalBytesExpectedToWrite; // Update Table View Cell MTEpisodeCell *cell = [self cellForForDownloadTask:downloadTask]; dispatch_async(dispatch_get_main_queue(), ^{ [cell setProgress:progress]; }); }
The implementation of cellForForDownloadTask:
is straightforward.
We pull the remote URL from the download task using its originalRequest
property
and loop over the feed items in the episodes
array until
we have a match. When we‘ve found a match, we ask the table view for the corresponding cell and return it.
cellForForDownloadTask: 方法的实现比较简单。根据 downloadTask 参数中的 originalRequest 属性提取到 URL。然后遍历 episodes 数组中的 feed item,从 table view 中找到一个匹配对应的 cell,并返回。
- (MTEpisodeCell *)cellForForDownloadTask:(NSURLSessionDownloadTask *)downloadTask { // Helpers MTEpisodeCell *cell = nil; NSURL *URL = [[downloadTask originalRequest] URL]; for (MWFeedItem *feedItem in self.episodes) { NSURL *feedItemURL = [self urlForFeedItem:feedItem]; if ([URL isEqual:feedItemURL]) { NSUInteger index = [self.episodes indexOfObject:feedItem]; cell = (MTEpisodeCell *)[self.tableView cellForRowAtIndexPath:[NSIndexPath indexPathForRow:index inSection:0]]; break; } } return cell; }
The third delegate method of the NSURLSessionDownloadDelegate
protocol
that we need to implement is URLSession:downloadTask:didFinishDownloadingToURL:
.
As I mentioned in the previous tutorials, one of the advantages of the NSURLSession
API
is that downloads are immediately written to disk. The result is that we are passed a local URL inURLSession:downloadTask:didFinishDownloadingToURL:
.
However, the local URL that we receive, points to a temporary file. It is our responsibility to move the file to a more permanent location and that‘s exactly what we do inURLSession:downloadTask:didFinishDownloadingToURL:
.
NSURLSessionDownloadDelegate 协议的第三个需要实现的是 URLSession:downloadTask:didFinishDownloadingToURL: 方法。正如我在前面教程中提到的, NSURLSession 的API 是将下载内容写入磁盘。其结果是,传递一个本地 URL 参数给 URLSession:downloadTask:didFinishDownloadingToURL: 方法,然后我们接收到的 URL 参数却是指向一个临时文件。所以在该方法中我们有必要将文件移到到一个持久化保存的固定位置。
- (void)URLSession:(NSURLSession *)session downloadTask:(NSURLSessionDownloadTask *)downloadTask didFinishDownloadingToURL:(NSURL *)location { // Write File to Disk [self moveFileWithURL:location downloadTask:downloadTask]; }
In moveFileWithURL:downloadTask:
, we extract the episode‘s
file name from the download task and create a URL in the application‘s Documents directory by invokingURLForEpisodeWithName:
.
If the temporary file that we received from the background session points to a valid file, we move that file to its new home in the application‘s Documents directory.
在 moveFileWithURL:downloadTask: 方法中,我们从下载任务中提取到情节(episode)文件名,通过调用URLForEpisodeWithName: 方法创建一个该应用程序 Documents 目录路径的 URL。如果从后台会话对象中接收到的临时文件是一个有效的文件,我们将该文件移动到应用程序的 Documents 目录下。
- (void)moveFileWithURL:(NSURL *)URL downloadTask:(NSURLSessionDownloadTask *)downloadTask { // Filename NSString *fileName = [[[downloadTask originalRequest] URL] lastPathComponent]; // Local URL NSURL *localURL = [self URLForEpisodeWithName:fileName]; NSFileManager *fm = [NSFileManager defaultManager]; if ([fm fileExistsAtPath:[URL path]]) { NSError *error = nil; [fm moveItemAtURL:URL toURL:localURL error:&error]; if (error) { NSLog(@"Unable to move temporary file to destination. %@, %@", error, error.userInfo); } } }
URLForEpisodeWithName:
is another helper method, which invokes episodesDirectory
.
InURLForEpisodeWithName:
, we append the name
argument
to the Episodes directory, which is located in the application‘s Documents directory.
在 URLForEpisodeWithName: 方法中,调用 episodesDirectory 方法,将 name 参数追加到情节文件目录,它位于应用程序的 Documents 目录。
- (NSURL *)URLForEpisodeWithName:(NSString *)name { if (!name) return nil; return [self.episodesDirectory URLByAppendingPathComponent:name]; }
In episodesDirectory
, we create the URL for the Episodes
directory and create the directory if it doesn‘t exist yet.
在 episodesDirectory 方法中,创建一个指向Episodes 目录的URL,并返回。
- (NSURL *)episodesDirectory {
NSURL *documents = [[[NSFileManager defaultManager] URLsForDirectory:NSDocumentDirectory inDomains:NSUserDomainMask] lastObject];
NSURL *episodes = [documents URLByAppendingPathComponent:@"Episodes"];
NSFileManager *fm = [NSFileManager defaultManager];
if (![fm fileExistsAtPath:[episodes path]]) {
NSError *error = nil;
[fm createDirectoryAtURL:episodes withIntermediateDirectories:YES attributes:nil error:&error];
if (error) {
NSLog(@"Unable to create episodes directory. %@, %@", error, error.userInfo);
}
}
return episodes;
}
Step 3: Build and Run
Run the application and test the result by downloading an episode from the list of episodes. You should see the table view cell‘s progress view progress from left to right reflecting the progress of the download task. There are a few issues though. Have you tried scrolling through the table view? That doesn‘t look right. Let‘s fix that.
运行程序,从 episodes 列表中选择一个 episode 下载进行测试。你应该可以看到选中的表视图单元的进度显示从左往右,反映出下载任务的进度。但还有几个问题,试一下滚动表视图,这看起来好像不对吧,接下来解决它吧!
4. Create a Progress Buffer
Because the table view reuses cells as much as possible, we need to make sure that each cell properly reflects the download state of the episode that it represents. We can fix this in several ways. One approach is to use an object that keeps track of the progress of each download task, including the download tasks that have already completed.
由于table view中存在表单元的复用机制,而我们需要每一个表单元都显示各自代表的 episode 文件下载状态。有几种方式解决。一种解决方式是使用一个对象来保存每一个下载任务的进度,包括已经下载完毕的任务。
Step 1: Declare a Property
Let‘s start by declaring a new private property progressBuffer
of
typeNSMutableDictionary
in the MTViewController
class.
在 MTViewController 类中声明一个 NSMutableDictionary 类型的私有属性 progressBuffer 。
#import "MTViewController.h" #import "MWFeedParser.h" #import "SVProgressHUD.h" #import "MTEpisodeCell.h" @interface MTViewController () <NSURLSessionDelegate, NSURLSessionDownloadDelegate, MWFeedParserDelegate> @property (strong, nonatomic) NSDictionary *podcast; @property (strong, nonatomic) NSMutableArray *episodes; @property (strong, nonatomic) MWFeedParser *feedParser; @property (strong, nonatomic) NSURLSession *session; @property (strong, nonatomic) NSMutableDictionary *progressBuffer; @end
Step 2: Initialize Buffer
In viewDidLoad
, we initialize the progress
buffer as shown below.
在 viewDidLoad 方法中,初始化 progress buffer 这个对象。
- (void)viewDidLoad { [super viewDidLoad]; // Setup View [self setupView]; // Initialize Session [self setSession:[self backgroundSession]]; // Initialize Progress Buffer [self setProgressBuffer:[NSMutableDictionary dictionary]]; // Load Podcast [self loadPodcast]; // Add Observer [[NSUserDefaults standardUserDefaults] addObserver:self forKeyPath:@"MTPodcast" options:NSKeyValueObservingOptionNew context:NULL]; }
Step 3: Update Table View Cells
The key that we‘ll use in the dictionary is the remote URL of the corresponding feed item. With this in mind, we can update the tableView:cellForRowAtIndexPath:
method
as shown below. We pull the remote URL from the feed item and ask progressBuffer
for
the value for the key that corresponds to the remote URL. If the value isn‘t nil
,
we set the cell‘s progress
property to that value, otherwise
we set the progress
property of the cell to 0.0
,
which hides the progress view by setting its width to 0.0
.
字典中的 key 值是每一 feed item对应的下载链接地址 URL。考虑到这一点,我们就可以在 tableView:cellForRowAtIndexPath: 方法中进行如下的修改。先从 feed item 中获取到下载链接地址 URL,然后对应的从 progressBuffer 字典中获取到 value 值,也即是下载的进度。如果这个value值不是 nil,那么就将表单元 progress 属性设置为该 value 值;否则将其置为 0.0,这样进度条显示宽带为 0.0。
- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath { MTEpisodeCell *cell = (MTEpisodeCell *)[tableView dequeueReusableCellWithIdentifier:EpisodeCell forIndexPath:indexPath]; // Fetch Feed Item MWFeedItem *feedItem = [self.episodes objectAtIndex:indexPath.row]; NSURL *URL = [self urlForFeedItem:feedItem]; // Configure Table View Cell [cell.textLabel setText:feedItem.title]; [cell.detailTextLabel setText:[NSString stringWithFormat:@"%@", feedItem.date]]; NSNumber *progress = [self.progressBuffer objectForKey:[URL absoluteString]]; if (!progress) progress = @(0.0); [cell setProgress:[progress floatValue]]; return cell; }
Step 4: Avoid Duplicates
We can also use the progress buffer to prevent users from downloading the same episode twice. Take a look at the updated implementation oftableView:didSelectRowAtIndexPath:
.
We take the same steps we took intableView:cellForRowAtIndexPath:
to
extract the progress value from the progress buffer. Only when the progress value is nil
,
we download the episode.
我们根据 progress buffer 还可以阻止用户对同一 episode 文件下载两次。更新 tableView:didSelectRowAtIndexPath: 方法如下。如 方法中那样从 progress buffer 中获取到某一表单元cell的进度的值,只有进度值为 nil,我们才需要进行下载。
- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath { [tableView deselectRowAtIndexPath:indexPath animated:YES]; // Fetch Feed Item MWFeedItem *feedItem = [self.episodes objectAtIndex:indexPath.row]; // URL for Feed Item NSURL *URL = [self urlForFeedItem:feedItem]; if (![self.progressBuffer objectForKey:[URL absoluteString]]) { // Download Episode with Feed Item [self downloadEpisodeWithFeedItem:feedItem]; } }
Step 5: Update Buffer
The progress buffer only works in its current implementation if we keep it up to date. This means that we need to update theURLSession:downloadTask:didWriteData:totalBytesWritten:totalBytesExpectedToWrite:
method
as well. All we do is store the new progress value in the progress buffer.
progress buffer 只有在以下方法执行的时候才进行更新,这就意味着需要对 URLSession:downloadTask:didWriteData:totalBytesWritten:totalBytesExpectedToWrite: 方法进行修改如下。我们只是将新的下载进度值保存到 progress buffer中。
- (void)URLSession:(NSURLSession *)session downloadTask:(NSURLSessionDownloadTask *)downloadTask didWriteData:(int64_t)bytesWritten totalBytesWritten:(int64_t)totalBytesWritten totalBytesExpectedToWrite:(int64_t)totalBytesExpectedToWrite { // Calculate Progress double progress = (double)totalBytesWritten / (double)totalBytesExpectedToWrite; // Update Progress Buffer NSURL *URL = [[downloadTask originalRequest] URL]; [self.progressBuffer setObject:@(progress) forKey:[URL absoluteString]]; // Update Table View Cell MTEpisodeCell *cell = [self cellForForDownloadTask:downloadTask]; dispatch_async(dispatch_get_main_queue(), ^{ [cell setProgress:progress]; }); }
In downloadEpisodeWithFeedItem:
, we set the progress value
to 0.0
when the download task starts.
在 downloadEpisodeWithFeedItem: 方法中,下载任务启动的时候,设置其进度值为 0.0
- (void)downloadEpisodeWithFeedItem:(MWFeedItem *)feedItem { // Extract URL for Feed Item NSURL *URL = [self urlForFeedItem:feedItem]; if (URL) { // Schedule Download Task [[self.session downloadTaskWithURL:URL] resume]; // Update Progress Buffer [self.progressBuffer setObject:@(0.0) forKey:[URL absoluteString]]; } }
The session delegate is notified when a download task finishes. InURLSession:downloadTask:didFinishDownloadingToURL:
,
we set the progress value to 1.0
.
当下载任务结束的时候,会通知会话的委托方法,我们在 URLSession:downloadTask:didFinishDownloadingToURL: 委托方法中设置下载任务的进度值为 1.0。
- (void)URLSession:(NSURLSession *)session downloadTask:(NSURLSessionDownloadTask *)downloadTask didFinishDownloadingToURL:(NSURL *)location { // Write File to Disk [self moveFileWithURL:location downloadTask:downloadTask]; // Update Progress Buffer NSURL *URL = [[downloadTask originalRequest] URL]; [self.progressBuffer setObject:@(1.0) forKey:[URL absoluteString]]; }
Step 6: Restore Buffer
At the moment, the progress buffer is only stored in memory, which means that it‘s cleared between application launches. We could write its contents to disk, but to keep this application simple we are going to restore or recreate the buffer by checking which
episodes have already been downloaded. The feedParser:didParseFeedItem:
method,
part of the MWFeedParserDelegate
protocol, is invoked for
every item in the feed. In this method, we pull the remote URL from the feed item, create the corresponding local URL, and check if the file exists. If it does, then we set the corresponding progress value for that feed item to 1.0
to
indicate that it‘s already been downloaded.
目前,progress buffer 对象只是保存在内存中,这就意味着应用程序退出的时候就会被清除。我们可以将其写入磁盘中持久保存,但为了保持应用程序的简洁,我们可以通过检查episodes 已经下载的进度,然后重新创建恢复 progress buffer。 MWFeedParserDelegate 协议的feedParser:didParseFeedItem: 方法,feed 中的每一个 item 都会调用这个方法。在这个方法中,从 item 中提取到下载链接remote URL ,然后得到对应的本地保存路径 local URL,检查下载文件是否存在。如果存在,则设置对应的下载进度值为 1.0 ,表明已经下载完毕了。
- (void)feedParser:(MWFeedParser *)parser didParseFeedItem:(MWFeedItem *)item { if (!self.episodes) { self.episodes = [NSMutableArray array]; } [self.episodes addObject:item]; // Update Progress Buffer NSURL *URL = [self urlForFeedItem:item]; NSURL *localURL = [self URLForEpisodeWithName:[URL lastPathComponent]]; if ([[NSFileManager defaultManager] fileExistsAtPath:[localURL path]]) { [self.progressBuffer setObject:@(1.0) forKey:[URL absoluteString]]; } }
Step 7: Rinse and Repeat
Run the application one more time to see if the issues with the table view are resolved. The application should now also remember which episodes have already been downloaded.
再一次运行应用程序,看看表视图有关的问题是否都解决了。现在应用程序应该知道哪些已经下载了。
5. Being a Good Citizen
It‘s important that our application is a good citizen by not wasting more CPU cycles or consume more battery power than needed. What does this mean for our podcast client. When a download task is started by our application and the application goes to the background, the background daemon that manages our application‘s download task notifies our application through the background session that the download task has finished. If necessary, the background daemon will launch our application so that it can respond to these notifications and process the downloaded file.
应该好的应用程序应该充分利用CPU的运行周期,并且不消耗更多电量。对于我们的播客客户端,启动一个下载任务,如果应用程序退到后台,那么后台的守护进程就管理这个应用程序的下载任务,而且当后台会话的下载任务完成下载,就会通知应用程序,如果有必要,后台守护进程将会启动应用程序,以便其可以处理下载的文件。
In our example, we don‘t need to do anything special to make sure that our application reconnects to the original background session. This is taken care of by theMTViewController
instance.
However, we do have to notify the operating system when our application has finished processing the download(s) by invoking a background completion handler.
在这个例子中,我们不需要对应用程序做额外特殊的处理使得应用程序和后台会话重新连接。但是,当下载完成后,我们也要通过调用完成处理程序块通知操作系统该应用程序的下载任务完成了。
When our application is woken up by the operating system to respond to the notifications of the background session, the application delegate is sent a message ofapplication:handleEventsForBackgroundURLSession:completionHandler:
.
In this method, we can reconnect to the background session, if necessary, and invoke the completion handler that is passed to us. By invoking the completion handler, the operating system knows that our application no longer needs to run in the background.
This is important for optimizing battery life. How do we do this in practice?
当应用程序被操作系统唤醒,对后台会话通知做出响应时,应用程序会调用 application:handleEventsForBackgroundURLSession:completionHandler: 委托方法。在这个方法中,应用程序和后台会话对象重新建立连接,如果有必要,也会执行传递参数:完成处理程序块。通过调用完成处理程序块,操作系统就知道,应用程序不再需要在后台继续运行了,这对于优化电池寿命是恒友好处的。如何将这一点付诸实践呢?
Step 1: Declare a Property
We first need to declare a property on the MTAppDelegate
class
to keep a reference to the completion handler that we get fromapplication:handleEventsForBackgroundURLSession:completionHandler:
.
The property needs to be public. The reason for this will become clear in a moment.
首先需要在 MTAppDelegate 类中声明一个公有属性:完成处理程序块(completion handler),保存在application:handleEventsForBackgroundURLSession:completionHandler: 委托方法中的参数。这么做的原因后面就知道了。
#import <UIKit/UIKit.h> @interface MTAppDelegate : UIResponder <UIApplicationDelegate> @property (strong, nonatomic) UIWindow *window; @property (copy, nonatomic) void (^backgroundSessionCompletionHandler)(); @end
Step 2: Implement Callback
In application:handleEventsForBackgroundURLSession:completionHandler:
,
we store the completion handler in backgroundSessionCompletionHandler
,
which we declared a moment ago.
在 application:handleEventsForBackgroundURLSession:completionHandler: 方法中,保存 backgroundSessionCompletionHandler 。
- (void)application:(UIApplication *)application handleEventsForBackgroundURLSession:(NSString *)identifier completionHandler:(void (^)())completionHandler { [self setBackgroundSessionCompletionHandler:completionHandler]; }
Step 3: Invoke Background Completion Handler
In the MTViewController
class, we start
by adding an import statement for theMTAppDelegate
class.
在 MTViewController 类中,添加 MTAppDelegate 类的 import 语句。
#import "MTViewController.h" #import "MWFeedParser.h" #import "MTAppDelegate.h" #import "SVProgressHUD.h" #import "MTEpisodeCell.h" @interface MTViewController () <NSURLSessionDelegate, NSURLSessionDownloadDelegate, MWFeedParserDelegate> @property (strong, nonatomic) NSDictionary *podcast; @property (strong, nonatomic) NSMutableArray *episodes; @property (strong, nonatomic) MWFeedParser *feedParser; @property (strong, nonatomic) NSURLSession *session; @property (strong, nonatomic) NSMutableDictionary *progressBuffer; @end
We then implement another helper method, invokeBackgroundSessionCompletionHandler
,
which invokes the background completion handler stored in the application delegate‘sbackgroundSessionCompletionHandler
property.
In this method, we ask the background session for all its running tasks. If there are no tasks running, we get a reference to the application delegate‘s background completion handler and, if it isn‘t nil
,
we invoke it and set it to nil
.
接着实现 invokeBackgroundSessionCompletionHandler 方法,这个方法会调用保存着应用程序委托的 backgroundSessionCompletionHandler 属性。在这个方法中,我们查看后台会话是否有任务在运行,如果没有且不为 nil,则将其置为 nil。
- (void)invokeBackgroundSessionCompletionHandler { [self.session getTasksWithCompletionHandler:^(NSArray *dataTasks, NSArray *uploadTasks, NSArray *downloadTasks) { NSUInteger count = [dataTasks count] + [uploadTasks count] + [downloadTasks count]; if (!count) { MTAppDelegate *applicationDelegate = (MTAppDelegate *)[[UIApplication sharedApplication] delegate]; void (^backgroundSessionCompletionHandler)() = [applicationDelegate backgroundSessionCompletionHandler]; if (backgroundSessionCompletionHandler) { [applicationDelegate setBackgroundSessionCompletionHandler:nil]; backgroundSessionCompletionHandler(); } } }]; }
Wait a minute. When do we invoke invokeBackgroundSessionCompletionHandler
?
We do this every time a download task finishes. In other words, we invoke this method inURLSession:downloadTask:didFinishDownloadingToURL:
as
shown below.
那么什么时候调用 invokeBackgroundSessionCompletionHandler 方法呢?我们在每一次下载任务完成的时候进行调用。换言之,在 URLSession:downloadTask:didFinishDownloadingToURL: 方法中进行调用,如下:
- (void)URLSession:(NSURLSession *)session downloadTask:(NSURLSessionDownloadTask *)downloadTask didFinishDownloadingToURL:(NSURL *)location { // Write File to Disk [self moveFileWithURL:location downloadTask:downloadTask]; // Update Progress Buffer NSURL *URL = [[downloadTask originalRequest] URL]; [self.progressBuffer setObject:@(1.0) forKey:[URL absoluteString]]; // Invoke Background Completion Handler [self invokeBackgroundSessionCompletionHandler]; }
6. Wrapping Up
I hope you agree that our podcast client isn‘t ready for the App Store just yet since one of the key features, playing podcasts, is still missing. As I mentioned in the previous tutorial, the focus of this project wasn‘t creating a full-featured podcast client.
The goal of this project was illustrating how to leverage the NSURLSession
API
to search the iTunes Search API and download podcast episodes using data and out-of-process download tasks respectively. You should now have a basic understanding of theNSURLSession
API
as well as out-of-process tasks.
你应该同意,现在我们实现的播客客户端还不能上 App Store,因为只实现了一个功能,播放功能还没有实现。正如我在前面教程中所提到的,我们关注的重点不是实现一个完整功能的播客客户端。我们的目标是通过这个项目展示如何利用 NSURLSession 的data task 对 iTunes 的 API 进行查询;out-of-process download task 下载播客节目。现在你应该对此有一个基本的了解了吧!
Conclusion
By creating a simple podcast client, we have taken a close look at data and download tasks. We‘ve also learned how easy it is to schedule download tasks in the background. The NSURLSession
API
is an important step forward for both iOS and OS X, and I encourage you to take advantage of this easy to use and flexible suite of classes. In the final installment of this series, I will take a look at AFNetworking 2.0. Why is it a milestone release? When
should you use it? And how does it compare to theNSURLSession
API?
通过创建一个简单的播客客户端,我们已经对数据任务和下载任务有了一定的了解。我们也了解到安排下载任务在后台执行也是简单的。NSURLSession 是 iOS 和 OS X开发重要的一个内容,我建议你根据其优势在开发中进行使用。最后,我会看看 AFNetworking
2.0 ,它是一个里程碑意义的版本,什么时候可以使用它?它和 NSURLSession
API
比较有怎样?