iOS MultiTasking再认识(下篇)


Remote Notification能力测试与验证

  • 以content-available为主,对payload的设置对push影响进行测试

    case1 不设置content-available,应用只能进入前台后才能处理push消息,无异于iOS7之前的Noraml Push.

    case2 设置content-availabel,同时设置其他项,如下:

         aps{
              alert:"My first push notification",
              badge: 1,
              sound: "default",
              content-available: 1
          }
    

    有Remote Notification的通知提示。

    case3 设置payload,发送silent Remote Notification,如下:

         aps{
              alert:"My first push notification",
              content-available: 1
          }
    

    没有来自系统的通知提示。

  • 以case3为基础进行Remote Notification的能力测试与验证,通过Charles模拟不同的网络环境,控制后台处理时间

    case1 应用处于前台时,发送Silent Remote Notification.此时会当作一般的前台任务处理。一旦应用退到后台,或者设备锁屏,任务马上结束。case2 应用处于后台时,发送Silent Remote Notification.应用在后台被唤醒,开始处理下载任务。30s后,无论下载是否结束,应用被挂起。 如果没有调用fetchCompletionHandler通知系统的话,超过了30s,系统会终止应用。 期间,锁屏,任务马上结束。 case3 应用Cransh等Not-Running的状态,发送Silent Remote Notification.应用在后台启动,开始处理下载任务。结果同上case4 应用Force-quit后,发送Silent Remote Notification.应用不会在后台被重新启动。

  • 最后,关于Remote Notification做一下总结:Remote Notification会唤醒后台应用(或在后台重启应用),并最多分配30s的时间处理notification,如下载数据,更新UI等,其间任务的执行并不受系统休眠等影响。如果超过30s的时间,系统会将应用挂起。fetchCompletionHandler是用来通知系统,本地处理已经结束,系统可以更新界面快照并将应用挂起。但是,若系统没有及时收到此通知的话,应用可能会被kill掉。所以,仍然强调的是,Remote Notification多用于:IM,Email更新,RSS内容同步更新等,如果内容更新涉及下载较大文件的话,需要结合Background Transfer Service

Background Transfer Service

Background Transfer Service是iOS7系统以来,作为多任务变革大招中最后一式,既可以作为一个很好的方式独立应用于项目中,也可以与Background Task、Background Fetch、Remote Notification进行完美结合,更好地服务于你的需求。

后台传输服务是基于Background Session来实现的,我们通过backgroundSessionConfiguration创建的session,实际是系统另起了一个后台进程来处理传输任务。所以,当应用退到后台或者是应用崩溃等终止运行了,并不影响后台传输进程正常运行。

当基于此background session的所有任务都完成时(成功or失败),系统都会唤醒后台应用(如果应用是退到后台)或者在后台重启应用(如果应用终止运行了)。并且会触发Application的代理方法:application:handleEventsForBackgroundURLSession:completionHandler,系统通过此代理方法,告知我们:

  • 唤醒应用的后台session的Identifier
  • 在与此session以及与session相关的任务处理结束后,需要通过系统的回调。

当与此session相关的所有消息都发送完毕后,应用会收到URLSessionDidFinishEventsForBackgroundURLSession消息。我们需要在方法中完成一些诸如界面更新等的工作,并且调用上述completionHanlder,通知系统应用可以进入休眠状态。

接下来,我们直接根据一个Demo APP,来了解如何使用NSURLSession API实现后台传输服务,包括以下几步:

  • 如何开始下载进程
  • 应用在前台运行时,如何追踪下载状态等信息
  • 应用退到后台或不在运行时,后台下载结束,系统如何与应用交互,更新下载状态及UI
  • 如果应用重启时,后台session还未结束,如何处理?(因为可能会出现同一个identifier对应两个session对象)

后台传输的实现

下面是我们Demo App的截图,主要结合NSURLSession和NSURLSessionDownlaodTask实现简单的支持后台下载的文件下载管理。

为了实现简单的文件下载管理功能,我们需要对下载信息进行持久化,这里通过Sqlite来实现。定义下载信息数据结构如下:

@interface FileDownloadInfo : NSObject

@property (nonatomic, strong) NSString *fileTitle;

@property (nonatomic, strong) NSString *downloadSource;

@property (nonatomic, strong) NSURLSessionDownloadTask *downloadTask;

@property (nonatomic, strong) NSData *taskResumeData;

@property (nonatomic) double downloadProgress;

@property (nonatomic) HTFileTransferState status;

@property (nonatomic) unsigned long taskIdentifier;

@end

前期工作准备好后,我们主要实现以下步骤:

  • 创建一个Background Session对象
  • 初始化下载任务列表,创建task,并通过resume方法开始下载
  • 获取文件下载进度,并持久化下载进度信息

      - (void)URLSession:(NSURLSession *)session downloadTask:(NSURLSessionDownloadTask *)downloadTask didWriteData:(int64_t)bytesWritten totalBytesWritten:(int64_t)totalBytesWritten totalBytesExpectedToWrite:(int64_t)totalBytesExpectedToWrite
    
  • 处理文件下载结束的相关工作:

      - (void)URLSession:(NSURLSession *)session downloadTask:(NSURLSessionDownloadTask *)downloadTask
                     didFinishDownloadingToURL:(NSURL *)location;
      - (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task
                                 didCompleteWithError:(NSError *)error;
    
  • 实现AppDelegate的代理方法application:handleEventsForBackgroundURLSession:completionHandler:,

  • 实现URLSession的代理方法URLSessionDidFinishEventsForBackgroundURLSession:,在此调用前一步骤中的completionHandler,通知系统此background session的所有任务完毕,App可以进入休眠状态

下面我们进入关键代码的演示与分析

//创建后台session对象
-(NSURLSession *)backgroundSession
{
    static NSURLSession * session = nil;
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        NSURLSessionConfiguration *sessionConfiguration = [NSURLSessionConfiguration backgroundSessionConfiguration:@"com.BGTransferDemo"];
        sessionConfiguration.HTTPMaximumConnectionsPerHost = 5;

        session = [NSURLSession sessionWithConfiguration:sessionConfiguration
                                                delegate:self
                                           delegateQueue:nil];
    });

    return session;
}

// 初始化下载任务列表,需要与DB里面的下载对象做merge
-(void)initializeFileDownloadDataArray{
    self.arrFileDownloadData = [[NSMutableArray alloc] init];

    [self.arrFileDownloadData addObject:[[FileDownloadInfo alloc] initWithFileTitle:@"iOS Programming Guide" andDownloadSource:@"https://developer.apple.com/library/ios/documentation/iphone/conceptual/iphoneosprogrammingguide/iphoneappprogrammingguide.pdf"]];

    [self.arrFileDownloadData addObject:[[FileDownloadInfo alloc] initWithFileTitle:@"xmind-7" andDownloadSource:@"http://xmind-dl.oss-cn-qingdao.aliyuncs.com/xmind-7-update1-macosx.dmg"]];

    NSArray<FileDownloadInfo *> *itemsFromDB = [_databaseManager allDownloadItems];
    for (int i = 0; i < _arrFileDownloadData.count; i++) {
        NSUInteger foundDownloadItemIndex = [itemsFromDB indexOfObjectPassingTest:^BOOL(FileDownloadInfo * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
            FileDownloadInfo * item = _arrFileDownloadData[i];
            if ([obj.fileTitle isEqualToString:item.fileTitle]) {
                return YES;
            }
            return NO;
        }];

        if (foundDownloadItemIndex != NSNotFound) {

            FileDownloadInfo * itemFromDB = [itemsFromDB objectAtIndex:foundDownloadItemIndex];
            _arrFileDownloadData[i].downloadProgress = itemFromDB.downloadProgress;
            _arrFileDownloadData[i].taskResumeData = itemFromDB.taskResumeData;
            _arrFileDownloadData[i].status = itemFromDB.status;
            _arrFileDownloadData[i].taskIdentifier = itemFromDB.taskIdentifier;

            if (itemFromDB.status == HTFileTransferStateTransfering) {
                _arrFileDownloadData[i].isDownloading = YES;
            }
            if (itemFromDB.status == HTFileTransferStateDone) {
                _arrFileDownloadData[i].downloadComplete = YES;
            }
        }
    }
}

省略了构建tableview的细节,直接进入下一步

//启动所有下载任务,并更新数据库
- (IBAction)startAllDownloads:(id)sender {
    // Access all FileDownloadInfo objects using a loop.
    for (int i=0; i<[self.arrFileDownloadData count]; i++) {
        FileDownloadInfo *fdi = [self.arrFileDownloadData objectAtIndex:i];

        // Check if a file is already being downloaded or not.
        if (fdi.downloadComplete) {
            continue;
        }
        if (!fdi.isDownloading) {
            // Check if should create a new download task using a URL, or using resume data.
            if (fdi.taskIdentifier == -1) {
                fdi.downloadTask = [self.session downloadTaskWithURL:[NSURL URLWithString:fdi.downloadSource]];
            }
            else{
                fdi.downloadTask = [self.session downloadTaskWithResumeData:fdi.taskResumeData];
            }

            // Keep the new taskIdentifier.
            fdi.taskIdentifier = fdi.downloadTask.taskIdentifier;
            fdi.status = HTFileTransferStateTransfering;
            [_databaseManager updateOrInsertDownloadItem:fdi];
            // Start the download.
            [fdi.downloadTask resume];

            // Indicate for each file that is being downloaded.
            fdi.isDownloading = YES;
        }
        else{

        }
    }

    // Reload the table view.
    [self.tblFiles reloadData];
}

实现相关Delegate方法

//此代理方法是必须实现的。文件下载完成后,将文件从临时目录写入定制化的其他目录,并更新数据库中的下载信息
-(void)URLSession:(NSURLSession *)session downloadTask:(NSURLSessionDownloadTask *)downloadTask didFinishDownloadingToURL:(NSURL *)location{

    NSError *error;
    NSFileManager *fileManager = [NSFileManager defaultManager];

    NSString *destinationFilename = downloadTask.originalRequest.URL.lastPathComponent;
    NSURL *destinationURL = [self.docDirectoryURL URLByAppendingPathComponent:destinationFilename];

    if ([fileManager fileExistsAtPath:[destinationURL path]]) {
        [fileManager removeItemAtURL:destinationURL error:nil];
    }

    BOOL success = [fileManager copyItemAtURL:location
                                        toURL:destinationURL
                                        error:&error];

    if (success) {
        int index = [self getFileDownloadInfoIndexWithTaskIdentifier:downloadTask.taskIdentifier];
        FileDownloadInfo *fdi = [self.arrFileDownloadData objectAtIndex:index];

        fdi.isDownloading = NO;
        fdi.downloadComplete = YES;

        fdi.status = HTFileTransferStateDone;

        fdi.taskResumeData = nil;
        [_databaseManager updateDownloadItem:fdi];

        [[NSOperationQueue mainQueue] addOperationWithBlock:^{
            // Reload the respective table view row using the main thread.
            [self.tblFiles reloadRowsAtIndexPaths:@[[NSIndexPath indexPathForRow:index inSection:0]]
                                 withRowAnimation:UITableViewRowAnimationNone];

        }];

    }
    else{
        NSLog(@"Unable to copy temp file. Error: %@", [error localizedDescription]);
    }
}

// 实现此代理方法,获取文件下载的进度信息,同时将其更新到DB中
-(void)URLSession:(NSURLSession *)session downloadTask:(NSURLSessionDownloadTask *)downloadTask didWriteData:(int64_t)bytesWritten totalBytesWritten:(int64_t)totalBytesWritten totalBytesExpectedToWrite:(int64_t)totalBytesExpectedToWrite{

    if (totalBytesExpectedToWrite == NSURLSessionTransferSizeUnknown) {
        NSLog(@"Unknown transfer size");
    }
    else{
        // Locate the FileDownloadInfo object among all based on the taskIdentifier property of the task.
        int index = [self getFileDownloadInfoIndexWithTaskIdentifier:downloadTask.taskIdentifier];
        FileDownloadInfo *fdi = [self.arrFileDownloadData objectAtIndex:index];

        fdi.downloadProgress = (double)totalBytesWritten / (double)totalBytesExpectedToWrite;

        [[NSOperationQueue mainQueue] addOperationWithBlock:^{

            UITableViewCell *cell = [self.tblFiles cellForRowAtIndexPath:[NSIndexPath indexPathForRow:index inSection:0]];
            UIProgressView *progressView = (UIProgressView *)[cell viewWithTag:CellProgressBarTagValue];
            progressView.progress = fdi.downloadProgress;
        }];

        NSDate * currentDate = [NSDate date];
        NSTimeInterval time = [currentDate timeIntervalSinceDate:_lastDate];
        if (time >= 1 || totalBytesWritten == totalBytesExpectedToWrite){
            _lastDate = currentDate;
            [_databaseManager updateDownloadItem:fdi];

            NSLog(@"[HTFileDownloader]: Progress update: %f)", fdi.downloadProgress);
        }
    }
}

处理后台下载

当应用不在前台或未启动,但是后台传输在进行时,每一次后台传输进程有消息到来,都会调用 application:handleEventsForBackgroundURLSession:completionHandler:,来唤醒应用。 此方法有两个参数:

  • identifier:是对应background session的id,用来关联这些任务的session。如果在所有的后台任务完成时,App不是running的状态,那么系统在后台重新启动App后,需要通过此id创建background session对象,才能接收与此session相关的代理事件。如果App从后台状态被唤醒,则无须重新创建session对象,任何代理事件会直接关联到这个已经存在的session上。
  • completionHandler:需通过此block通知系统更新页面快照,app可以再次进入休眠状态。completionHandler必须在此方法中被保存一份,并在所有downloads相关工作处理完成之后被调用,以通知系统可以应用可以再次进入休眠状态。

//此处只对completionHandler进入一次copy,backgroundSession的创建在应用启动初始化的工作中一并处理
-(void)application:(U
IApplication *)application handleEventsForBackgroundURLSession:(NSString *)identifier completionHandler:(void (^)())completionHandler{

    self.backgroundTransferCompletionHandler = completionHandler;

}

当系统已经没有其他的messages通知给应用时,NSURLSession的代理方法URLSessionDidFinishEvensForBackgroundURLSession:会被调用。所以我们需要实现此方法,并调用上述completionHandler

-(void)URLSessionDidFinishEventsForBackgroundURLSession:(NSURLSession *)session{
    AppDelegate *appDelegate = [UIApplication sharedApplication].delegate;

    // Check if all download tasks have been finished.
    [self.session getTasksWithCompletionHandler:^(NSArray *dataTasks, NSArray *uploadTasks, NSArray *downloadTasks) {

        if ([downloadTasks count] == 0) {
            if (appDelegate.backgroundTransferCompletionHandler != nil) {
                // Copy locally the completion handler.
                void(^completionHandler)() = appDelegate.backgroundTransferCompletionHandler;

                // Make nil the backgroundTransferCompletionHandler.
                appDelegate.backgroundTransferCompletionHandler = nil;

                [[NSOperationQueue mainQueue] addOperationWithBlock:^{
                    // Call the completion handler to tell the system that there are no other background transfers.
                    completionHandler();

            }
        }
    }];
}

继续戳->完整的Demo源码

Background Transfer Service能力测试与验证

这里列举几种case,通过Charles抓包分析,对后台传输服务的能力进行简单总结。

case1 应用处于前台的时候,进行下载

case2 下载过程中,应用退到后台

下载不受限地在后台继续进行。在下载完成之前,应用切换到前台,下载界面保持最新的状态,直至下载结束; 在所有下载完成后,App Switcher中页面快照被更新。进入前台,下载界面被更新,下载完成。

case3 下载过程中,应用crash而终止,下载在后台继续进行

在下载完成之前,应用启动进入前台,下载界面被更新,下载在前台正常进行,直至结束 在下载完成之后,App Switcher中页面快照被更新。应用启动进入前台,下载界面被更新,下载完成.

case4 下载过程中,应用被强制退出,下载立即停止

So,不同于前面几种后台模式,Background Transfer Service通过独立的后台进程接管下载任务。所以,当应用退到后台或者是应用因crash等被中断运行(除了force-quit),并不影响后台下载进程继续运行。如果下载完成时,应用处于后台suspend的状态,那么系统会在后台唤醒应用;如果应用没有运行的话,会在后台启动应用。然后在相应的回调中,执行下载结束之后所要处理的相关工作。

Background Transfer Service特别适合于一些大文件的传输,如果配合Background Fetch,Remote Notification的话,就可以满足我们应用开发过程中的更多需求。


四 总结


iOS7以来,强大的多任务和网络API为现有应用和新应用开启了一系列全新的可能性。通过BackgroundTask,我们可以像系统申请更多的时间在后台运行任务;通过Background Fetch和Push,也使得应用启动无需等待数据更新加载成为可能;而Background Transfer Service,通过NSURLSession更好地帮我们实现任何传输任务在后台自由执行。在使用过程中,我们只需要根据需求合理地选择一种或多种后台模式,共同服务于我们的APP,就可以带来不一样的用户体验!



参考文档


  1. Background Execution

  2. iOS7的多任务-en

  3. iOS7的多任务-cn

  4. iOS应用程序生命周期

  5. WWDC 2013 Session笔记 - iOS7中的多任务

  6. What's New with Multitasking

  7. What's New in Foundation Networking

  8. Using Local And Push Notifications on iOS and Mac OS X

  9. Local and Remote Notification Programming Guide

  10. iOS的后台运行和多任务处理

  11. iOS后台运行实现总结

  12. iOS推送之远程推送https://www.163yun.com/gift

  13. iOS推送之本地推送

  14. objc-from-urlconnection-to-urlsession

  15. Using NSURLSession

  16. Life Cycle of a URL Session

网易云新用户大礼包:https://www.163yun.com/gift

本文来自网易实践者社区,经作者何慧授权发布。