iOS MultiTasking再认识(上篇)

本文主要结合Apple一系列官方文档、WWDC、以及相关博文的学习,并配合自己的一系列测试,对iOS7之后的多任务进行了总结。在了解iOS的多任务方式之前,对于iOS的应用程序的生命周期也需要有个清晰的认识。所以,文章将按照如下章节来介绍iOS7及其后系统多任务的新特性:

  • iOS应用程序运行状态
  • iOS多任务的发展
  • iOS7多任务之新特性

iOS应用程序运行状态

iOS应用程序的有如下几种状态:

  1. Not Running: App已经终止,或者还未启动
  2. InActive : App处于前台但不再接收事件(eg:用户处于活动时锁住了设备)。
  3. Active : App处于在前台运行而且接收到了事件,这是前台的一个正常模式
  4. Background : 程序在后台而且能执行代码,大多数程序进入这个状态后会在在这个状态上停留一会。时间到之后会进入挂起状态(Suspended)。有的程序经过特殊的请求后可以长期处于Backgroud状态
  5. Suspend : App驻留内存,但不再执行代码。系统会自动把程序变成这个状态而且不会发出通知。当挂起时,程序还是停留在内存中的,当系统内存低时,系统就把挂起的程序清除掉,为前台程序提供更多的内存。

下图是应用程序各状态的变化图:

对应不同状态切换时,会触发delegate对象对应的方法来响应app的状态改变。了解这些方法,在app状态发生转换时,可以更合理地选择相应的方法,执行我们期待的操作。

告诉代理对象,应用启动过程结束,准备开始运行
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions

当应用程序将要入非活动状态执行,在此期间,应用程序不接收消息或事件,比如来电话了
- (void)applicationWillResignActive:(UIApplication *)application

当应用程序入活动状态执行,这个刚好跟上面那个方法相反
- (void)applicationDidBecomeActive:(UIApplication *)application 

当程序被推送到后台的时候调用。所以要设置后台继续运行,则在这个函数里面设置即可
- (void)applicationDidEnterBackground:(UIApplication *)application

当程序从后台将要重新回到前台时候调用,这个刚好跟上面的那个方法相反。
- (void)applicationWillEnterForeground:(UIApplication *)application

当程序将要退出是被调用,通常是用来保存数据和一些退出前的清理工作。这个需要要设置UIApplicationExitsOnSuspend的键值。
- (void)applicationWillTerminate:(UIApplication *)application

更具体的介绍,可以参考这篇博文:iOS应用程序生命周期

iOSx多任务发展

  1. iOS4之前: app并不存在后台的概念,只要Home键按下,app就被干掉。
  2. iOS4: 引入了后台和多任务,但实际上是伪多任务。它仅支持少数几类服务在通过注册后可以真正在后台运行,而一般的app后台并不能执行自己的代码。
  3. IOS5、6: 能后台运行的服务种类增加,但后台的本质没有变化。

总结下来,iOS7之前,系统所接受的应用多任务大致分为以下几种:

  • 后台完成某些花费时间的特定任务(通过beginBackgroundTaskWithExpirationHandler:实现)
  • 后台播放音乐
  • Location Service
  • IP 电话
  • NewsStand

而iOS7作为多任务发展的转折点,不仅改变了之前后台任务的处理方式,还加入了全新的后台模式。以下列出iOS7多任务的新特性:

  • 改变了后台任务的运行方式
  • 新特性之一——Background Fetch
  • 新特性之二——Slient Remote Notifications
  • 新特性之三——Background Transfer Service

除了这些新特性以外,当然它仍然继续支持一些诸如IP电话等特殊服务。不过,本文的调研路线主要focus在iOS7多任务的四类新特性上。

iOS7的多任务之新特性

关于本章节的所有Demo都是基于iOS7以上的系统测试的。

Finite-length Background Task

  1. 后台任务的运行方式

    后台执行有限时长的任务,这是自iOS4以来就存在的一种后台模式。严格上来说,它并不是真正意义上的后台模式。它只是UIApplication提供的一个API,通过此API我们可以实现后台任务执行的一种方式:

     -(UIBackgroundTaskIdentifier)beginBackgroundTaskWithName:expirationHandler:
     or
     -(UIBackgroundTaskIdentifier)beginBackgroundTaskWithExpirationHandler:
    

    通过此API,我们可以向系统申请额外的时间,以保持app在被切换到后台后仍然能保持运行一段时间。一些文档上都说这“一段时间”大概有180s~600s,具体的执行时间我将在后面解释。反正,超过这个时间,程序会被挂起。如果处理不当,程序也可能会被kill掉。

    在iOS6和之前的系统中,系统在用户退出应用后,如果应用正在执行后台任务的话,即使用户锁屏或设备进入休眠状态,系统都会保持活跃的状态直到后台任务完成或者超时以后(文档上都说此时间大概10min,对于此时间iOS7之前的系统我并未测试),才进入真正的低功耗休眠状态。如下图所示:                                              而自从iOS7以来,后台任务的处理方式发生了改变。系统将在用户锁屛后,尽快让设备进入休眠状态,以节省电力,这时后台任务是被暂停的。之后设备在特定时间进行系统应用的操作被唤醒时(如检查邮件、来电等),之前暂停的后台任务将一起进行。也即,系统并不会专门为第三方应用保持设备处于活跃的状态。如下图所示:                                           iOS7之后系统,对于后台任务处理方式的这种变化,是需要实际验证的。然而,我通过一些Demo并未证实。So,求证~~

  1. 后台任务的实现

    一般来说,会有3分钟~10分钟的时间,但是API文档上并没有给出一个确切的时间。具体的运行时间取决于iOS系统,对于后台运行时间可以在UIApplication查询backgroundTimeRemaining,它会告诉你剩余多长时间。

    应用在退到后台时,applicationDidEnterBackground:applicatiion会被调用。API文档上说明在真正进入后台之前,有5s的时间可以去完成一些工作,但是实际测试大概只有几十ms吧。所以,若要处理一些稍微长时间点的任务的话,最好是通过beginBackgroundTaskWithExpirationHandler:handler向系统申请更多的时间。使用此API还需要注意以下几点:

    • handler会在backgroundTimeRemaining为0之前被执行,即在超时之前被执行,在这里我们需要做一些清理工作并通过endBackgroundTask:标记task结束。否则,如果超时导致此block被执行,但是没有调用endBackgroundTask:的话,app会被系统kill掉
    • 此API必须与endBackgroundTask:identifier成对出现,这里的identifier就是上述API返回的对应后台任务的ID。需要通过此方法来通知系统后台任务完成,可以将app挂起。 需要在两处调用此API:

      • 后台任务处理完成,调用endBackgroundTask:,尽快通知系统任务结束可以将app挂起
      • completionHandler block中,在超时之前,需要调用它通知系统

      否则,系统会kill掉应用,而不是让应用从后台模式进入挂起状态(注意:测试的时候应该真机运行测试,而不要通过Xcode调试状态去测试此情况)

  2. Demo演示与测试

    说了这么多,贴点代码,我们来验证一下吧!

     - (void)applicationDidEnterBackground:(UIApplication *)application {
         NSLog(@"did enter backgournd");
    
         // 当应用程序留给后台的时间快要到结束时(应用程序留给后台执行的时间是有限的), 这个Block块将被执行
         // 我们需要在次Block块中执行一些清理工作。
    
         // 清理工作需要在主线程中用同步的方式来进行
         self. backgroundTaskIdentifier =[application beginBackgroundTaskWithExpirationHandler:^( void) {
             [self endBackgroundTask];
         }];
    
         // 模拟一个Long-Running Task
         [self dataTaskResume];                                              
     }
    
     -(void)dataTaskResume
     {
         NSURL * url = [NSURL URLWithString:@"http://xmind-dl.oss-cn-qingdao.aliyuncs.com/xmind-7-update1-macosx.dmg"];
    
        // NSURLSessionDataTask * dataTask = [[self defaultURLSession] dataTaskWithURL:url];
         NSURLSessionDataTask * dataTask = [[self defaultURLSession] dataTaskWithURL:url
                                                               completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error) {
    
                                                                   [self endBackgroundTask];
         }];
         [dataTask resume];
    
     }
    
     - (NSURLSession *)defaultURLSession
     {
         static NSURLSession * defaultSession = nil;
         static dispatch_once_t onceToken;
         dispatch_once(&onceToken, ^{
             NSURLSessionConfiguration * configuration = [NSURLSessionConfiguration defaultSessionConfiguration];
    
             defaultSession = [NSURLSession sessionWithConfiguration:configuration
                                                            delegate:self
                                                       delegateQueue:[NSOperationQueue mainQueue]];
         });
         return defaultSession;
     }
    
     -(void)endBackgroundTask{
         dispatch_queue_t mainQueue = dispatch_get_main_queue();
         AppDelegate *weakSelf = self;
         dispatch_async(mainQueue, ^(void) {
    
             AppDelegate *strongSelf = weakSelf;
             if (strongSelf != nil){
    
                // 每个对 beginBackgroundTaskWithExpirationHandler:方法的调用,必须要相应的调用 endBackgroundTask:方法。这样,来告诉系统后台任务已经执行完,这样系统就可以将app挂起;否则,在执行完backgroundTask或者是超过了时间限制的话,系统会直接终止app.
                 [[UIApplication sharedApplication] endBackgroundTask:self.backgroundTaskIdentifier];
                 // 销毁后台任务标识符
                 strongSelf.backgroundTaskIdentifier = UIBackgroundTaskInvalid;
             }
         });
     }
    
  1. 通过以下case,我们对Background Task的能力进行了简单测试:

    case1 启动应用后,退到后台,保持系统不进入休眠状态,通过Charles模拟慢网络情况,以致下载超过3min。

    case2 启动应用后,退到后台,保持系统不进入休眠状态,正常速度下载,确保下载任务在3min之内完成。

    case3 启动应用后,退到后台后,锁屏使应用进入休眠状态。

    根据以上几种case,分别测试了调用endBackgroundTask和不调用它的结果。可以得出以下结论:

    应用退到后台后,无论系统是否进入休眠状态,系统都会分配3min的时间给后台任务执行。 如果超过3min任务未完成,则会终止下载任务。 如果endBackgroundTask:identifier没有在合适的地方调用,系统会kill掉进程; 反之,系统会将应用挂起。

    但是,这和Apple官方文档上给出的iOS7前后后台任务执行方式的变化图似乎有些出入。图中解释,iOS7之后,当用户锁屏致使系统休眠,后台任务会暂停,并通过一些零碎的时机去唤醒应用,继续未完的后台任务。而实际测试,anytime,后台任务都有3min钟的时间去持续执行。So, that's my confused.

Anyway,Background Task更适合处理一些非耗时的网络传输的工作,而对于耗时的网络传输工作,iOS7给我们提供了一个大招:Background Transfer Service。不过,我们先继续下一个新特性:Background Fetch!

Background Fetch

iOS7之前,诸如新闻、天气、社交应用等,此类应用有一个共同的缺点,即每次加载应用的时候,用户都需要等待应用从服务器获取最新数据。这一点如果体现在弱网环境下,就尤为明显了。

自iOS7以来,加入了新的后台模式,后台获取。系统会在后台以一定的时间间隔来唤醒应用,获取最新的数据,刷新应用快照。这样在用户打开应用的时候,最新的内容将已然呈现在用户眼前,而省去了所有的加载过程。后台获取是在应用程序挂起之前的30s时间执行工作的,因此并不适用于CPU频繁工作或者长时间运行任务,更适合于处理长时间运行的网络请求队列,或执行快速的内容更新等。

另外,系统执行获取会结合应用设置以及用户行为来给出一个合理的时间,这是系统自学习的过程。比如用户每天12点会刷一下微博,只要这个习惯持续三四天,系统就会将此时间调整到12点之前一点,这样每次打开应用都直接有最新内容的同时,也节省了电量和流量。

使用Background Fetch

  1. 启用Background Fetch模式

    如下图,打开Capabilities标签中设置Background Modes选项,勾选上Background Fetch。

  2. 实例设置获取间隔

    Fetch Interval一般在didFinishLaunchingWithOptions:方法中完成。

     - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
    {
     // Override point for customization after application launch.
    
     [[UIApplication sharedApplication] setMinimumBackgroundFetchInterval:UIApplicationBackgroundFetchIntervalMinimum];
     return YES;
    }
    

    Fetch Interval,是指系统唤醒应用并允许它加载数据的最低时间间隔。具体什么时候会进行后台获取,取决于系统。系统将根据App的设定,选择比如接收邮件的时候顺便为你的应用获取一下,或者也有可能专门为你的应用唤醒一下设备。对于获取时间间隔的设置,我们可以使用系统预定义的值,也可以使用自定义的NSTimeInterval类型的值。系统预定义的值有以下两个:

    UIApplicationBackgroundFetchIntervalMinimum:这个值会要求系统尽可能频繁地去管理应用什么时候应该被唤醒

    UIApplicationBackgroundFetchIntervalNever:这是系统默认的设置,表示永远不去后台获取。

    但是,后台获取越频繁,占用的资源就越多。iOS有一套自我保护的机制,会限制访问此API过于频繁的应用。而且后台获取会导致电池消耗地过快,所以在设置自定义间隔的时候需谨慎。

  3. 实现后台获取代码并通知系统

    这是完成Background Fetch的最后也是很关键的一步啦。AppDelegate同样提供了如下API让我们完成Fetch相关工作:

     -(void)application:(UIApplication *)application performFetchWithCompleteHandler:handler
    

    系统在执行Fetch的时候会唤醒后台应用并执行此方法,我们只需要在此方法中完成获取工作,刷新UI,并通知系统fetch结束即可。同样,对于此API的使用也有一些注意点:

    • 系统提供的fetch时间最多30s(简单测试下来,大概也是这个时间), 因此fetch只适合做一些简单的数据下载工作。
    • 当完成了网络请求和界面更新后,需要尽快执行completion handler来报告系统,系统会及时将app挂起,同时会更新页面快照。这样用户在应用间切换时,就可以看到新内容了。但是如果超过30s,系统没有收completionHandler的通知,则会kill掉应用。
    • 系统会根据completeHandler的调用时间评估Apps后台下载所耗电量和流量,选择分配后台获取的机会给不同Apps,从而平衡不同Apps和系统自身的需求(整体感知,没有细测)。
    • 一旦completion handler block被调用后,系统会利用剩余时间去计算后台下载所消耗的电量和流量。如果App下载数据量小而快,在下次获取后台fetch的时间可能性能更大些。反之,对于此类Apps,下载时间所花时间越长,或者通过completion handler block告知fetch结束,但实际上没有做任务下载相关的工作的,在下次获取后台fetch的机会就稍逊。

      看来想获取后台获取的机会还是得自己自觉好好把握啦。

      这里以获取知乎上RSS订阅内容的标题和更新时间为例,实现后台获取的功能:

      -(void)application:(UIApplication *)application performFetchWithCompletionHandler:(void (^)(UIBackgroundFetchResult))completionHandler{
      
            NSDate *fetchStart = [NSDate date];
      
        ViewController *viewController = (ViewController *)self.window.rootViewController;
      
        [viewController fetchNewDataWithCompletionHandler:^(UIBackgroundFetchResult result) {
            completionHandler(result);
      
            UIAlertView *alert = [[UIAlertView alloc] initWithTitle:@"test2" message:@"系统alertview" delegate:self cancelButtonTitle:@"fetch结束" otherButtonTitles:nil, nil];
            [alert show];
      
            NSDate *fetchEnd = [NSDate date];
            NSTimeInterval timeElapsed = [fetchEnd timeIntervalSinceDate:fetchStart];
            NSLog(@"Background Fetch Duration: %f seconds", timeElapsed);
      
        }];
         }
      

      在ViewController中定义public method,真正完成获取的工作

      static NSString * NewsFeed = @"http://www.zhihu.com/rss";
      
      -(void)fetchNewDataWithCompletionHandler:(void (^)(UIBackgroundFetchResult))completionHandler{
        XMLParser *xmlParser = [[XMLParser alloc] initWithXMLURLString:NewsFeed];
        [xmlParser startParsingWithCompletionHandler:^(BOOL success, NSArray *dataArray, NSError *error) {
            if (success) {
                NSDictionary *latestDataDict = [dataArray objectAtIndex:0];
                NSString *latestTitle = [latestDataDict objectForKey:@"title"];
      
                NSDictionary *existingDataDict = [self.arrNewsData objectAtIndex:0];
                NSString *existingTitle = [existingDataDict objectForKey:@"title"];
      
                if ([latestTitle isEqualToString:existingTitle]) {
                    completionHandler(UIBackgroundFetchResultNoData);
      
                    NSLog(@"No new data found.");
                }
                else{
                    [self performNewFetchedDataActionsWithDataArray:dataArray];
      
                    completionHandler(UIBackgroundFetchResultNewData);
      
                    NSLog(@"New data was fetched.");
                }
            }
            else{
                completionHandler(UIBackgroundFetchResultFailed);
      
                NSLog(@"Failed to fetch new data.");
            }
        }];
      }
      

      这里还提供了两个button: Trash和Load,主要用来清除持久化的数据以及手动加载这些数据。也更方便我们对比Background Fetch与Foreground Fetch的体验差异化:

      -(IBAction)removeDataFile:(id)sender {
        if ([[NSFileManager defaultManager] fileExistsAtPath:self.dataFilePath]) {
            [[NSFileManager defaultManager] removeItemAtPath:self.dataFilePath error:nil];
      
            self.arrNewsData = nil;
      
            [self.tblNews reloadData];
        }
      }
      
      // 前台手动加载数据
      -(IBAction)reloadData:(id)sender{
        [self refreshData];
      }
      
  4. 模拟调试

    这里有两种调试方法:一种是从后台获取中启动应用,一种是当应用在后台时模拟一次后台推送。 对于前者,我们可以新建一个Scheme来专门调度从后台启动。相关配置如下图:

点击Product->Scheme->Edit Scheme,在编辑Scheme的窗口中,按下Duplicate Scheme按钮复制一个当前方案,然后在新Scheme的option中将Background Fetch打上勾。从这个Scheme来运行应用时,应用将不会直接启动切入前台,而是调用后台获取部分代码并更新UI,这样再点击图标进入应用时,就可以看到最新数据和更新好的UI了。

另一种是当应用在后台时,模拟一次后台获取。在App调试运行时,点击Xcode的Debug菜单中的Simulate Background Fetch,即可模拟完成一次获取调用。

戳戳戳->完整的Background Fetch Demo源码在这里



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

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