屏幕成像原理以及FPS优化Tips

前言

移动端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,如下是工作原理图:

image.png

PS:更多信息请自行复习《计算机组成原理》或《数字电路与逻辑设计》等大学教材。

屏幕显示图像的原理

通常来时,计算机系统的CPU、GPU、显示器是以一种类似于串行的方式协同工作的。如下图,CPU计算好显示的内容提交给GPU;GPU把CPU提交过来的内容渲染成显示器可以显示的格式(也就是我们常说的一帧)。GPU渲染完成后将渲染结果(也就是一帧画面)放到屏幕的帧缓冲区(此处的帧缓冲区和离屏渲染的屏幕缓冲区、屏幕外缓冲区是一回事);随后视频控制器会按照VSync(垂直同步信号)读取帧缓冲区的数据,经过数模转换传递给显示器显示。

image.png

单缓冲机制

单缓冲机制。帧缓冲区只有一个,GPU向帧缓冲区提交渲染好的数据,视频控制器从帧缓冲区读取数据显示到屏幕上(典型的生产者—消费者模型)。这时帧缓冲区的读取和刷新都都会有比较大的效率问题。

image.png

双缓冲机制

注意,此处的“双缓冲”和计算机组成原理中的“二级缓存”是两回事。三重缓存也是如此。

为了解决单缓冲的效率问题,显示系统通常会引入两个缓冲区,即双缓冲机制,实际上iOS设备也是这么做的。双缓冲机制下,GPU 会预先渲染好一帧放入一个缓冲区内,让视频控制器读取,当下一帧渲染好后,GPU 会直接把视频控制器的指针指向第二个缓冲器。如此一来效率会有很大的提升。(iOS 保持界面流畅的技巧)

image.png

双缓冲虽然能解决单缓冲区效率问题,但会引入一个新的问题。当视频控制器还未读取完成时,即屏幕内容刚显示一半时,GPU 将新的一帧内容提交到帧缓冲区并把两个缓冲区进行交换后,视频控制器就会把新的一帧数据的下半段显示到屏幕上,造成“画面撕裂”现象,我们称之为“screen tearing”,如下图(google搜索画面撕裂即可):

image.png

PS:实际上,单缓冲区机制也是存在画面撕裂现象的。例如,当帧率大于刷新频率,当屏幕还没有刷新第 n-1 帧的时候,GPU 已经在生成第 n 帧了,从上往下开始覆盖第 n-1 帧的数据,当屏幕开始刷新第 n-1 帧的时候,Buffer 中的数据上半部分是第 n 帧数据,而下半部分是第 n-1 帧的数据,显示出来的图像就会出现上半部分和下半部分明显偏差的现象,我们称之为 “tearing”。

双缓冲+VSync同步机制

double buffer/双重缓存

两个缓存区分别为 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开关:


image.png

注意,当 VSync 信号发出时,如果 GPU/CPU 正在生产帧数据,此时不会发生复制操作。屏幕进入下一个刷新周期时,从 Frame Buffer 中取出的是“老”数据,而非正在产生的帧数据,即两个刷新周期显示的是同一帧数据。这是我们称发生了“掉帧”(Dropped Frame,Skipped Frame,Jank)现象。

另外还有triple buffer(三重缓存),但是iOS设备采用的是双重缓存,Android设备采用的三重缓存,在这里不作讲解。

卡顿产生的原因和优化方案

image.png

在 VSync 信号到来后,系统图形服务会通过 CADisplayLink 等机制通知 App,App 主线程开始在 CPU 中计算显示内容,比如视图的创建、布局计算、图片解码、文本绘制等。随后 CPU 会将计算好的内容提交到 GPU 去,由 GPU 进行变换、合成、渲染。随后 GPU 会把渲染结果提交到帧缓冲区去,等待下一次 VSync 信号到来时显示到屏幕上。由于垂直同步的机制,如果在一个 VSync 时间内,CPU 或者 GPU 没有完成内容提交,则那一帧就会被丢弃,等待下一次机会再显示,而这时显示屏会保留之前的内容不变。这就是界面卡顿的原因。

从上面的图中可以看到,CPU 和 GPU 不论哪个阻碍了显示流程,都会造成掉帧现象。所以开发时,也需要分别对 CPU 和 GPU 压力进行评估和优化。iOS 保持界面流畅的技巧

FPS优化Tips

CPU优化

  1. 尽量使用基本数据类型这种轻量级的类型,避免使用对象类型,比如使用int而不是NSNumber。

  2. 避免UIView属性的频繁调整或设置,频繁冗余的设置属性frame、bounds、transform会频繁的浪费CPU的计算能力,会导致额外的CPU开销。因为CPU需要先计算好UIView的这些属性,然后才会交由GPU渲染。
    对象的调整也经常是消耗 CPU 资源的地方。这里特别说一下 CALayer:CALayer 内部并没有属性,当调用属性方法时,它内部是通过运行时 resolveInstanceMethod 为对象临时添加一个方法,并把对应属性值保存到内部的一个 Dictionary 里,同时还会通知 delegate、创建动画等等,非常消耗资源。UIView 的关于显示相关的属性(比如 frame/bounds/transform)等实际上都是 CALayer 属性映射来的,所以对 UIView 的这些属性进行调整时,消耗的资源要远大于一般的属性。对此你在应用中,应该尽量减少不必要的属性修改。

  3. 视图无交互时尽量使用CALayer,比如使用CALayer代替UIView\UILabel\UIImageView。

  4. 尽量提前计算好布局,一次性设置给UIView,避免多次设置。如下:

- (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;
}
  1. 复杂的页面推荐使用frame布局,尽量不要使用autolayout。autolayout会比frame布局消耗更多的CPU资源。

  2. 尽量把耗时的操作放到子线程。比如文本处理(包括尺寸计算和文本绘制)、图片处理(包括解码和绘制)

  • 尽量在子线程计算文本尺寸,比如boundingRect方法的调用,可以放到子线程
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];
});
  • 尽量在子线程对图片进行解码(UIImage只有在显示的时候才会解码,而这个操作一般是在主线程,所以容易造成卡顿)
    说明:[UIImage imageNamed:@"xxx"]方式加载进来的图片是不能直接显示到屏幕上的,imageNamed:加载进来的是压缩过的图片的二进制数据,想要把image渲染到屏幕上还需要对二进制数据进行解码,而这个解码过程往往是在主线程中执行的。如果图片比较多或者比较大,也有可能极大地消耗CPU的资源,造成卡顿。
    解决思路:在子线程解码。
@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
  1. 图片的size最好刚好和UIImageView的size一致。尽量避免图片尺寸伸缩。

  2. 如果确定子视图大小和位置是固定的,那么避免在cell的layoutSubViews中设置子视图的位置和大小。因为tableView滚动时候会调用cell的layoutSubView方法。cell的layoutSubViews方法中布局代码太多比较耗时。

  3. 如果一个对象(比如subview)在父对象init时就要创建,那么避免使用懒加载的方式。因为事后频繁的判断懒加载的if也是耗性能的。

  4. 依赖于其他数据的对象或者初始化比较复杂的对象,能懒加载的就懒加载,能延后加载的就延后加载。

  5. 后台释放大对象,比如较大的图片。ASDK认为,大图在主线程释放的时候会消耗更高的性能和时间,此处最小尺寸是20x20。

    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.控制线程的最大并发数量。

GPU优化

  1. 尽量减少视图数量和层次。

  2. 尽量避免短时间内大量图片的显示,可以的话将多张图片合成一张显示。

  3. GPU能处理的最大文理尺寸是4096*4096,一旦超过这个尺寸,就会占用CPU资源进行处理
    4.减少透明的视图(alpha<1), 不透明的视图就设置opaque = YES(默认为YES)

  4. 尽量避免离屏渲染
    哪些操作会触发离屏渲染?

  • 光栅化 layer.shouldRasterize = YES 会触发离屏渲染

  • 遮罩 layer.mask = xxx 也会触发离屏渲染

  • 圆角 同时设置了layer.masksToBounds = YES、layer.cornerRadius大于0会触发离屏渲染。只设置layer.masksToBounds = YES或者layer.cornerRadius大于0不会触发离屏渲染 (如果需要圆角,可以使用CoreGraphics绘制裁剪圆角或者让UI提供圆角图片)

  • 阴影 layer.shadowXXX 比如layer.shadowColor、layer.shadowOffset 都会触发离屏渲染
    如果设置了layer.shadowPath就不会触发离屏渲染

综上,开发中应该尽量避免以上操作。

离屏渲染的概念
在OpenGL中,GPU有两种渲染方式:

  • On-Screen Render: 当前屏幕渲染,即在当前用于显示的屏幕缓冲区进行渲染。

  • Off-Screen Render:离屏渲染,在当前屏幕缓冲区外新开辟一个缓冲区进行渲染。

离屏渲染消耗性能的原因:

  • GPU需要创建新的缓冲区

  • 离屏渲染的整个过程,需要多次切换上下文环境,先是从当前屏幕缓冲区(On-Screen)切换到离屏状态(Off-Screen),等到离屏渲染结束后(即在屏幕外缓冲区把内容渲染好了)需要将离屏缓冲区渲染的结果显示到屏幕上,又需要将上下文环境从离屏屏幕外缓冲区切换到当前屏幕(当前屏幕的缓冲区)。这里有一个背景:屏幕视频控制器只会从屏幕对应的帧缓存中一帧一帧的取数据,而不会从其他的缓冲区中取数据,所以我们想把其他缓冲区(也就是屏幕外缓冲区)中的内容显示到屏幕上,需要把屏幕外缓冲区渲染的结果提交到屏幕的缓冲区,然后供视频控制器去取。

CALayer和UIView除了对事件的处理之外,无差别。CALayer用来显示内容的,UIView是用来监听点击事件的,如果内容和用户无交互,可以考虑使用CALayer。

通常(默认)情况下,狭义的离屏渲染是指发生在GPU的离屏渲染。广义的离屏渲染既包括GPU离屏渲染也包括CPU离屏渲染。
离屏渲染并不意味着软件绘制,但是它意味着图层必须在被显示之前在一个屏幕外上下文中被渲染(不论CPU还是GPU)。所谓软件绘制就是代码绘制,因为代码都是被CPU运行的,所以软件绘制即是CPU绘制,硬件绘制即指GPU绘制。

CPU渲染
如果将不在GPU的当前屏幕缓冲区中进行的渲染都称为离屏渲染,那么就还有另一种特殊的“离屏渲染”方式: CPU渲染。
如果我们重写了drawRect方法,并且使用任何Core Graphics的技术进行了绘制操作,就涉及到了CPU渲染。整个渲染过程由CPU在App内 同步地
完成,渲染得到的bitmap最后再交由GPU用于显示。因为Core Graphics是线程安全的,所以我们可以在子线程使用CG API进行异步绘制。

YYText上的FPS解决方案

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 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 保持界面流畅的技巧

你可能感兴趣的:(屏幕成像原理以及FPS优化Tips)