CoreAnimation专题一 CADisplayLink –同步屏幕刷新的神器

620 阅读21分钟
原文链接: www.jianshu.com
目录
  • iOS绘图系统
    • FPS
    • 绘制动画
  • CADisplayLink
    • 构建CADisplayLink
    • 线性插值
    • 基于CADisplayLink的动画
    • 非线性的插值
一 iOS绘图系统

虽然CoreAnimation框架的名字和苹果官方文档的简介中都是一个关于动画的框架,但是它在iOS和OS X系统体系结构中扮演的角色却是一个绘图的角色。

About Core Animation

系统体系结构:

image.png

解释说明

  1. 可以看到,最上面一层是是应用层(UI层),直接和用户打交道(UIKit框架也就是干这件事的),而真正的绘图层则在下面一层,绿色的这一层。

  2. 绘图层由3个部分组成:最上面是CoreAnimation,是面向对象的。往下就是更底层的东西了:OpenGLCoreGraphics,它们提供了统一的接口来访问绘图硬件。而绘图硬件则是绘图真正发生的地方。

  3. 那我们就可以这样来理解这个体系结构:真正干事的是绘图硬件(通常是GPU),也就是最下面那一块,它负责把像素画到屏幕上。而我们为了命令它画图(如何绘制)需要有方法能访问到它,当然这种硬件层面的东西肯定不能直接访问的,操作系统一定会做限制(如果不加以限制的话可能一些错误的操作将导致系统故障),这里就和面向对象的封装很像了,操作系统封装了硬件层,只提供简单的能够由开发者直接访问的接口,而不同的硬件可能有不同的封装方式,直接访问起来势必相当麻烦(我们的代码需要适配不同的硬件),于是就有了OpenGL,它统一了所有绘图硬件的接口,我们使用OpenGL提供的同一套API就能控制任意的绘图硬件了。

  4. 而OpenGL虽然很强大,但是很少会用到它一些复杂的功能,而简单的功能也是C语言不太好使用,所以具体地针对iOS和OS X系统,苹果为我们封装了OpenGL,没错这就是CoreAnimation

所以大家可以体会一下,实际上CoreAnimation虽然表面上更多的是提供了动画的功能,但是动画是基于绘图的,所以完全可以把CoreAnimation框架当做一个用来绘图的框架来处理。它直接提供的动画接口实际上是相当少的,而大量的提供了辅助动画的API,我们这里将用到一个大杀器:CADisplayLink

1.1 FPS

首先我们从FPS的概念入手来帮助理解CADisplayLink。这里的FPS不是第一人称射击游戏,而是frame per second,也就是帧率,表示屏幕每秒钟刷新多少次。如果帧率为60,表示屏幕每秒刷新60次,并不代表每1/60秒刷新一次,只能表示在1秒钟的时间内屏幕会刷新60次,每次屏幕刷新的间隔并不一定是平均的。

1.2 绘制动画

动画是一系列静态图片以极快的速度进行切换形成的,这个速度要快到人眼察觉不出其中的间隙(两张图片切换之间的间隔时间),具体地,这个切换频率必须大于人眼的刷新频率:每秒钟60次。也就是说,如果屏幕刷新频率大于每秒钟60次,那么我们人眼就感受不到两帧图片切换之间的间隙,所以我们感觉起来这些切换就是连续的,这就是动画的产生。也就是说,动画实际上就是以尽量大于60fps的速度在多张静态图片之间进行快速切换。

二 CADisplayLink

我们的屏幕每时每刻都在以>60fps的帧率进行刷新,每次刷新都会根据最新的绘制信息重绘屏幕上显示的内容,这样你才能顺利的看见各种动画,比如一个UITableView的滚动效果。CADisplayLink提供了API,每当屏幕刷新的时候,系统会回调我们向CADisplayLink注册的一个方法,也就是说,我们可以在屏幕每次刷新的时候调用一个我们自己的方法。基于上面对绘制动画的认识,肯定我们就能够像系统那样一帧一帧地画动画了。

2.1 构建CADisplayLink

我们先提供一个回调方法

- (void)onDisplayLink:(CADisplayLink *)displayLink {
    NSLog(@"display link callback");
}

通过target-action的形式来向系统注册回调,然后向runloop中添加displayLink。这里要注意一下runloop中mode的概念。

1.一个runloop只能在某一个mode中跑,runloop可以在多个mode之间进行切换,
2.默认的,系统提供了两个mode:NSDefaultRunloopMode和UITrackingRunloopMode。正常情况下是default,但是如果一个scrollView滑动的时候(UITableView是scrollView的子类)runloop就会切换到UITrackingRunloopMode,这时候所有往default里面添加的内容都没法跑起来了。
3.这也是为什么,如果使用NSTimer的schedule方法来调度timer,当一个tableView滚动的时候timer会停止,就是因为schedule将把timer添加进default,而tableView滚动的时候runloop切换到了UITrackingRunloopMode,此时default中的timer就跑不起来了。

我们的CADisplayLink应该在这两种情况都能跑,所以我们可以这样来添加:

[displayLink addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSDefaultRunLoopMode];
[displayLink addToRunLoop:[NSRunLoop mainRunLoop] forMode:UITrackingRunLoopMode];

这样就把displayLink添加进了两种mode,无论runloop处于哪种mode,我们的displayLink都能被系统调度。这里其实还有一种写法

[displayLink addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes];

NSRunLoopCommonModes后面多了一个s,表示mode的复数形式,意味着多个mode,这里表示向所有被注册为common的mode中添加displayLink。实际上,NSDefaultRunloopMode和UITrackingRunloopMode都被系统注册成了common,所以这样写的效果和前一种是一样的,你在自己使用runloop的时候也可以自定义mode,然后把它注册成为common。

打印结果

2019-08-17 15:00:49.010520+0800 CADisplayLinkAnimation[5258:3737973] display link callback:2019-08-17 15:00:49:010
2019-08-17 15:00:49.026609+0800 CADisplayLinkAnimation[5258:3737973] display link callback:2019-08-17 15:00:49:027
2019-08-17 15:00:49.043214+0800 CADisplayLinkAnimation[5258:3737973] display link callback:2019-08-17 15:00:49:043
2019-08-17 15:00:49.060706+0800 CADisplayLinkAnimation[5258:3737973] display link callback:2019-08-17 15:00:49:061
2019-08-17 15:00:49.076248+0800 CADisplayLinkAnimation[5258:3737973] display link callback:2019-08-17 15:00:49:076
2019-08-17 15:00:49.094041+0800 CADisplayLinkAnimation[5258:3737973] display link callback:2019-08-17 15:00:49:094
2019-08-17 15:00:49.109894+0800 CADisplayLinkAnimation[5258:3737973] display link callback:2019-08-17 15:00:49:110
2019-08-17 15:00:49.126611+0800 CADisplayLinkAnimation[5258:3737973] display link callback:2019-08-17 15:00:49:127
2019-08-17 15:00:49.143636+0800 CADisplayLinkAnimation[5258:3737973] display link callback:2019-08-17 15:00:49:144
2019-08-17 15:00:49.160757+0800 CADisplayLinkAnimation[5258:3737973] display link callback:2019-08-17 15:00:49:161
2019-08-17 15:00:49.176317+0800 CADisplayLinkAnimation[5258:3737973] display link callback:2019-08-17 15:00:49:176
2019-08-17 15:00:49.193994+0800 CADisplayLinkAnimation[5258:3737973] display link callback:2019-08-17 15:00:49:194
2019-08-17 15:00:49.210705+0800 CADisplayLinkAnimation[5258:3737973] display link callback:2019-08-17 15:00:49:211
2019-08-17 15:00:49.226364+0800 CADisplayLinkAnimation[5258:3737973] display link callback:2019-08-17 15:00:49:226
2019-08-17 15:00:49.243284+0800 CADisplayLinkAnimation[5258:3737973] display link callback:2019-08-17 15:00:49:243
2019-08-17 15:00:49.260316+0800 CADisplayLinkAnimation[5258:3737973] display link callback:2019-08-17 15:00:49:260
2019-08-17 15:00:49.276499+0800 CADisplayLinkAnimation[5258:3737973] display link callback:2019-08-17 15:00:49:276
2019-08-17 15:00:49.294020+0800 CADisplayLinkAnimation[5258:3737973] display link callback:2019-08-17 15:00:49:294
2019-08-17 15:00:49.310249+0800 CADisplayLinkAnimation[5258:3737973] display link callback:2019-08-17 15:00:49:310
2019-08-17 15:00:49.326591+0800 CADisplayLinkAnimation[5258:3737973] display link callback:2019-08-17 15:00:49:327
2019-08-17 15:00:49.343819+0800 CADisplayLinkAnimation[5258:3737973] display link callback:2019-08-17 15:00:49:344
2019-08-17 15:00:49.359610+0800 CADisplayLinkAnimation[5258:3737973] display link callback:2019-08-17 15:00:49:360
2019-08-17 15:00:49.376512+0800 CADisplayLinkAnimation[5258:3737973] display link callback:2019-08-17 15:00:49:376
2019-08-17 15:00:49.393997+0800 CADisplayLinkAnimation[5258:3737973] display link callback:2019-08-17 15:00:49:394
2019-08-17 15:00:49.410703+0800 CADisplayLinkAnimation[5258:3737973] display link callback:2019-08-17 15:00:49:411
2019-08-17 15:00:49.427444+0800 CADisplayLinkAnimation[5258:3737973] display link callback:2019-08-17 15:00:49:427
2019-08-17 15:00:49.444050+0800 CADisplayLinkAnimation[5258:3737973] display link callback:2019-08-17 15:00:49:444
2019-08-17 15:00:49.460548+0800 CADisplayLinkAnimation[5258:3737973] display link callback:2019-08-17 15:00:49:461
2019-08-17 15:00:49.476770+0800 CADisplayLinkAnimation[5258:3737973] display link callback:2019-08-17 15:00:49:477
2019-08-17 15:00:49.493034+0800 CADisplayLinkAnimation[5258:3737973] display link callback:2019-08-17 15:00:49:493
2019-08-17 15:00:49.510546+0800 CADisplayLinkAnimation[5258:3737973] display link callback:2019-08-17 15:00:49:511
2019-08-17 15:00:49.526190+0800 CADisplayLinkAnimation[5258:3737973] display link callback:2019-08-17 15:00:49:526
2019-08-17 15:00:49.543370+0800 CADisplayLinkAnimation[5258:3737973] display link callback:2019-08-17 15:00:49:543
2019-08-17 15:00:49.560688+0800 CADisplayLinkAnimation[5258:3737973] display link callback:2019-08-17 15:00:49:561
2019-08-17 15:00:49.576429+0800 CADisplayLinkAnimation[5258:3737973] display link callback:2019-08-17 15:00:49:576
2019-08-17 15:00:49.592880+0800 CADisplayLinkAnimation[5258:3737973] display link callback:2019-08-17 15:00:49:593
2019-08-17 15:00:49.610707+0800 CADisplayLinkAnimation[5258:3737973] display link callback:2019-08-17 15:00:49:611
2019-08-17 15:00:49.626664+0800 CADisplayLinkAnimation[5258:3737973] display link callback:2019-08-17 15:00:49:627
2019-08-17 15:00:49.644023+0800 CADisplayLinkAnimation[5258:3737973] display link callback:2019-08-17 15:00:49:644
2019-08-17 15:00:49.660715+0800 CADisplayLinkAnimation[5258:3737973] display link callback:2019-08-17 15:00:49:661
2019-08-17 15:00:49.676896+0800 CADisplayLinkAnimation[5258:3737973] display link callback:2019-08-17 15:00:49:677
2019-08-17 15:00:49.693550+0800 CADisplayLinkAnimation[5258:3737973] display link callback:2019-08-17 15:00:49:693
2019-08-17 15:00:49.709966+0800 CADisplayLinkAnimation[5258:3737973] display link callback:2019-08-17 15:00:49:710
2019-08-17 15:00:49.726642+0800 CADisplayLinkAnimation[5258:3737973] display link callback:2019-08-17 15:00:49:727
2019-08-17 15:00:49.743258+0800 CADisplayLinkAnimation[5258:3737973] display link callback:2019-08-17 15:00:49:743
2019-08-17 15:00:49.760633+0800 CADisplayLinkAnimation[5258:3737973] display link callback:2019-08-17 15:00:49:761
2019-08-17 15:00:49.776308+0800 CADisplayLinkAnimation[5258:3737973] display link callback:2019-08-17 15:00:49:776
2019-08-17 15:00:49.794030+0800 CADisplayLinkAnimation[5258:3737973] display link callback:2019-08-17 15:00:49:794
2019-08-17 15:00:49.809933+0800 CADisplayLinkAnimation[5258:3737973] display link callback:2019-08-17 15:00:49:810
2019-08-17 15:00:49.826592+0800 CADisplayLinkAnimation[5258:3737973] display link callback:2019-08-17 15:00:49:827
2019-08-17 15:00:49.842970+0800 CADisplayLinkAnimation[5258:3737973] display link callback:2019-08-17 15:00:49:843
2019-08-17 15:00:49.859638+0800 CADisplayLinkAnimation[5258:3737973] display link callback:2019-08-17 15:00:49:860
2019-08-17 15:00:49.876409+0800 CADisplayLinkAnimation[5258:3737973] display link callback:2019-08-17 15:00:49:876
2019-08-17 15:00:49.894044+0800 CADisplayLinkAnimation[5258:3737973] display link callback:2019-08-17 15:00:49:894
2019-08-17 15:00:49.909634+0800 CADisplayLinkAnimation[5258:3737973] display link callback:2019-08-17 15:00:49:910
2019-08-17 15:00:49.926425+0800 CADisplayLinkAnimation[5258:3737973] display link callback:2019-08-17 15:00:49:926
2019-08-17 15:00:49.943997+0800 CADisplayLinkAnimation[5258:3737973] display link callback:2019-08-17 15:00:49:944
2019-08-17 15:00:49.960561+0800 CADisplayLinkAnimation[5258:3737973] display link callback:2019-08-17 15:00:49:961
2019-08-17 15:00:49.976851+0800 CADisplayLinkAnimation[5258:3737973] display link callback:2019-08-17 15:00:49:977
2019-08-17 15:00:49.994037+0800 CADisplayLinkAnimation[5258:3737973] display link callback:2019-08-17 15:00:49:994

一旦我们把displayLink添加进了runloop,它就已经准备好进行回调了,每当屏幕刷新的时候,就会调用我们注册的回调方法。运行我们的程序,就会发现控制台开始疯狂的进行打印输出。NSLog是日志打印,所以能提供该次打印的系统时间,看看两次打印的间隔,差不多在1/60秒左右,即在15:00:49一秒内打印了60次。

2.2 线性插值

为了实现基于CADisplayLink的动画,我们首先要弄清一个概念:插值。插值在不同的地方有不同的解释。大家思考一下,我们现在要自己在每一帧进行重绘来实现动画,想象这样一个动画:让一个质点从(10,20)点移动到(300,400),持续时间2.78秒。我们要做的是,在每一次屏幕刷新的时候根据当前已经经历的时间(从动画开始到当前时间)计算出该质点的坐标点并更新它的坐标,也就是我们要解决的是:对于任意时刻t,质点的坐标是多少?

这里我们将引入线性插值,我们把问题改一下:你现在距离家f米,学校距离家t米,现在你要从当前的位置匀速走到学校,整个过程将持续d秒,问:当时间经过△t后,你距离家多远?

这是一道很简单的匀速直线运动问题,首先根据距离和持续时间来获得速度:

v = (t-f)/d

然后用速度乘以已经经过的时间来获得当前移动的距离:

△s = v△t = (t-f)/d * △t

最后再用已经移动的距离加上初始的距离得到当前距离家有多远:

s = △s + f = (t-f)/d * △t + f

我们把上面的公式稍微变一下形:

s = f + (t-f) * (△t/d)

这里令p = △t/d就有:

s = f + (t-f) * p

这就是线性插值的公式:

value = from + (to - from) * percent

from表示起始值,to表示目标值,percent表示当前过程占总过程的百分比(上个例子中就是当前已经经历的时间占总时间的百分比所以是△t/d),这个公式成立的前提是变化是线性的,也就是匀速变化,所以叫做线性插值。

有了这个公式,我们回到代码上面来,使用CADisplayLink加上线性插值来计算每帧所需的数据以实现一个匀速动画

1.3 基于CADisplayLink的动画

我们已经构建好了CADisplayLink,剩下的只需要添加一个视图然后在CADisplayLink的回调方法中改变视图的坐标就行了,创建一个view。

@property (nonatomic, strong) UIView * myView;

- (UIView *)myView {
    if (!_myView) {
        _myView = [[UIView alloc] initWithFrame:CGRectMake(0, 0, 80, 80)];
        _myView.backgroundColor = [UIColor yellowColor];
    }
    return _myView;
}

接下来我们用一个私有方法来实现线性插值的公式:

- (CGFloat)interpolateFrom:(CGFloat)from to:(CGFloat)to percent:(CGFloat)percent {
    return from + (to - from) * percent;
}

然后在onDisplayLink方法中解决以下问题:

1.计算当前经历的时间 
2.当前时间占总时间的百分比 
3.利用线性插值计算当前的坐标 
4.更新视图的坐标

首先是如何计算当前经历的时间,由于每次调用onDisplayLink的间隔都不是平均的,我们就不能通过调用次数乘以间隔来得到当前经历的时间,只能用当前时刻减去动画开始的时刻,所以我们声明一个属性用来记录动画开始的时刻,并且在把CADisplayLink添加进runloop的代码后面赋值。

self.beginTime = CACurrentMediaTime();

这样我们就可以在onDisplayLink方法里面这样获取动画经历的时间了:

NSTimeInterval currentTime = CACurrentMediaTime() - self.beginTime;

然后计算出百分比,我们先在方法开头定义出动画的起始值、终止值、持续时间:

CGPoint fromPoint = CGPointMake(10, 20);
CGPoint toPoint = CGPointMake(300, 400);
NSTimeInterval duration = 2.78;

这样的话百分比就是:

CGFloat percent = currentTime / duration;

然后使用线性插值来计算视图的x和y,直接调用公式即可:

CGFloat x = [self _interpolateFrom:fromPoint.x to:toPoint.x percent:percent];
CGFloat y = [self _interpolateFrom:fromPoint.y to:toPoint.y percent:percent];

接下来直接使用计算结果来更新视图的center:

self.myView.center = CGPointMake(x, y);

运行结果如下

Aug-17-2019 15-29-23.gif

然后运行就能看见,视图如我们所愿的以动画的形式开始移动了

1.4 非线性的插值

刚才的动画是基于线性插值来实现的,也就是匀速变化,如果我们要实现类似ease效果的变速运动应该如何来做呢?这里对大家的数学能力有一定挑战了。

我们先来看一个easeIn的效果,easeIn的s-t图像大概是这样的:

image.png

首先要搞清楚x和y分别代表什么。为了让我们的函数能在任意一种动画情况中使用,我们把定义域和值域都设置为[0,1],那么x代表的就是动画时间的进程了,y代表的就是动画值的进程。进程的意思表示当前值占总进度的百分比,比如考虑这样一个函数y = f(x) = x^2(抛物线函数,拥有easeIn的效果,也就是点的斜率随着x的增大而增大),其中一个点(0.5, 0.25)代表的就是当动画时间进行到50%的时候,动画进程执行了25%。

如果对动画进程还有不清楚的地方,考虑上面一个动画的例子,视图的center.x从10变为300,也就是f=10, to=300,那么动画进程s就等于视图的x已经改变的值(x-f)除以x一共可以改变的值(t-f)也就是s= (x-f)/(t-f)

那么我们就建立了一个从动画时间进程p到动画值进程s的一个映射(函数):
s = f(p),这个映射只要满足其图像上面的点的斜率随着p的增大而增大就能达到easeIn的效果了,因为点的斜率就代表这一时刻动画的速度,比如s = f(p) = p^2就满足这一easeIn的条件。

这样我们就有了两个方程:

s = (x-f)/(t-f) 
s = f(p)

那我们就解得动画当前值x和时间进程p的关系

x = f(p) * (t-f) + f

其中f(p)是一个缓冲函数,满足值域和定义域均为[0,1],你可以任意修改f(p)的表达式来达到各种不同的变速效果。仔细观察就能发现,当f(p)=p时,就是线性插值,这样我们就可以通过时间来求出p后,把p作用于缓冲函数f(p),返回的值再带进线性插值的公式,就能算出我们的动画值了,而匀速动画的缓冲函数恰好就是f(p)=p。

如果你想实现匀加速动画,恰好匀加速s-t映射就是一个二次函数:s = 1/2at^2 + v0t,其中初速度v0 = 0,那么我们的缓冲函数f(p) = 1/2ap^2。

现在我们可以将代码修改一下以达到一个easeIn的效果。

首先定义一个easeIn的缓冲函数:

- (CGFloat)easeIn:(CGFloat)p {
    return p * p;
}

然后在回调中作用于percent,将回调方法修改为:

- (void)onDisplayLink:(CADisplayLink *)displayLink {
//    NSLog(@"display link callback:%@",[self.dateFormatter stringFromDate:[NSDate date]]);
    // 获取时间间隔
    NSTimeInterval currentTime = CACurrentMediaTime() - self.beginTime;
    CGPoint fromPoint = CGPointMake(10, 20);
    CGPoint toPoint = CGPointMake(300, 400);
    NSTimeInterval duration = 2.78;
    CGFloat percent = currentTime / duration;   // 百分比
    
    if (percent > 1) {  // stop
        percent = 1;
        [displayLink invalidate];
    }
    
    percent = [self easeIn:percent];
    
    // 计算X和Y值
    CGFloat x = [self interpolateFrom:fromPoint.x to:toPoint.x percent:percent];
    CGFloat y = [self interpolateFrom:fromPoint.y to:toPoint.y percent:percent];
    
    // 赋值
    self.myView.center = CGPointMake(x, y);
}

这样我们就有了一个匀速加速启动的效果了,运行看看。

Aug-17-2019 15-38-59.gif

以上就是我们这次关于CADisplayLink的全部内容,我们使用它来实现了一个基于帧重绘的动画,并且我们深入研究了插值和easeIn效果的数学实现。我们将在实践篇中再用一篇来看看CADisplayLink的另一种用法:利用系统自带的一些动画效果实现更多的动画。


本文摘自
iOS CoreAnimation专题——技巧篇(一)CADisplayLink –同步屏幕刷新的神器
非常感谢该作者。


项目链接地址 - CADisplayLinkAnimation