iOS CoreAnimation专题——技巧篇(一)CADisplayLink –同步屏幕刷新的神器

  • iOS绘图系统
    • FPS
    • 绘制动画
  • CADisplayLink
    • 构建CADisplayLink
    • 线性插值
    • 基于CADisplayLink的动画
    • 非线性的插值

iOS绘图系统

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

官方文档

系统体系结构:

iOS CoreAnimation专题——技巧篇(一)CADisplayLink –同步屏幕刷新的神器_第1张图片

可以看到,最上面一层是是应用层(UI层),直接和用户打交道(UIKit框架也就是干这件事的),而真正的绘图层则在下面一层,绿色的这一层。绘图层由3个部分组成:最上面是CoreAnimation,是面向对象的。往下就是更底层的东西了:OpenGL和CoreGraphics,它们提供了统一的接口来访问绘图硬件。而绘图硬件则是绘图真正发生的地方。那我们就可以这样来理解这个体系结构:真正干事的是绘图硬件(通常是GPU),也就是最下面那一块,它负责把像素画到屏幕上。而我们为了命令它画图(如何绘制)需要有方法能访问到它,当然这种硬件层面的东西肯定不能直接访问的,操作系统一定会做限制(如果不加以限制的话可能一些错误的操作将导致系统故障),这里就和面向对象的封装很像了,操作系统封装了硬件层,只提供简单的能够由开发者直接访问的接口,而不同的硬件可能有不同的封装方式,直接访问起来势必相当麻烦(我们的代码需要适配不同的硬件),于是就有了OpenGL,它统一了所有绘图硬件的接口,我们使用OpenGL提供的同一套API就能控制任意的绘图硬件了。而OpenGL虽然很强大,但是很少会用到它一些复杂的功能,而简单的功能也是C语言不太好使用,所以具体地针对iOS和OS X系统,苹果为我们封装了OpenGL,没错这就是CoreAnimation。

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

FPS

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

绘制动画

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

CADisplayLink

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

构建一个CADispalyLink非常的简单,我们先提供一个回调方法:

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

接下来我们初始化一个displayLink,只有一个便利构造方法:

CADisplayLink * displayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(onDisplayLink:)];

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

一个runloop只能在某一个mode中跑,runloop可以在多个mode之间进行切换,默认的,系统提供了两个mode:NSDefaultRunloopMode和UITrackingRunloopMode。正常情况下是default,但是如果一个scrollView滑动的时候(UITableView是scrollView的子类)runloop就会切换到UITrackingRunloopMode,这时候所有往default里面添加的内容都没法跑起来了。这也是为什么,如果使用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。

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

线性插值

为了实现基于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加上线性插值来计算每帧所需的数据以实现一个匀速动画

基于CADisplayLink的动画

我们已经构建好了CADisplayLink,剩下的只需要添加一个视图然后在CADisplayLink的回调方法中改变视图的坐标就行了,很明显,这个视图应该使用成员变量或者属性来声明。

@property (nonatomic, strong) UIView * myView;

实现属性的getter方法

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

在viewDidLoad中添加:

[self.view addSubview:self.myView];

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

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

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

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

首先是如何计算当前经历的时间,由于每次调用onDisplayLink的间隔都不是平均的,我们就不能通过调用次数乘以间隔来得到当前经历的时间,只能用当前时刻减去动画开始的时刻,所以我们声明一个属性用来记录动画开始的时刻:

@property (nonatomic, assign) NSTimeInterval beginTime;

在把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);

然后运行就能看见,视图如我们所愿的以动画的形式开始移动了(这里由于我的录制gif软件的原因,动画看起来有点卡帧,实际上动画是相当平滑的)。
iOS CoreAnimation专题——技巧篇(一)CADisplayLink –同步屏幕刷新的神器_第2张图片
但是有一个问题:动画根本停不下来!这是由于我们没有停止CADisplayLink,所以onDisplayLink会不停地调用,所以当percent超过1的时候,视图会朝着我们既定的方向继续移动。

正确的停止CADisplayLink的方式是这样的:在计算出percent之后进行判断

if (percent > 1) {
        percent = 1;
        [displayLink invalidate];
  }

如果少了percent = 1这一行,就会造成一个很小的误差,但是千万不能小看这个误差,我们应该杜绝任何误差的产生。
再次运行:
我们的视图就停在了(300,400)的位置

iOS CoreAnimation专题——技巧篇(一)CADisplayLink –同步屏幕刷新的神器_第3张图片

非线性的插值:

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

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

iOS CoreAnimation专题——技巧篇(一)CADisplayLink –同步屏幕刷新的神器_第4张图片

首先要搞清楚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
{
    CGPoint fromPoint = CGPointMake(10, 20);
    CGPoint toPoint = CGPointMake(300, 400);
    NSTimeInterval duration = 2.78;
    NSTimeInterval currentTime = CACurrentMediaTime() - self.beginTime;

    CGFloat percent = currentTime / duration;
    if (percent > 1) {
        percent = 1;
        [displayLink invalidate];
    }

    percent = [self easeIn:percent];

    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);
}

这样我们就有了一个匀速加速启动的效果了,运行看看。
iOS CoreAnimation专题——技巧篇(一)CADisplayLink –同步屏幕刷新的神器_第5张图片

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

你可能感兴趣的:(iOS动画)