
本文主要记录项目中遇到的一个内存泄露问题:由于一个循环动画引起的内存泄露,并且这个问题也是偶现的,在后面的 隐藏问题 里会说明。
先说下该动画:
进入 AController 后,需要执行一个动画,该动画会执行以下步骤:
view 从左到右移动,动画时间 0.5s hidden 1 秒下面将逐步分析问题并提供相应的解决方案,以及如何从根源上解决这个问题。
最开始该代码如下:
- (void)viewWillAppear:(BOOL)animated
{
[super viewWillAppear:animated];
[self startBaseAnimation];
}
- (void)startBaseAnimation
{
if (!_baseAniMoveView) return;
self.navigationItem.title = @"动画进行中...";
[self.baseAniMoveView.layer removeAllAnimations];
self.baseAniMoveView.hidden = NO;
CABasicAnimation * baseAni = [CABasicAnimation animationWithKeyPath:@"position"];
CGPoint leftStarPosition = self.baseAniMoveView.center;
baseAni.fromValue = [NSValue valueWithCGPoint:self.baseAniMoveView.center];
baseAni.toValue = [NSValue valueWithCGPoint:CGPointMake(leftStarPosition.x + moveLength, leftStarPosition.y)];
baseAni.duration = moveDuration;
baseAni.removedOnCompletion = NO;
baseAni.delegate = self;
baseAni.fillMode = kCAFillModeForwards;
[self.baseAniMoveView.layer addAnimation:baseAni forKey:kBaseAnimationKey];
}
#pragma mark - animation delegate
- (void)animationDidStop:(CAAnimation *)anim finished:(BOOL)flag
{
if (flag && _baseAniMoveView) {
[self.baseAniMoveView.layer removeAllAnimations];
self.baseAniMoveView.hidden = YES;
[self performSelector:@selector(startBaseAnimation) withObject:nil afterDelay:pauseDuration];
}
}这里有两个问题:
CABasicAnimation 的 delegate 为 strong performSelector:
[self performSelector:@selector(startBaseAnimation) withObject:nil afterDelay:pauseDuration];第一个问题要么在 viewWillDisappear 时,手动置该 delegate 为 nil,要么对该 view 的 layer 执行 removeAllAnimations 方法(之后记得在 viewWillAppear 重新启动动画)。
原本代码因为在 viewWillDisappear 里有执行了 removeAllAnimations,所以这个地方的内存泄露风险没有暴露出来。
第二个问题,因为 performSelector 这个方法内部是有一个 timer,该 timer 会持有 self,self 也持有该 timer,造成循环引用,所以 dealloc 就一直不调用了。
解决方法也有多个,比如说在 viewWillDisappear 里取消掉该 perform 的方法(之后记得在 viewWillAppear 重新启动动画):
[NSObject cancelPreviousPerformRequestsWithTarget:@selector(startBaseAnimation)];或者不用 perfomrSelector,改用 dispatch_after,block 里用 weak self:
SCWeakSelf(self);
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(pauseDuration * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
SCStrongSelf(self);
if (!self) return;
[self startBaseAnimation];
});或是自己实现一个 weak timer,这种方式就要注意一定要用 weak timer,并在合适的时机进行定时器的销毁。
这里还有一个隐藏的问题,就是发现 dealloc 方法,在 pop 页面时,有时能执行,有时不能执行,按理来说有执行了 performSelector 方法,应该是必现的问题。
后来发现,问题是出在动画完成的回调里,里面是判断 flag 为 YES 时才会跑进去执行 performSelector 方法,而为 NO 时就不会有问题。
- (void)animationDidStop:(CAAnimation *)anim finished:(BOOL)flag
{
// 注意这里是判断 flag 为 YES 时才会进去
if (flag && _baseAniMoveView) {
[self.baseAniMoveView.layer removeAllAnimations];
self.baseAniMoveView.hidden = YES;
[self performSelector:@selector(startBaseAnimation) withObject:nil afterDelay:pauseDuration];
}
}而什么时候为 NO 呢,顾名思义就是动画未完成,所以动画正在执行中时,点击了返回按钮,回调的 flag 就为 NO,所以就不会执行 performSelector,所以也就不会造成内存泄露了。
所以这个内存泄露出现的时机,就为:动画完成后刚好点击了返回。
上面分析了问题,并给出了相应的解决方案,不过以上只是治标不治本的方法,问题的根源在动画的实现方式上。
以下是用 CAKeyframeAnimation 动画组来实现的方案:
- (void)startKeyAnimation
{
if (!_baseAniMoveView) return;
[self.baseAniMoveView.layer removeAllAnimations];
// 显示 view
CAKeyframeAnimation *showAni = [CAKeyframeAnimation animationWithKeyPath:@"opacity"];
showAni.duration = 0;
showAni.values = @[@1, @1];
// 移动 view
CGPoint leftStarPosition = self.baseAniMoveView.center;
CAKeyframeAnimation *baseAni = [CAKeyframeAnimation animationWithKeyPath:@"position"];
baseAni.duration = moveDuration;
NSValue *fromValue = [NSValue valueWithCGPoint:self.baseAniMoveView.center];
NSValue *toValue = [NSValue valueWithCGPoint:CGPointMake(leftStarPosition.x + moveLength, leftStarPosition.y)];
baseAni.values = @[fromValue, toValue];
baseAni.removedOnCompletion = NO;
baseAni.fillMode = kCAFillModeForwards;
// 隐藏 view
CAKeyframeAnimation *hideAni = [CAKeyframeAnimation animationWithKeyPath:@"opacity"];
hideAni.duration = pauseDuration;
hideAni.values = @[@0, @0];
hideAni.beginTime = moveDuration; // important!
// 动画组
CAAnimationGroup *group = [CAAnimationGroup animation];
group.animations = @[showAni, baseAni, hideAni];
group.repeatCount = FLT_MAX;
group.duration = moveDuration + pauseDuration;
// 添加动画
[self.baseAniMoveView.layer addAnimation:group forKey:kKeyAnimationKey];
}其中难点在于如何控制 平移动画 完成后,将 view 隐藏 1 秒后重新显现并继续执行。
这里就使用多一个关键帧动画操作其 opacity 参数实现隐藏 1 秒。将其 values 设置为 0 到 0,该帧动画持续 1 秒,并且该帧动画的开始时间要另外设置一下,改为在 平移动画完成后:
hideAni.beginTime = moveDuration;并且在重新执行 平移动画 前将 view 重新显示出来,这里同样使用多一个关键帧动画,将该 view 的 opacity 设置为 从 1 到 1,持续 0 秒,这样就能立马显示出来:
CAKeyframeAnimation *showAni = [CAKeyframeAnimation animationWithKeyPath:@"opacity"];
showAni.duration = 0;
showAni.values = @[@1, @1];最后将这三个关键帧动画加到 CAAnimationGroup 里即可,这样就不会有上面的 delegate 和 timer 相关的问题。
使用 performSelector 来延时执行,要记得其内部是有一个 timer 的,会持有 self,所以要注意循环引用的问题,虽然在最后会自动释放,但是这样也会造成延时释放或是上述重复调用导致 self 一直不能被释放等问题。
写动画时,要注意其 delegate 为 strong,所以要注意释放。
demo 工程可以去这里查看: https://github.com/Aevit/SCAnimationMemoryLeakDemo
另外,动画在 push 到新页面,或是回到桌面,再重新返回,动画会停止,猜测可能是系统某些机制,毕竟执行动画是要刷新 layer,所以是要耗电的,可能系统做了优化来节电。
节电这一点查了很久也没有查到明确的资料来证明,不过苹果关于 后台任务 的文档里有这样一段话:
When the user is not actively using your app, the system moves it to the
background state. For many apps, the background state is just a brief stop
on the way to the app being suspended. Suspending apps is a way of
improving battery life it also allows the system to devote important
system resources to the new foreground app that has drawn the user’s
attention.在这里提到了进入后台及电池相关的,所以才推测是为了省电,不然在用户不可见的界面,还一直进行 layer 的刷新来做动画,是会对电池造成一点点损耗的,当动画一多就更明显了。
所以一般就在页面即将消失时移除动画,在 viewWillAppear,以及监听从桌面回到 app 的事件,重新添加动画。
苹果提供了 Instruments 工具来检测内存泄露,不过一般是想到要检测时才会去用,并且需要针对性地去某个页面查看,不能在开发阶段就发现问题。业界也有一些库来检测,如 PLeakSniffer、FBRetainCycleDetector(主要检测循环引用问题)、HeapInspector-for-iOS、MSLeakHunter、MLeaksFinder 等。
目前 github 上 star 较多的是 MLeaksFinder,其基本原理简单来说是 hook 掉 pop 及 dismiss 方法,在里面调用自定义的 willDealloc 方法,该方法会延时几秒后进行断言,如果命中断言,说明内存泄露了。详情可参见该团队的文章: MLeaksFinder:精准 iOS 内存泄露检测工具,这里不再赘述。
我们 APP 里已经接入该库,在 Debug 模式中检测到类似的内存泄露就弹框或者 Assert,及时地发现和解决。
QQ音乐团队诚聘测试、研发。有意者请发送简历至tmezp@tencent.com,请注明来自公众号,我们将优先拜读。