基于 CADisplayLink 的 FPS 指示器详解

前言

之前在开发中有使用到计时器NSTimer,后来了解到iOS中不同的计时方法,其中就包括了CADisplayLink。基于CADisplayLink以屏幕刷新频率同步绘图的特性,尝试根据这点去实现一个可以观察屏幕当前帧数的指示器。

结论在前

根据CADisplayLink所实现的FPS指示器在实际生产场景下只有指导意义,不能代表真实的FPS,具体原因见下文。

什么是CADisplayLink

CADisplayLinkCoreAnimation提供的另一个类似于NSTimer的类,它总是在屏幕完成一次更新之前启动,它的接口设计的和NSTimer很类似,所以它实际上就是一个内置实现的替代,但是和timeInterval以秒为单位不同,CADisplayLink有一个整型的frameInterval属性,指定了间隔多少帧之后才执行。默认值是1,意味着每次屏幕更新之前都会执行一次。但是如果动画的代码执行起来超过了六十分之一秒,你可以指定frameInterval为2,就是说动画每隔一帧执行一次(一秒钟30帧)或者3,也就是一秒钟20次,等等。

可以在这个链接中关于CADisplayLink的部分查看更多关于CADisplayLink的用法

一、初步尝试

思路:既然CADisplayLink可以以屏幕刷新的频率调用指定selector,而且iOS系统中正常的屏幕刷新率为60Hz(60次每秒),那只要在这个方法里面统计每秒这个方法执行的次数,通过次数/时间就可以得出当前屏幕的刷新率了。

二话不说这代码我先码为敬。

- (void)setupDisplayLink {

//创建CADisplayLink,并添加到当前run loop的NSRunLoopCommonModes

_displayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(linkTicks:)];

[_displayLink addToRunLoop:[NSRunLoop currentRunLoop] forMode:NSRunLoopCommonModes];

}

- (void)linkTicks:(CADisplayLink *)link

{

//执行次数

_scheduleTimes ++;

//当前时间戳

if(_timestamp == 0){

_timestamp = link.timestamp;

}

CFTimeInterval timePassed = link.timestamp - _timestamp;

if(timePassed >= 1.f)

//fps

CGFloat fps = _scheduleTimes/timePassed;

printf("fps:%.1f, timePassed:%f\n", fps, timePassed);

//reset

_timestamp = link.timestamp;

_scheduleTimes = 0;

}

}

上述代码实现了一个简单地FPS指示器,每秒统计linkTicks方法的执行次数打印出对应的FPS。

fps:60.0, timePassed:1.015984

fps:60.0, timePassed:1.000630

fps:60.0, timePassed:1.000022

fps:60.0, timePassed:1.016638

打印结果没什么问题,好的,马上将这个类添加到以前一个在模拟器上运行很卡的Demo中验证一下,在模拟器中运行后台打印结果如下:

为了让Demo更卡,Demo中所有UIImageView都使用了圆角并设置了阴影

小结:不错,能统计到帧数的变化,这个FPS指示器也就完成了。

等等!

二、真机测试

我们还要对这个FPS指示器做更多的事情,例如在真机上测试一下。

模拟器运行在你的Mac上,然而Mac上的CPU往往比iOS设备要快。相反,Mac上的GPU和iOS设备的完全不一样,模拟器不得已要在软件层面(CPU)模拟设备的GPU,这意味着GPU相关的操作在模拟器上运行的更慢,尤其是使用CAEAGLLayer来写一些OpenGL的代码时候。

这是iOS-Core-Animation-Advanced-Techniques一书中的第12节的一段话,关于动画、帧率等我们需要在真实的设备上来验证我们的代码。

二话不说我马上在真机上把这个Demo运行起来,为了能更直观的观察FPS,我将FPS显示在了屏幕上,另外运行的设备是一台运行iOS9的iPod Touch5,性能与iPhone4s差不多。

从GIF上可能不能直观的看出来,但是就本人的感受来看,除了在切换图片时有一点卡顿,其他时候都感觉挺流畅的,而且重复加载过的图片之后再加载,就不会再造成卡顿了。

在Instrument上测量的结果也是大致相同。

小结:在真机和模拟器上动画的表现确实不一样(模拟器卡,真机流畅),到此为止我们的FPS指示器仍然能正确反应屏幕的FPS。

三、极端情况

但是上面的Demo仍然不够极端,所以我们来看下面的这个Demo:在一个普通的列表里面,我们准备了1000条数据,每条数据包含了一张图片(头像)和一段文本(名字),用于在列表的Cell里面显示。每张图片都设置了圆角,且图片与文本都设置了阴影。具体代码如下:

@implementation DemoViewController
- (void)viewDidLoad {
    [super viewDidLoad];

    //1000条记录,每条记录包含一个名字和一个头像
    NSMutableArray *array = [NSMutableArray array];
    for (int i = 0; i< 1000; i++) {
        [array addObject:@{@"name": [self randomName], @"image": [self randomAvatar]}];
    }

    self.items = array;
    [self.tableView registerClass:[UITableViewCell class] forCellReuseIdentifier:@"Cell"];
}

#pragma mark - UITableViewDataSource
- (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section {
    return [self.items count];
}

- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
    UITableViewCell *cell = [self.tableView dequeueReusableCellWithIdentifier:@"Cell" forIndexPath:indexPath];

    NSDictionary *item = self.items[indexPath.row];
    NSString *filePath = [[NSBundle mainBundle] pathForResource:item[@"image"] ofType:@"png"];

    //name and image
    cell.imageView.image = [UIImage imageWithContentsOfFile:filePath];
    cell.textLabel.text = item[@"name"];

    //image shadow
    cell.imageView.layer.shadowOffset = CGSizeMake(0, 5);
    cell.imageView.layer.shadowOpacity = 1;
    cell.imageView.layer.cornerRadius = 5.0f;
    cell.imageView.layer.masksToBounds = YES;

    //text shadow
    cell.textLabel.backgroundColor = [UIColor clearColor];
    cell.textLabel.layer.shadowOffset = CGSizeMake(0, 2);
    cell.textLabel.layer.shadowOpacity = 1;

    return cell;
}

- (NSString *)randomName {
    NSArray *first = @[@"Alice",@"Bob",@"Bill"];
    NSArray *last = @[@"Appleseed",@"Bandicoot",@"Caravan"];
    NSUInteger index1 = (rand()/(double)INT_MAX) * [first count];
    NSUInteger index2 = (rand()/(double)INT_MAX) * [last count];
    return [NSString stringWithFormat:@"%@ %@", first[index1], last[index2]];
}

- (NSString *)randomAvatar {
    NSArray *images = @[@"A",@"B",@"C"];
    NSUInteger index = (rand()/(double)INT_MAX) * [images count];
    return images[index];
}
@end

下面我们来运行一下这个Demo:

慢速滑动时画面不流畅

显而易见,一开始当我快速滑动列表的时候,FPS下降到肉眼能识别的程度,屏幕上的FPS指示器的数字也同步下降到只有10不到。

而后面当我继续慢速滑动列表的时候,看得出列表滑动依然很不流畅,但FPS指示器却保持着55FPS以上,而且与Instrument中Core Animation FPS所显示的不同。

小结:CADisplayLink保持与屏幕刷新率一致的频率触发指定方法,我们根据此来实现FPS指示器。但上面的Demo中却发现FPS指示器无法检测到真是设备上卡顿,造成这个结果的原因是什么?

四、详细分析

iOS中每一帧画面的生成是一个复杂的过程,但简单来说需要经过以下步骤:

1、系统根据你的代码,设置布局各个元素的位置(frame、AutoLayout)、属性(颜色、透明度、阴影等)。
2、CPU对需要提前绘制的元素、图形使用Core Graphics进行绘制。
3、CPU将一切需要绘制到屏幕上的内容(包括解压后的图片)打包发送到GPU
4、GPU对内容进行计算绘制,显示到屏幕上。

所以,在上面的Demo中造成性能下降的原因有两个。

1、滑动列表时(即使是慢速滑动),GPU都需要计算图像、文本的动态阴影的位置和形状来进行阴影的绘制,此时GPU将成性能瓶颈,能明显观察到FPS的下降。
2、快速滑动列表时Cell每次在显示前都需要通过imageWithContentsOfFile从硬盘加载图片并解压,此时文件的IO,图片的解压让CPU也遇到性能瓶颈,使主线程无法流畅执行,让FPS雪上加霜。

上述的两个性能问题我们可以通过下面的方法来验证并解决

1、对于第一个问题,我们可以直接禁用阴影来解决问题。

或者通过代码开启光栅化,在一定程度上优化列表的滑动性能(由于当Cell滑动出屏幕后,Cell中的内容会改变,缓存过的位图会被重新生成,所以开启光栅化的效果并不明显)。

//开启光栅化

cell.layer.shouldRasterize = YES;

cell.layer.rasterizationScale = [[UIScreen mainScreen] scale];

2、对于第二个问题,我们可以使用imageNamed:方法代替原来的图片加载方法

//会一直存在内存中

cell.imageView.image = [UIImage imageNamed:item[@"image"]];

//不会一直占用内存,但需要进行IO并每次都解压图片

NSString *filePath = [[NSBundle mainBundle] pathForResource:item[@"image"] ofType:@"png"];

cell.imageView.image = [UIImage imageWithContentsOfFile:filePath];

同时上面两个原因也解释了为什么在列表慢速滑动的情况下,FPS指示器与Instrument中显示的FPS不一致:

CADisplayLink运行在主线程RunLoop之中,RunLoop中所管理的任务的调度时机受任务所处的RunLoopMode和CPU的繁忙程度所影响。
在第二个原因中受文件IO、解压图片的影响,RunLoop 自然无法保证CADisplayLink被调用的次数达到每秒60次,这里的调用频率正是我们的FPS指示器中所显示FPS。
而在第一个原因中主要瓶颈在于GPU,即使RunLoop能保持每秒60次调用CADisplayLink,也无法说明此时的屏幕刷新率能达到60FPS(Core Animation通过与OpenGl打交道控制GPU进行屏幕绘制),也正因为这样FPS指示器显示55+的FPS,但Instrument中的Core Animation FPS 却很低。

小结:通过对iOS中屏幕绘制过程的分析,了解到基于CADisplayLink实现的FPS指示器无法完全检测出当前Core Animation性能情况,因为它只能检测出当前RunLoop的帧率。不过这个帧率可以对某些性能问题(如上面的第二个性能问题)给出参考,但要真正定位到准确的性能问题所在,最好还是通过Instrument来确认。

Instrument性能调优

1、Core Animation
查看App帧率,查看是否有元素产生了离屏渲染、光栅化是否有效、像素是否对齐等。
2、OpenGL ES Analysis
查看GPU的使用情况。
3、Time Profile
查看CPU使用情况,定位消耗大量CPU资源的方法等。

有兴趣的读者可以自行尝试。

五、其他

本文在上面一节中本应结束了,但最后我还想将自己在这个过程中尝试的其他一些东西记录下来。

在上述FPS指示器中,如果将CADisplayLink放置于子线程的Runloop中,将会发生什么?

- (void)setupDisplayLink

{

_displayLink = [CADisplayLink displayLinkWithTarget:self selector:@selector(linkTicks:)];

[_displayLink setPaused:YES];

//添加到子线程中

NSThread *thread = [[self class] fpsThread];

[self performSelector:@selector(scheduleLink) onThread:thread withObject:nil waitUntilDone:NO modes:[[NSSet setWithObject:NSRunLoopCommonModes] allObjects]];

}

#pragma mark - fps thread

+ (NSThread *)fpsThread;

{

static NSThread *fpsThread;

static dispatch_once_t onceToken;

dispatch_once(&onceToken, ^{

fpsThread = [[NSThread alloc] initWithBlock:^(){

//开启子线程的RunLoop

[[NSRunLoop currentRunLoop] run];

}];

fpsThread.name = @"WWFpsThread";

[fpsThread start];

});

return fpsThread;

}

答案是无论主线程有多么繁忙,GPU占用有多么高,FPS始终是60,原因是基于CADisplayLink的FPS指示器只能检测到当前RunLoop(子线程上)的FPS,也进一步证明了前文的分析。

觉得FPS指示器不够灵敏,想让FPS指示器的更新频率再高一点

- (void)linkTicks:(CADisplayLink *)link

{

//执行次数

_scheduleTimes ++;

//当前时间戳

if(_timestamp == 0){

_timestamp = link.timestamp;

}

CFTimeInterval timePassed = link.timestamp - _timestamp;

//判断时间时根据1.f/_frequency来判断

//手动设置frequency的值,frequency值越大,更新频率越高

if(timePassed >= 1.f/_frequency)

//fps

CGFloat fps = _scheduleTimes/timePassed;

printf("fps:%.1f, timePassed:%f\n", fps, timePassed);

//reset

_timestamp = link.timestamp;

_scheduleTimes = 0;

}

}

更新频率过高的话会影响性能

参考资料

iOS-Core-Animation-Advanced-Techniques一书中关于CADisplayLink与性能调优的章节

https://github.com/AttackOnDobby/iOS-Core-Animation-Advanced-Techniques

本文中实现的FPS指示器与Demo,指示器支持利用手势移动位置,可以配置fps的更新频率。

WWFPSIndicator

https://github.com/Tidusww/WWFPSIndicator

其他网上搜到的FPS指示器,仅用于与自己实现的指示器作功能与性能上的比较

JPFPSStatus

https://github.com/joggerplus/JPFPSStatus/issues

YWFPSLabel

http://t.cn/RIHLnVx

时间: 2024-11-16 15:29:12

基于 CADisplayLink 的 FPS 指示器详解的相关文章

基于jQuery的TreeGrid组件详解

一.TreeGrid组件相关的类 1.TreeGrid(_config) _config:json格式的数据,组件所需要的数据都通过该参数提供. 2.TreeGridItem(_root, _rowId, _rowIndex, _rowData) _root:显示组件实例的目标容器对象. _rowId:选中行的id. _rowIndex:选中行的索引. _rowData:json格式的行数据. 二._config参数详解 id:组件实例的id. width:组件实例的宽度. renderTo:用

Unix Shell_Oracle EBS基于主机文件Host开发详解(案例)

2014-06-20 BaoXinjian 一.摘要 Oracle 并发程式中Host Type的可执行程式,它的作用是用于调用Unix Shell去执行某些需求 个人觉得Oracle EBS中引入Host去调用unix shell其弥补了很多PLSQL类型程式无法做的某些功能,以unix shell的语法结构直接对服务器进行操作 写host并发程式时,需要较强的Bash语法知识,个人不做DBA,只了解一部分,所以就不具体介绍了,只说明一下Oracle EBS开发Unix Shell时需要注意的

php_ThinkPHP的RBAC(基于角色权限控制)详解

一.什么是RBAC 基于角色的访问控制(Role-Based Access Control)作为传统访问控制(自主访问,强制访问)的有前景的代替受到广泛的关注. 在RBAC中,权限与角色相关联,用户通过成为适当角色的成员而得到这些角色的权限.这就极大地简化了权限的管理. 在一个组织中,角色是为了完成各种工作而创造,用户则依据它的责任和资格来被指派相应的角色,用户可以很容易地从一个角色被指派到另一个角色.角色可依新的需求和系统的合并而赋予新的权限,而权限也可根据需要而从某角色中回收.角色与角色的关

mysql5.6 基于GTID及多线程复制详解

一 GTID 详解 官方文档:http://dev.mysql.com/doc/refman/5.6/en/replication-gtids.html在这篇文档里,我们可以知道全局事务 ID 的官方定义是:GTID = source_id:transaction_id MySQL 5.6 中,每一个 GTID 代表一个数据库事务.在上面的定义中,source_id 表示执行事务的主库 uuid(server_uuid),transaction_id 是一个从 1 开始的自增计数,表示在这个主库

thinkphp基于角色的权限控制详解

一.什么是RBAC 基于角色的访问控制(Role-Based Access Control)作为传统访问控制(自主访问,强制访问)的有前景的代替受到广泛的关注. 在RBAC中,权限与角色相关联,用户通过成为适 基于角色的访问控制(Role-Based Access Control)作为传统访问控制(自主访问,强制访问)的有前景的代替受到广泛的关注. array( 'APP_AUTOLOAD_PATH'=>'@.TagLib', 'SESSION_AUTO_START'=>类型 1 登录认证 2

基于Ehcache的Spring缓存详解

一 简介 缓存,通过将数据保存在缓冲区中,可以为以后的相同请求提供更快速的查询,同时可以避免方法的多次执行,从而提高应用的性能. 在企业级应用中,为了提升性能,Spring提供了一种可以在方法级别上进行缓存的缓存抽象.通过使用AOP原则,Spring对使用缓存的方法自动生成相应代理类,如果已经为提供的参数执行过该方法,那么就不必重新执行实际方法而是直接返回被缓存的结果.在基于Spring的Web应用中,为了启用缓存功能,需要使用缓存注解对待使用缓存的方法进行标记. Spring缓存仅仅提供了一种

基于php5.6 php.ini详解

PHP中auto_prepend_file与auto_append_file的用法 第一种方法:在所有页面的顶部与底部都加入require语句.例如:?123require('header.php');//页面正文内容部分require('footer.php');但这种方法如果需要修改顶部或底部require的文件路径,则需要修改所有页面文件.而且需要每个页面都加入require语句,比较麻烦.第二种方法:使用auto_prepend_file与auto_append_file在所有页面的顶部

SylixOS 基于ZYNQ的时钟频率修改详解

概述 本文档以ZYNQ7000平台为例,详细介绍如何去修改ZYNQ的时钟频率. 时钟频率修改流程 ZYNQ7000的时钟频率修改流程,如图 2.1所示.具体步骤如下: 步骤一:解除ZYNQ7000的寄存器写锁定: 步骤二:向对应寄存器写入我们需要设置的PLL倍频值和PLL配置参数: 步骤三:进行PLL的旁路模式转换和软件重启,使我们刚刚设置的PLL倍频值和PLL配置参数生效: 步骤四:重新使寄存器处于写锁定状态. 图 2.1 ZYNQ7000的时钟频率修改流程图 ZYNQ7000的ARM_PLL

本地机apache配置基于域名的虚拟主机详解

1.打开apache的httpd.conf文件,找到# Virtual hosts#Include conf/extra/httpd-vhosts.conf这一段把Include conf/extra/httpd-vhosts.conf前面的"#"去掉. 2.修改位于(win7)c:/windows/system32/drivers/etc/目录下的hosts文件增加一段:127.0.0.1    x.acme.com(你用来访问的域名) 3.我用的是wamp包,所以到c:/wamp/