昨天看到一篇文章,讲述的是 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 中,会处理一下事情:
- 处理触摸事件
- 发送和接受网络数据包
- 执行使用GCD代码
- 处理定时器执行行为
- 屏幕重绘
这些时间被添加到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值会反馈出来。
代码量很少,Demo不贴了,直接copy过去就行。