一个循环动画引起的内存泄露问题总结

前言

本文主要记录项目中遇到的一个内存泄露问题:由于一个循环动画引起的内存泄露,并且这个问题也是偶现的,在后面的 隐藏问题 里会说明。

先说下该动画:
进入 AController 后,需要执行一个动画,该动画会执行以下步骤:

  • 将一个 view 从左到右移动,动画时间 0.5s
  • 上一步的动画完成后,将 view hidden 1 秒
  • 1 秒后将 view 显示出来,并回到原来位置,重复执行上面步骤

下面将逐步分析问题并提供相应的解决方案,以及如何从根源上解决这个问题。

问题初步分析及解决

最开始该代码如下:

- (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];
    }
}

这里有两个问题:

  • CABasicAnimationdelegatestrong
  • 动画完成的回调里执行了 performSelector
     [self performSelector:@selector(startBaseAnimation) withObject:nil afterDelay:pauseDuration];

第一个问题要么在 viewWillDisappear 时,手动置该 delegate 为 nil,要么对该 view 的 layer 执行 removeAllAnimations 方法(之后记得在 viewWillAppear 重新启动动画)。

原本代码因为在 viewWillDisappear 里有执行了 removeAllAnimations,所以这个地方的内存泄露风险没有暴露出来。

第二个问题,因为 performSelector 这个方法内部是有一个 timer,该 timer 会持有 selfself 也持有该 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 方法,应该是必现的问题。

后来发现,问题是出在动画完成的回调里,里面是判断 flagYES 时才会跑进去执行 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 里即可,这样就不会有上面的 delegatetimer 相关的问题。

总结

使用 performSelector 来延时执行,要记得其内部是有一个 timer 的,会持有 self,所以要注意循环引用的问题,虽然在最后会自动释放,但是这样也会造成延时释放或是上述重复调用导致 self 一直不能被释放等问题。

写动画时,要注意其 delegatestrong,所以要注意释放。

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 掉 popdismiss 方法,在里面调用自定义的 willDealloc 方法,该方法会延时几秒后进行断言,如果命中断言,说明内存泄露了。详情可参见该团队的文章: MLeaksFinder:精准 iOS 内存泄露检测工具,这里不再赘述。

我们 APP 里已经接入该库,在 Debug 模式中检测到类似的内存泄露就弹框或者 Assert,及时地发现和解决。

QQ音乐团队诚聘测试、研发。有意者请发送简历至tmezp@tencent.com,请注明来自公众号,我们将优先拜读。

本站文章资源均来源自网络,除非特别声明,否则均不代表站方观点,并仅供查阅,不作为任何参考依据!
如有侵权请及时跟我们联系,本站将及时删除!
如遇版权问题,请查看 本站版权声明
THE END
分享
二维码
海报
一个循环动画引起的内存泄露问题总结
本文主要记录项目中遇到的一个内存泄露问题:由于一个循环动画引起的内存泄露,并且这个问题也是偶现的,在后面的隐藏问题里会说明。
<<上一篇
下一篇>>