从对象持有到 UIView Animation

谈到 iOS 中内存泄露,第一反应一般是循环引用,但在最近实践中,踩到了不少除了循环引用之外的坑,以此总结。文章主要从 Background Task 谈到 UIView Animation。

环境信息
macOS 10.12.6
Xcode 8.3
iOS 10.3


Background Task

谈到后台任务,顺便总结几点:

  • iOS 7 之后,后台任务为 180s 左右,实测 175s 的样子。
  • 如果连接数据线进行测试,后台任务会一直执行。
  • 如果在 expirationHandler 中未 endBackgroundTask,三分钟之后进程会被 kill,crash 日志如下。

针对后台任务的处理,最初的代码是这样的:

// HeartbeatService.m

// 对象释放移除通知
- (void)dealloc {
  [[NSNotificationCenter defaultCenter] removeObserver:self];
}

// 在回到前台后,停止后台任务
- (void)applicationWillEnterForeground:(NSNotification *)notification {
  if (_keepAliveBackgroundTask != UIBackgroundTaskInvalid) 
    [[UIApplication sharedApplication] endBackgroundTask:_keepAliveBackgroundTask];
     _keepAliveBackgroundTask = UIBackgroundTaskInvalid;
  }
  
  // 处理一些回到前台的逻辑
  [self doSomething];
}

// 进入后台时,开启后台任务
- (void)applicationDidEnterBackground:(NSNotification *)notification {
  _keepAliveBackgroundTask = [[UIApplication sharedApplication] beginBackgroundTaskWithName:@"ID" expirationHandler:^{
    // 处理超时后的一些逻辑
    [self doSomething];
    // 在超时 handler 中结束后台任务
    [[UIApplication sharedApplication] endBackgroundTask:self.keepAliveBackgroundTask];
    self.keepAliveBackgroundTask = UIBackgroundTaskInvalid;
  }];
}

以上是 HeartbeatService 类的逻辑,为保证程序进入后台后,还能继续心跳,开启了后台任务。在后台超时时,关闭心跳服务。在程序回到前台时,停止后台任务。如果用户在另一台设备上登录,则心跳服务会被释放。

乍一看没什么问题,但实际情况没这么简单:

  1. iOS 8 下,用户在后台被抢登,回到前台后 crash,原因 BAD_EXC_ACCESS;
  2. 修改问题 1 后,出现另外一个 bug,用户在后台被抢登后,三分钟后程序 crash。

问题 1

看到 BAD_EXC_ACCESS 首先想到的是内存问题,断点打在了回到前台后的 [self doSomething] 方法的调用处。单步调试之后发现整个调用逻辑如下(黑色为正常逻辑):

整个 crash 原因可以归结为:用户被抢登之后,心跳服务未能正常释放,导致通知未移除,所以用户回到前台后,收到通知,结束后台任务,出现异常调用栈,最终导致 crash。

既然在前台 endBackgroundTask 之后能正常释放,说明开启后台任务时,出了问题,让我们回到开启后台任务的代码:

expirationHandler 中出现了 self,虽然这里没有循环引用,但是导致 block 持有 self。既然如此,那么没有 endBackgroundTaskself 就不会释放问题就很好理解了。(关于如何控制 self 的释放时机,如必须要调用 block 才释放 self 的情况,可以看看我之前写的文章:Block 梳理与疑问)。

所以,问题 1 可以用 weakSelf 来解决。那么再来看看问题 2。

问题 2

出现三分钟之后,程序被 kill 的问题。这个 crash 原因很明显,有种操作路径未能正常 endBackgroundTask,导致程序被 kill。

因为修复了问题 1,那么用户在后台被抢登之后,就能正常调用 dealloc 方法,那么程序回到前台后,就不会再执行收到通知的操作,那么 endBackgroundTask 就不会调用,最终导致 kill。至此,如果使用了 background task,有三处路径需要 end:expirationHandler,回到前台,dealloc

dispatch_after

GCD 的 block 也会对 self 持有,直到 block 调用结束:

- (void)viewWillDisappear:(BOOL)animated {
    dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(4 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
        NSLog(@"%@", self);
    });
}

上面这段代码,在 4s 之后,提交到对应线程执行,执行完毕之后,释放 self

UIView Aniamtion

之前提到了诸多非循环引用,但是 block 会持有 self 的情况,那么 UIView 的 animation 呢?

- (void)viewWillDisappear:(BOOL)animated {
    [super viewWillDisappear:animated];
    
    [UIView animateWithDuration:2 delay:3 options:UIViewAnimationOptionCurveEaseInOut animations:^{
        self.animationView.frame = CGRectMake(200, 200, 200, 200);
    } completion:^(BOOL finished) {
        
    }];
}

viewWillDisappear 的时候,执行一个延迟 3s 的动画,是否会像 dispatch_after 一样,等待动画执行完成,再释放 self

实验结果是不会的,self 能正常释放。因为以上代码其实等同于:

- (void)viewWillDisappear:(BOOL)animated {
    [super viewWillDisappear:animated];
    
    [UIView beginAnimations:@"ID" context:NULL];
    [UIView setAnimationDuration:2];
    [UIView setAnimationDelay:3];
    self.animationView.frame = CGRectMake(200, 200, 200, 200);
    [UIView commitAnimations];
}

所以,block 的作用就是简化动画提交的代码,而动画实际提交给了 CATransaction,当 delay 的时间到时,再进行执行。所以在这里,block 中的代码只是一个瞬时操作(如果在在 block 中 NSLog,可以发现 NSLog 是立马执行的),而实际 view 的状态,是被保存在了 self.animationView.layer.modelLayer 中。当 view removeFromSuperView 时,animation 就会被移除,[view release],所以不会出现动画执行完后,再释放 view 的问题。

动画的实现

UIView 提交动画之后,真正去执行动画的,是 UIView 的表现层 CALayerCALayer 所属的 UIView 实现了 CALayerDelegateactionForLayer:forKey: 方法,该方法用于返回一个实现了 CAAction 的对象给 layer。而 CALayer 将会用这个返回的 CAAction 对象生成对应的 CAAnimation

// UIView.m

#pragma mark - CALayerDeleage
  
- (id<CAAction>)actionForLayer:(CALayer *)layer forKey:(NSString *)event {
    return nil; // 执行默认的隐式动画
    return [NSNull null]; // 不执行动画
    return [CAAnimation new]; // 执行实现了 CAAction 协议的动画,如 CAAnimation
}

而在 layer 中,还包含两个 layer,分别是用于呈现的 presentationLayer 与存储最终状态的 modelLayer。当动画提交以后,最新的 self.animationView.frame 就被存储到了 self.animationView.layer.modelLayer 中。而在动画执行时,实时更新的是 self.animationView.layer.presentationLayer,可以在 CADisplayLink 中获取到实时的状态。