隐式动画的性能瓶颈

3,073 阅读11分钟

原文链接

隐式动画实现的背后体现了核心动画精心设计的许多机制。在layer的属性发生改变之后,会向它的代理方请求一个CAAction行为来完成后续的工作,系统允许代理方返回nil指针。一旦这么做,修改属性的工作最终移交给CATransaction处理,由修改的属性值决定是否自动生成一个CABasicAnimation。如果满足,此时隐式动画将被触发。

关于CATransaction

在核心动画中,每个图层的修改都是事务CATransaction的一部分,它可以同时对多个layer的属性进行修改,然后成批的将将多个图层树包装起来,一次性发送到渲染服务进程。CATransaction事务对象被分为implicitexplicit两种类型,分别对应隐式显式implicit transaction会被投递到线程的下一个runloop完成处理:

Core Animation supports two types of transactions: implicit transactions and explicit transactions. Implicit transactions are created automatically when the layer tree is modified by a thread without an active transaction and are committed automatically when the thread's runloop next iterates.

默认情况下,CATransaction会在背后独立完成图层树属性计算的工作。系统提供API来显式的使用事务类,并且手动提交给渲染服务进程,这种做法被称作推进过渡推进过渡会生成一个默认时长为0.25s时长的动画效果来完成属性值的修改。下面代码会在0.25s内将图层放大一倍:

[CATransaction begin];
self.circle.transform = CATransform3DScale(CATransform3DIdentity, 2, 2, 1);
[CATransaction commit];

layer如何实现动画

图层属性被修改时,会朝着自己的代理对象请求一个CAAction行为来帮助自己完成属性修改的行为。代理方法actionForLayer:forKey:允许三种返回的数据格式来完成不同的修改动作:

  • 空对象

    UIView在响应代理时默认会返回一个NSNull对象,表示属性修改后,不实现任何的动作,根据修改后的属性值直接更新视图。但UIView不总是会返回空对象,如果layer的修改发生在[UIView animatedXXX]接口的block中,每一个修改的属性值UIView都会返回对应的CABasicAnimation对象来进行动画修改

  • nil

    手动创建并添加到视图上的CALayer或其子类在属性修改时,没有获取到具体的修改行为。此时被修改的属性会被CATransaction记录,最终在下一个runloop的回调中生成动画来响应本次属性修改。由于这个过程非开发者主动完成的,因此这种动画被称作隐式动画

  • CAAction的子类

    如果返回的是CAAction对象,会直接开始动画来响应图层属性的修改。一般返回的对象多为CABasicAnimation类型,对象中包装了动画时长动画初始/结束状态动画时间曲线等关键信息。当CAAction对象被返回时,会立刻执行动作来响应本次属性修改

了解隐式动画的必要

首先,隐式动画是相对于显式动画而言的,属于被动实现。由于显式动画是主动实现的,因此在实现这些动画的时候,我们会去考虑动画是否流畅,动画前后是否会有卡帧,也会不断的运行来保证动画效果如预期完成。而隐式动画多属于系统自己完成的动画效果,提供给我们的可调试空间也很小,这也导致了开发者对它的重视不够,从而阻碍了进一步深入学习的可能性。

其次,和用户直接进行交互的就是UI元素。在发生卡帧、掉帧的性能问题时,用户对静止界面和动画的感知是完全不同的。即便只有1帧页面丢失,在动画中也能轻易的被用户捕捉。举个例子,当用户按下按钮,应用推迟了1、2帧才开始跳转。又或者是在界面跳转时丢失帧数据,具体表现为卡帧,此时用户对于卡顿的感知是远远大于平常的,因此了解隐式动画过程中如何发生卡顿是很有必要的。

隐式动画何时开始

隐式动画的修改最终由CATransaction事务完成,它在主线程的runloop注册了一个监听者,具体回调发生在before waiting阶段。在回调中会将所有implicit transactions以动画的形式展示。虽然苹果文档没有明说具体的回调时机,但通过简单的测试可以定位transaction的回调时间:通过注册两个runloop监听者,回调优先级分别设为NSIntegerMaxNSIntegerMin,监控最早和最晚的回调阶段,并且在对应位置添加断点,查看断点前后图层是否更新:

- (void)viewDidLoad {
    [super viewDidLoad];
    
    CFRunLoopObserverContext ctx = { 0, (__bridge void *)self, NULL, NULL };
    CFRunLoopObserverRef allActivitiesObserver = CFRunLoopObserverCreate(kCFAllocatorDefault, kCFRunLoopBeforeWaiting, YES, NSIntegerMin, &__runloop_callback, &ctx);
    CFRunLoopAddObserver(CFRunLoopGetCurrent(), allActivitiesObserver, kCFRunLoopCommonModes);
    
    CFRunLoopObserverRef beforeWaitingObserver = CFRunLoopObserverCreate(kCFAllocatorDefault, kCFRunLoopBeforeWaiting, YES, NSIntegerMax, &__runloop_before_waiting_callback, &ctx);
    CFRunLoopAddObserver(CFRunLoopGetCurrent(), beforeWaitingObserver, kCFRunLoopCommonModes);
}

手动创建的CALayer在属性修改会产生隐式动画,将layer增加到视图层级上后,点击按钮来修改它的transform属性,并且观察断点前后的效果:

self.circle = [CAShapeLayer layer];
self.circle.delegate = self;
self.circle.anchorPoint = CGPointMake(0.5, 0.5);
self.circle.fillColor = [UIColor orangeColor].CGColor;
self.circle.path = [UIBezierPath bezierPathWithOvalInRect: CGRectMake(CGRectGetMidX([UIScreen mainScreen].bounds) - 50, 80, 100, 100)].CGPath;
[self.view.layer addSublayer: self.circle];

通过断点和界面显示可以看到在Before Waiting阶段的两次回调之间,transaction完成了属性修改的渲染任务(在DEBUG+断点状态下,隐式动画不能很好的完成动画效果):

通过上面的测试可以确定transaction的事务处理确实发生在before waiting阶段。但由于注册observer时传入的优先级可以影响回调顺序,为了排除回调顺序可能对测试的干扰,可以通过hookCFRunLoopAddObserver这一注册函数,来获取已有的所有注册before waiting的回调信息:

void new_runloopAddObserver(CFRunLoopRef rl, CFRunLoopObserverRef observer, CFRunLoopMode mode) {
    CFOptionFlags activities = CFRunLoopObserverGetActivities(observer);
    if (activities & kCFRunLoopBeforeWaiting) {
        CFRunLoopObserverContext context;
        CFRunLoopObserverGetContext(observer, &context);
        void *info = context.info;
        NSLog(@"%d, %@", CFRunLoopObserverGetOrder(observer), (__bridge id)info);
    }
    origin_CFRunLoopAddObserver(rl, observer, mode);
}

运行后应用注册了5个包含before waiting状态的observer,优先级分别是最小为0,最大为2147483647,也就是0 ~ 2^31-1,处于NSIntegerMinNSIntergerMax之间,足以确定测试的正确性。

隐式动画的性能瓶颈

通过上面的测试,可知layer的隐式动画发生在before waiting这一阶段。那么理论上来说,假如在两个监听回调之间发生了卡顿,应该会对动画效果造成影响。另外,卡顿的时机可能也会影响动画的效果。分别在transaction的回调让主线程进入休眠来测试不同时机的卡顿对动画造成的效果,上面的测试证明了注册的两个已有回调可以用于制作不同时机的卡顿:

NSLog(@"ready sleep");
[NSThread sleepForTimeInterval: 1];
NSLog(@"after sleep");

先于CATransaction回调发生卡顿。点击按钮后,界面卡顿1s,然后才开始执行动画。期间多次点击按钮无效:

后于CATransaction回调发生卡顿。点击按钮后,动画立刻开始执行。界面会停止响应1s,同样卡顿期间不响应点击。动画存在卡帧现象,但不严重:

transaction前后制作卡顿确实产生了不同的效果,但是即便更换卡顿的时机,动画效果仍是比较流畅的,这证明了渲染、展示过程和主线程可能是并发执行的。实际上在WWDC2014的视频中有对图层渲染过程的详细讲述,隐式动画遵循这样的渲染过程。图层渲染过程分为三个阶段:

  1. Commit Transaction + Decode

    transaction此时会将被修改的一个或者多个layer的属性统一计算,更新modelLayer属性,然后将图层信息整合提交渲染服务进程。渲染服务进程反序列化获取渲染树信息,并准备开始渲染

  2. Draw Calls + Render

    渲染服务进程根据渲染树信息,计算出动画的帧数和图层信息。此时GPU利用渲染树开始合成位图并准备展示到屏幕上

  3. Display

    将渲染好的位图信息展示到屏幕上,如果存在动画则逐帧展示。如果在transaction后发生卡顿,会对动画展示造成一定的影响,但影响程度相对较低

结合渲染服务进程的工作流程,可以知道实际上transaction的工作是1,在transaction回调结束时已经将图层树提交给渲染服务进程了,因此之后即便主线程发生卡顿,也不会影响渲染服务进程的工作。而早于transaction回调发生的卡顿会导致应用不能将图层树及时的提交到渲染服务进程,从而造成了动画开始前的界面停滞现象。

显式动画何时开始

说完了隐式动画如何开始、瓶颈等信息,对应的也理当说说显式动画。虽然直接响应属性修改是显式动画的最大特点,但通过已有的测试可以直接证明这一点。修改CALayerDelegate的代理方法,主动返回一个CABasicAnimation对象:

#pragma mark - CALayerDelegate
- (id<CAAction>)actionForLayer: (CALayer *)layer forKey: (NSString *)event {
    CABasicAnimation *animation = [CABasicAnimation animationWithKeyPath: event];
    CGFloat randomScale = (arc4random() % 20 + 1) * 0.1;
    animation.toValue = [NSValue valueWithCATransform3D: CATransform3DScale(CATransform3DIdentity, randomScale, randomScale, 1)];
    return animation;
}

同样添加断点进行测试,运行后可以看到在动画开始之后,断点才会停下来。也可以确定虽然transaction虽然也负责了显式动画的渲染事务,但会立即commit到渲染服务进程响应属性修改。

转场卡顿

默认的转场动画实际上也是由transaction来完成的,属于隐式动画。通过hook掉获取CAAction的代理方法,在忽略掉nilNSNull的无效返回值后,一个push跳转动画总共涉及到了三个CAAction子类。从类名上来看_UIViewAdditiveAnimationAction是和转场动画关联最密切的子类,也证明了系统默认的转场跳转实际上也是交给了transaction机制来处理的。另外从log上的执行来看,转场实际上也属于隐式动画

转场卡顿从效果上看能分为转场前卡顿转场后卡顿,后者属于常见的的转场性能瓶颈,大多由于新界面视图层级复杂、大量IO等工作导致,是最容易定位的一类问题。而前者属于少见,且不容易定位的卡顿现象之一。结合上面的测试,如果发生了转场前卡顿,那么说明渲染工作在1开始之前就发生了卡顿。

在上面的log中可以看到viewDidLoadviewWillAppear的调用同样处在before waiting阶段。假设这两个方法的调用时机在transaction前面,那么一旦两个方法发生了卡顿,肯定会跳转动画卡帧后执行的效果。通过分别在两个方法中添加sleep操作测试,还原了gif的卡顿效果。因此可以得出转场动画过程中的流程:

view did load --> view will appear --> CATransaction callback --> animate

补充

虽然苹果文档和测试结果都说明了一件事情:transaction的回调处在before waiting阶段,但是否存在可能:runloop无法进入before waiting呢?实际上这种可能是完全存在的,根据苹果文档中的描述,下图可以用来表示runloop的内部逻辑:

假如runloop中一直有source1事件,那么会一直在2、3、4、5、9之间循环处理。而touches发生时,就是典型的持续source1事件环境。换句话说,如果用户一直在滚动列表,那么before waiting将不会到来。但实际在应用使用中,即便是手指不离开屏幕,cell依旧能够展示各种动画。因此可以推断出transaction至少还注册了UITracking这个模式下的runloop监听处理,感兴趣的同学可以在滚动列表上采用类似的手段测试具体的处理时机。

结论

由于隐式动画的特殊性质,我们与之打交道的地方基本在页面跳转环节,一旦这个过程发生了卡顿,无论是跳转前卡顿或者是跳转后卡顿,都会使得应用的体验大打折扣。总结了一下,在日常开发中,我们与隐式动画打交道时记住几点:

  • 隐式动画开始前的卡顿是因为CATransaction回调前其他任务占用了大量的CPU资源,通过懒加载、延后加载、异步执行可以有效的避免这个问题

  • viewDidLoadviewWillAppear是一丘之貉,它们都会导致转场动画前的卡顿。所以如果你将前者的工作放到后者执行,并没有任何作用

  • 动画在开始之后,即便是应用发生卡顿,对动画的影响也要低于先于transaction的卡顿。因此如果你不知道如何优化动画前的烂摊子,那么放到动画开始之后吧

关注我的公众号获取更新信息