编程语言爱好者iOS开发者IT面试

NSTimer 和 CADisplayLink 详解以及如何做一

2018-03-31  本文已影响40人  BennyLoo

昨天看到一篇文章,讲述的是 NSTimer 和 CADisplayLink 这两个定时器的区别。刚好有感而发,突然想到了测试屏幕FPS的小工具实现思路。

我们知道 NSTimer 和 CADisplayLink 都有定时的功效。区别在于 NSTimer 基于时间,而 CADisplayLink 基于帧率。我们设置 NSTimer 定时器的间隔为1/60秒执行任务的时候,实际效果就和 CADisplayLink 类似,因为 CADisplayLink 基于帧率执行,而iOS手机的刷新频率就是一秒60次,相当于每1/60秒刷新一次(默认),不管是NSTimer 还是 CADisplayLink,对于界面的刷新,他们的最小的执行间隔时间都不会小于 1/60 秒,因为屏幕刷新没那么快,就算小于这个时间,也是徒劳。

按照这个逻辑,我们想要实现每1/60秒做一次动作的话,使用 NSTimer 和 CADisplayLink 都可以实现。但是 NSTimer 的 timeInterval 属性是一个浮点数,我们可以随意调整想要的时间间隔,而 CADisplayLink 只能基于帧率执行动作,尽管它有一个名为 frameInterval 的整型属性(默认值为1,代表1帧刷新一次,修改为2则为2帧刷新一次,也就是1/30秒刷新一次)可以用来更改执行的频率,但是能更改的数值依然有限。但是 CADisplayLink 有它适合的使用场景,今天要介绍的测试FPS小工具就是其一。

NETimer的使用

如果要使用自定义数值的定时器,最好使用 NSTimer,对于重复的行为,它能让我们自定义任意的时间间隔 。就像这样:

{
  _timer = [NSTimer timerWithTimeInterval:2 target:self selector:@selector(timerAction) userInfo:nil repeats:YES];
    [[NSRunLoop currentRunLoop] addTimer:_timer forMode:NSDefaultRunLoopMode];
}

- (void)timerAction{
    NSLog(@"\n定时器  ---   %f",CACurrentMediaTime());
}
NSTimer执行在Runloop中

但是使用 NSTimer 并非没有缺陷。在程序主线程的 RunLoop 中,会处理一下事情:

这些时间被添加到runloop中之后,统统被称之为 dataSource 或 timeSource(也就是事件源) 。NSTimer 属于 timeSource ,因此,每次创建一个定时器,需要将之加入到对应线程中的runloop才能够被执行。这样就是为什么上述代码会有一段这个代码:

    [[NSRunLoop currentRunLoop] addTimer:_timer forMode:NSDefaultRunLoopMode];

NSRunLoop 有很多个mode,我们默认使用的是 NSDefaultRunLoopMode,除此之外,还有 NSRunLoopCommonModes 以及 UITrackingRunLoopMode
等mode,这两个mode分别用于不同的场景。我们有遇到过同一个界面中的NSTimer和UIScrollView不会同时执行,每当我们滑动UIScrollView的时候,NSTimer 就会暂停,原因是RunLoop的mode有优先级,当滑动UIScrollView,Runloop 切换为 UITrackingRunLoopMode,这时候,NSTimer 所处的NSDefaultRunLoopMode 被搁置,NSTimer也就暂停了,解决办法是将 NSTimer 加入到 NSRunLoopCommonModes 或者 UITrackingRunLoopMode中。

重复执行的NSTimer导致内存泄漏

NSTimer 加入到Runloop之后被执行的原理是,Runloop 中有一个和NSTimer可 toll-free-bridge 的类 —— CFRunLoopTimerRef,它直接和NSTimer混用,其中包含了一个定时器和回调函数。并持有NSTimer的target,以备后续调用。对于非重复的NStimer,执行完一次回调函数之后,会直接释放NSTimer,这样NSTimer也就不会持有targat,当target释放的时候,NSTimer 跟随释放,我们不需要做处理。但是,对于重复执行的NSTimer,NSTimer的target一直得不到释放,这样就会造成引用循环,导致内存泄漏。解决办法是在Targeth(UIView/UIViewController)消失之前,使用NSTimer的 invildata,将NSTimer对象停止,以解除引用关系。

NSTimer的使用弊端

NSTimer能够间隔时间重复执行某个函数,但是,它的时候的京都并不能保证。我们在一些老设备上,经常会看到NSTimer定时器的延迟和条数。比如我创建了一个每两秒执行一次的定时器,但是它不会精确在每两秒,它可能延后执行,这种情形给人一种跳动的感觉,在一般的数据处理还好。但是在动画的场景里,会显得动画极不流畅。

这个原因是什么呢?

我们将NSTimer放入Runloop的之后,如果runloop中只有一个该定时器的事件源,那么这个定时器将会精准的执行,可是显然,runloop要做的工作并不少,它内部还有其他各式的事件源,这些事件源有的是触摸事件,有的是数据发送事件,还有其他的定时器,它们都会经过一个有序的顺序执行, 如果某个在定时器之前的事件执行需要花费很长的时间,那么就会导致后面的定时器不能准时执行。这就造成了定时器执行时间的跳动问题。 这个跳动不会引起很大的问题,因为,在一个较大的时间内,定时器执行的次数是不会变的,套用一句话:定时器的执行可能迟到,但是不会不来。

从这里看出,NSTimer不适合做合成动画有关的定时器,因为它的执行时间跳动将给动画带来卡顿的感觉。

首先我们要明白,屏幕刷新的频率在每秒60次,也就是说,系统在每一次绘制之前都有 1/60 的时间去计算即将绘制的内容,然后渲染到视图上,如果没有及时绘制完成,那么就会等到下一次绘制的时间点上将图像铺上。 一般简单的绘制,会在这个时间内完成, 我们能看到流畅的画面,如果绘制的图像过于复杂,那就可能需要超过1/60秒的时间进行绘制,这时候会出现下一帧的画面显示的还是上一个时间点的绘制内容,也就是出现画面暂停,在我们肉眼看来就是卡顿了。
定时器的执行时间跳动会让1/60秒执行的内容出现漏洞,它很容易因为时间跳动错过了一次系统的刷新,这个跟图像渲染无关,跟执行的时间点有关,即使我们精确定义定时器每 1/60 秒执行一次也因为执行时间跳动而错过帧率。

CADisplayLink的使用

上面有提到,NSTimer并不适合做动画有关的计时器。而CADisplayLink几乎是为这个而生的。

CADisplayLink 的执行间隔不是具体的时间,而是在每一帧执行前会调用它的执行。如果屏幕的帧率为60的话,也就是每1/60秒执行一次。这样避免了一个问题,那就是如果因为图像的绘制耗时太久,错过了某个时间点的渲染,但是没关系,CADisplayLink 不会因此而乱掉,我们可以设置。

当然,不管是 CADisplayLink 和 NSTimer,都不能完全避免帧率不均导致的屏幕卡顿或者变慢。 我们要更加流畅的显示画面的时候,最好还是手动计算每一帧之间的时间间隔,然后在每帧被渲染之前调用执行。这个思路有人已经实现,这里就不详细说了。

使用CADisplayLink的帧前调用特性制作一个FPS测试

CADisplayLink 的特性是每一帧画面之前调用,利用这个特性我们可以实现一个简单的FPS测试工具。基本原理很简单,统计每一秒中 CADisplayLink 的执行次数就可以了。正常的状态,这个值为60。如果出现画面卡顿的时候,这个值会降低。我们要制作的工具就是实时的显示当前的FPS值。代码如下:
.h 文件

#import <UIKit/UIKit.h>

@interface FPSWhatchDog : UIView

+ (void)showFPSLabel;
+ (void)dismissFPSLabel;

@end

.m 文件

#import "FPSWhatchDog.h"

@interface FPSWhatchDog ()

@end

@implementation FPSWhatchDog
{
    CADisplayLink *_dispalyLink;
    UILabel *_fpsLabel;
    NSInteger _fps;
    NSTimeInterval _lastTime;
}

+ (id)shareInstance{
    static FPSWhatchDog* tool = nil;
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        tool = [[FPSWhatchDog alloc] init];
    });
    return tool;
}

+ (void)showFPSLabel{
    FPSWhatchDog *dog = [FPSWhatchDog shareInstance];
    [dog showLabel];
    [dog openDispalyLink];
}

+ (void)dismissFPSLabel{
    FPSWhatchDog *dog = [FPSWhatchDog shareInstance];
    [dog invaildDisplayLink];
    [dog removeLabel];
}

- (void)showLabel{
    _fpsLabel = [[UILabel alloc] initWithFrame:CGRectMake(0, 0, 80, 30)];
    _fpsLabel.text = @"60 FPS";
    _fpsLabel.textColor = [UIColor greenColor];
    _fpsLabel.backgroundColor = [UIColor darkGrayColor];
    _fpsLabel.layer.cornerRadius = 15.0;
    _fpsLabel.center = CGPointMake([UIScreen mainScreen].bounds.size.width/2.0, 85);
    _fpsLabel.textAlignment = NSTextAlignmentCenter;
    UIWindow *keyWindow = [[UIApplication sharedApplication] windows].firstObject;
    _fpsLabel.layer.zPosition = 100;  //浮在最上面
    [keyWindow addSubview:_fpsLabel];
}

- (void)openDispalyLink{
    _fps = 0;
    _lastTime = (NSTimeInterval)CACurrentMediaTime(); //获取当前APP开启时间
    _dispalyLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(countFPS)];
    [_dispalyLink addToRunLoop:[NSRunLoop currentRunLoop] forMode:NSRunLoopCommonModes];
}


- (void)invaildDisplayLink{
    [_dispalyLink invalidate];
}

- (void)removeLabel{
    [_fpsLabel removeFromSuperview];
}

- (void)countFPS{
    if (_lastTime + 0.5 <= (NSTimeInterval)CACurrentMediaTime()) {
        _lastTime = (NSTimeInterval)CACurrentMediaTime();
        _fpsLabel.text = [NSString stringWithFormat:@"%d FPS",(int)_fps * 2];
        _fps = 0;
    }else{
        _fps++;
    }
}

@end

整个类暴露的只有两个方法,一个是调用方法,另一个是移除方法。
下面是我在5S模拟器下测试的效果,测试内容是在tableView上的每一个Cell呈现的时候,更改cell 上的几张图片,然后滑动,发现卡顿现象。这时候显示的FPS值会反馈出来。


2018-03-31 17_17_47.gif

代码量很少,Demo不贴了,直接copy过去就行。

上一篇下一篇

猜你喜欢

热点阅读