移动端FPS优化已经是一个老生常谈的话题了,但在相当长一段时间内却一直是一个不过期的话题,除非硬件强大到可以帮我们抹平屏幕成像和渲染上的性能损耗。身为一个移动互联网从业者,对FPS的认识和优化依旧是很有限的,深感不安和羞愧,本文整理了之前的一些工作笔记,结合一些大牛们的优秀文章,希望能够起到复习和深化的作用。内容不实之处还请大家及时指出,感谢!
即 Frame Rate,单位 fps,是指 gpu 生成帧的速率,如 33 fps,60fps,越高越好。
即 Refresh Rate 或 Scanning Frequency,单位赫兹/Hz,是指设备刷新屏幕的频率,该值对于特定的设备来说是个常量,如 60hz。
如下图,屏幕的刷新过程是每一行从左到右(行刷新,水平刷新,Horizontal Scanning),从上到下(屏幕刷新,垂直刷新,Vertical Scanning)。当整个屏幕刷新完毕,即一个垂直刷新周期完成,会有短暂的空白期,此时发出 VSync 信号。所以,VSync 中的 V 指的是垂直刷新中的垂直/Vertical。
那什么是Vsync/垂直同步信号呢? iOS和Android系统中有 2 种 VSync 信号,屏幕产生的硬件VSync信号和负责给GPU的软件信号(CADisplayLink)。 VSync: 垂直同步信号,又叫做帧同步信号,表示扫描1帧的开始,一帧也就是LCD显示的一个画面。Vsync信号是由硬件时钟产生的一个脉冲信号,起到开关或触发某种操作的作用。Vsync会以固定的频率产生,不受软件的影响(只要有电就会产生)。这个固定的频率叫做屏幕刷新频率(refresh rate或者Scanning Frequency)。通常情况下,这个频率是60hz。也就是1/60s == 16.666ms就会产生一个垂直同步信号。ps:另外还有帧率/frame rate ,单位 fps,是指 gpu 生成帧的速率,如 33 fps,60fps,越高越好。屏幕刷新频率和帧率没有什么关系。 另外还有水平同步信号HSync,如下是工作原理图:
PS:更多信息请自行复习《计算机组成原理》或《数字电路与逻辑设计》等大学教材。
通常来时,计算机系统的CPU、GPU、显示器是以一种类似于串行的方式协同工作的。如下图,CPU计算好显示的内容提交给GPU;GPU把CPU提交过来的内容渲染成显示器可以显示的格式(也就是我们常说的一帧)。GPU渲染完成后将渲染结果(也就是一帧画面)放到屏幕的帧缓冲区(此处的帧缓冲区和离屏渲染的屏幕缓冲区、屏幕外缓冲区是一回事);随后视频控制器会按照VSync(垂直同步信号)读取帧缓冲区的数据,经过数模转换传递给显示器显示。
单缓冲机制。帧缓冲区只有一个,GPU向帧缓冲区提交渲染好的数据,视频控制器从帧缓冲区读取数据显示到屏幕上(典型的生产者—消费者模型)。这时帧缓冲区的读取和刷新都都会有比较大的效率问题。
注意,此处的“双缓冲”和计算机组成原理中的“二级缓存”是两回事。三重缓存也是如此。
为了解决单缓冲的效率问题,显示系统通常会引入两个缓冲区,即双缓冲机制,实际上iOS设备也是这么做的。双缓冲机制下,GPU 会预先渲染好一帧放入一个缓冲区内,让视频控制器读取,当下一帧渲染好后,GPU 会直接把视频控制器的指针指向第二个缓冲器。如此一来效率会有很大的提升。(iOS 保持界面流畅的技巧)
双缓冲虽然能解决单缓冲区效率问题,但会引入一个新的问题。当视频控制器还未读取完成时,即屏幕内容刚显示一半时,GPU 将新的一帧内容提交到帧缓冲区并把两个缓冲区进行交换后,视频控制器就会把新的一帧数据的下半段显示到屏幕上,造成“画面撕裂”现象,我们称之为“screen tearing”,如下图(google搜索画面撕裂即可):
PS:实际上,单缓冲区机制也是存在画面撕裂现象的。例如,当帧率大于刷新频率,当屏幕还没有刷新第 n-1 帧的时候,GPU 已经在生成第 n 帧了,从上往下开始覆盖第 n-1 帧的数据,当屏幕开始刷新第 n-1 帧的时候,Buffer 中的数据上半部分是第 n 帧数据,而下半部分是第 n-1 帧的数据,显示出来的图像就会出现上半部分和下半部分明显偏差的现象,我们称之为 “tearing”。
两个缓存区分别为 Back Buffer 和 Frame Buffer。GPU 向 Back Buffer 中写数据,屏幕从 Frame Buffer 中读数据。VSync 信号负责调度从 Back Buffer 到 Frame Buffer 的复制操作,可认为该复制操作在瞬间完成。其实,该复制操作是等价后的效果,实际上双缓冲的实现方式是交换 Back Buffer 和 Frame Buffer 的名字,更具体的说是交换内存地址(有没有联想到那道经典的笔试题目:“有两个整型数,如何用最优的方法交换二者的值?”),通过按位运算“与”即可完成,所以可认为是瞬间完成。 双缓冲的模型下,工作流程这样的: 在某个时间点,一个屏幕刷新周期完成,进入短暂的刷新空白期。此时,VSync 信号产生,先完成复制操作(交换缓冲区内容),然后通知 CPU/GPU 绘制下一帧图像。复制操作完成后屏幕开始下一个刷新周期,即将刚复制到 Frame Buffer 的数据显示到屏幕上。
在这种模型下,只有当 VSync 信号产生时,CPU/GPU 才会开始绘制。这样,当帧率大于刷新频率时,帧率就会被迫跟刷新频率保持同步,从而避免“tearing”现象。总结一下,开启VSync的本质就是强制拉平我们的GPU每秒绘制的帧数和屏幕的刷新频率。
可能你还会问,为什么我的显卡和显示器配置都很高,玩游戏时还是会存在画面撕裂的现象呢?这里需要强调下,显卡性能高和显示器频率高并不代表不会出现画面撕裂,如果没有开启VSync就会存在画面撕裂的情况。所以,如果你发现你的玩游戏的时候出现了画面撕裂,可以检查下是否开启了VSync。如下,是某款游戏的VSync开关:
注意,当 VSync 信号发出时,如果 GPU/CPU 正在生产帧数据,此时不会发生复制操作。屏幕进入下一个刷新周期时,从 Frame Buffer 中取出的是“老”数据,而非正在产生的帧数据,即两个刷新周期显示的是同一帧数据。这是我们称发生了“掉帧”(Dropped Frame,Skipped Frame,Jank)现象。
另外还有triple buffer(三重缓存),但是iOS设备采用的是双重缓存,Android设备采用的三重缓存,在这里不作讲解。
在 VSync 信号到来后,系统图形服务会通过 CADisplayLink 等机制通知 App,App 主线程开始在 CPU 中计算显示内容,比如视图的创建、布局计算、图片解码、文本绘制等。随后 CPU 会将计算好的内容提交到 GPU 去,由 GPU 进行变换、合成、渲染。随后 GPU 会把渲染结果提交到帧缓冲区去,等待下一次 VSync 信号到来时显示到屏幕上。由于垂直同步的机制,如果在一个 VSync 时间内,CPU 或者 GPU 没有完成内容提交,则那一帧就会被丢弃,等待下一次机会再显示,而这时显示屏会保留之前的内容不变。这就是界面卡顿的原因。
从上面的图中可以看到,CPU 和 GPU 不论哪个阻碍了显示流程,都会造成掉帧现象。所以开发时,也需要分别对 CPU 和 GPU 压力进行评估和优化。iOS 保持界面流畅的技巧
- (void)layoutSubviews
{
[super layoutSubviews];
// 正例:
CGFloat headerWidth = 97.f;
CGFloat headerHeight = 86.f;
self.headerImageView.frame = CGRectMake(16.f, 15.f, headerWidth, headerHeight);
// ...
// 反例:
self.priceView.top = 65.f;
self.priceView.left = 16.f;
}
dispatch_async(dispatch_get_global_queue(0, 0), ^{
// 尺寸计算
[@"xxx" boundingRectWithSize:CGSizeMake(CGFLOAT_MAX, CGFLOAT_MAX) options:NSStringDrawingUsesLineFragmentOrigin | NSStringDrawingUsesFontLeading attributes:@{NSFontAttributeName : [UIFont systemFontOfSize:12], NSForegroundColorAttributeName : HEXCOLOR(0x333333)} context:nil];
});
dispatch_async(dispatch_get_global_queue(0, 0), ^{
// 文本绘制
[@"xxx" drawWithRect:CGRectMake(0, 0, SCREEN_WIDTH, 50) options:NSStringDrawingUsesLineFragmentOrigin attributes:nil context:nil];
});
@implementation UIImageView (FPS)
- (void)fps_setImage:(UIImage *)image {
if (image == nil) {
return;
}
dispatch_async(dispatch_get_global_queue(0, 0), ^{
// 获取CGImage
CGImageRef cgImage = image.CGImage;
// alphaInfo
CGImageAlphaInfo alphaInfo = CGImageGetAlphaInfo(cgImage) & kCGBitmapAlphaInfoMask;
BOOL hasAlpha = NO;
if (alphaInfo == kCGImageAlphaPremultipliedLast ||
alphaInfo == kCGImageAlphaPremultipliedFirst ||
alphaInfo == kCGImageAlphaLast ||
alphaInfo == kCGImageAlphaFirst) {
hasAlpha = YES;
}
// bitmapInfo
CGBitmapInfo bitmapInfo = kCGBitmapByteOrder32Host;
bitmapInfo |= hasAlpha ? kCGImageAlphaPremultipliedFirst : kCGImageAlphaNoneSkipFirst;
// size
size_t width = CGImageGetWidth(cgImage);
size_t height = CGImageGetHeight(cgImage);
// context
CGContextRef context = CGBitmapContextCreate(NULL, width, height, 8, 0, CGColorSpaceCreateDeviceRGB(), bitmapInfo);
// draw 把cgImage绘制到上下文会触发解码
CGContextDrawImage(context, CGRectMake(0, 0, width, height), cgImage);
// get CGImage
cgImage = CGBitmapContextCreateImage(context);
// into UIImage
UIImage *newImage = [UIImage imageWithCGImage:cgImage];
// release
CGContextRelease(context);
CGImageRelease(cgImage);
// back to the main thread
dispatch_async(dispatch_get_main_queue(), ^{
self.image = newImage;
});
});
}
@end
static const CGSize kMinReleaseImageOnBackgroundSize = {20.0, 20.0};
// ASDK这样做的:
void ASPerformBlockOnDeallocationQueue(void (^block)()) {
static dispatch_queue_t queue;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
queue = dispatch_queue_create("org.AsyncDisplayKit.deallocationQueue", DISPATCH_QUEUE_SERIAL);
});
dispatch_async(queue, block);
}
// 如何使用:
- (void)_clearImage
{
// Destruction of bigger images on the main thread can be expensive
// and can take some time, so we dispatch onto a bg queue to
// actually dealloc.
__block UIImage *image = self.image;
CGSize imageSize = image.size;
BOOL shouldReleaseImageOnBackgroundThread = imageSize.width > kMinReleaseImageOnBackgroundSize.width ||
imageSize.height > kMinReleaseImageOnBackgroundSize.height;
if (shouldReleaseImageOnBackgroundThread) {
ASPerformBlockOnDeallocationQueue(^{
image = nil;
});
}
///TODO
///
12.避免UI上使用过多的RAC信号,UI上绑定RAC信号太多也会影响FPS。 13.控制线程的最大并发数量。
综上,开发中应该尽量避免以上操作。
离屏渲染的概念 在OpenGL中,GPU有两种渲染方式:
离屏渲染消耗性能的原因:
CALayer和UIView除了对事件的处理之外,无差别。CALayer用来显示内容的,UIView是用来监听点击事件的,如果内容和用户无交互,可以考虑使用CALayer。
YYText实现了一个异步绘制的layer—YYAsyncLayer。 YYAsyncLayer内部持有一个sentinel(使用OSAtomicIncrement32保证线程安全),该变量自增,起到标记作用。当layer调用dealloc、setNeedsDisplay、就会递增这个变量,异步绘制过程中会多次检查这个变量来判断此次绘制任务是否应该取消。
重写CALayer的display方法,在display方法中异步绘制。
- (void)display {
[self _displayAsync:_displaysAsynchronously];
}
- (void)_displayAsync:(BOOL)async {
// 向delegate也就是YYLabel获取更新任务,newAsyncDisplayTask会返回一个新的绘制的任务
__strong id<YYAsyncLayerDelegate> delegate = (id)self.delegate;
YYAsyncLayerDisplayTask *task = [delegate newAsyncDisplayTask];
if (!task.display) {
if (task.willDisplay) task.willDisplay(self);
self.contents = nil;
if (task.didDisplay) task.didDisplay(self, YES);
return;
}
if (task.willDisplay) task.willDisplay(self);
YYSentinel *sentinel = _sentinel;
int32_t value = sentinel.value;
// 判断是否该取消异步绘制任务
BOOL (^isCancelled)() = ^BOOL() {
return value != sentinel.value;
};
CGSize size = self.bounds.size;
BOOL opaque = self.opaque;
CGFloat scale = self.contentsScale;
CGColorRef backgroundColor = (opaque && self.backgroundColor) ? CGColorRetain(self.backgroundColor) : NULL;
if (size.width < 1 || size.height < 1) {
CGImageRef image = (__bridge_retained CGImageRef)(self.contents);
self.contents = nil;
if (image) {
dispatch_async(YYAsyncLayerGetReleaseQueue(), ^{
CFRelease(image);
});
}
if (task.didDisplay) task.didDisplay(self, YES);
CGColorRelease(backgroundColor);
return;
}
// 异步绘制
dispatch_async(YYAsyncLayerGetDisplayQueue(), ^{
if (isCancelled()) {
CGColorRelease(backgroundColor);
return;
}
// 开启图片上下文
UIGraphicsBeginImageContextWithOptions(size, opaque, scale);
CGContextRef context = UIGraphicsGetCurrentContext();
if (opaque && context) {
CGContextSaveGState(context);
{
if (!backgroundColor || CGColorGetAlpha(backgroundColor) < 1) {
CGContextSetFillColorWithColor(context, [UIColor whiteColor].CGColor);
CGContextAddRect(context, CGRectMake(0, 0, size.width * scale, size.height * scale));
CGContextFillPath(context);
}
if (backgroundColor) {
CGContextSetFillColorWithColor(context, backgroundColor);
CGContextAddRect(context, CGRectMake(0, 0, size.width * scale, size.height * scale));
CGContextFillPath(context);
}
}
CGContextRestoreGState(context);
CGColorRelease(backgroundColor);
}
task.display(context, size, isCancelled);
if (isCancelled()) {
UIGraphicsEndImageContext();
dispatch_async(dispatch_get_main_queue(), ^{
if (task.didDisplay) task.didDisplay(self, NO);
});
return;
}
// 从当前上下文获取图片
UIImage *image = UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();
if (isCancelled()) {
dispatch_async(dispatch_get_main_queue(), ^{
if (task.didDisplay) task.didDisplay(self, NO);
});
return;
}
dispatch_async(dispatch_get_main_queue(), ^{
if (isCancelled()) {
if (task.didDisplay) task.didDisplay(self, NO);
} else {
self.contents = (__bridge id)(image.CGImage);
if (task.didDisplay) task.didDisplay(self, YES);
}
});
});
}
后续会结合代码和效果图补充一些FPS优化的Tip,敬请持续关注和讨论!
vsync, hsync, VBLANK VSYNC和HSYNC 垂直同步是什么?造成游戏画面撕裂的原因 什么是画面撕裂?垂直同步,G-sync,Freesync到底有啥用? iOS 保持界面流畅的技巧