【iOS】离屏渲染

前言

这里我简单学习一下有关iOS渲染的相关知识。

屏幕卡顿

在我们日常使用过程中,经常会遇到屏幕卡顿的问题。(图形撕裂、掉帧等问题)

产生原因

  • CPU和GPU在渲染的流水线中耗时过长,导致缓存区获取位图然后显示的时候,下一帧的数据还没有准备好从而产生了掉帧的现象
  • 苹果针对屏幕撕裂的为题采用的是垂直同步+双缓存机制,可以有效防止屏幕撕裂,但是同时容易产生掉帧
  • 于是在垂直同步+爽滑黁区的方案上优化为三缓存区,但是还是没办法从根本上解决掉帧问题,知识相比双缓存区掉帧的概率更小。

屏幕撕裂

屏幕成像过程

  • 首先GPU将需要显示的图像进行渲染
  • 然后将渲染后的结果以位图的形式存储到帧缓存区
  • 视频控制器从帧缓存区读取位图数据之后交给显示器,从左上角扫描显示

这个流程中,很容易出现一个屏幕撕裂的问题,如下:

在屏幕显示图形图像的过程中,是不断的从帧缓存区获取一帧一帧的数据进行显示的,在渲染过程中,如果屏幕拿到的数据是旧的,就会显示旧的数据。

在旧的数据没有读取完成时,新的一帧的数据处理好了放到缓存区之后,又会导致屏幕另一部分的显示是获取的新数据,导致屏幕上呈现图片不匹配、任务景象显示错位等情况。

苹果采用垂直同步+双缓存区的机制,强制要求同步,但是会出现掉帧问题:

  • 垂直同步:给帧缓存区加锁,当电子光束扫描的过程中,只有扫描完成之后才会继续读取下一帧的数据。
  • 双缓存区:采用两个缓存区交替当作GPU结果的存储区,当屏幕显示其中一个缓存区的时候,另一个缓存区继续等待下一个渲染结果,两个缓存区交替进行。

掉帧

在VSync信号到来之前,系统图形服务会通过CADisplayLink等机制通知App,主线程开始在CPU种计算显示内容,随后GPU进行变换、合成、渲染,完成之后会将渲染结果提交到帧缓存区中去,等待下一次同步信号到来。如果在下一个同步信号到来之前,CPU或者GPU没有处理完内容并提交,那一帧就会丢弃,等待下一次机会显示。产生掉帧。

CPU/GPU 必须在下一次 VSync 到来之前完成一帧的绘制。若未完成,显示器无法切换到新缓冲区,只能重复显示上一帧,于是产生掉帧和卡顿。

卡顿监控:

主流的卡顿监控主要有下面两种:

  • FPS监控:为了保持流畅的UI交互,App的刷新频率应该在60fps左右(iOS设备默认的刷新率)。而一次刷新的事件间隔16.67ms,如果这个时间内数据没有准备好就会产生卡顿。
  • 主线程卡顿监控:通过自线程检测主线程runloop。这个方法笔者前面在学习runloop的时候有给过具体的实现流程,这里不在赘述。主要就是通过KCFRunLoopBeforeSources和KCFRunLoopAfterWaiting之间的耗时判断。

FPS监控

参照YYKit中的YYFPSLabel,主要通过CADisplaylink实现,借助link时间差,计算一次刷新所需要的时间,然后通过刷新次数/时间差判断范围,通过不同颜色文字表示卡顿程度。本质上就是一个悬浮在窗口上的FPS实时监控器。这里测量的不是GPU最终真是输出的帧数,而是主线程实际处理了多少次CADisplaylink回调,判断主线程是否阻塞RunLoop是否卡顿、界面滚动是否流畅。

objc 复制代码
#import "ZYLFPSLabel.h"
#import <QuartzCore/QuartzCore.h>

/// FPS 标签在 window 上的唯一 tag,用于 show 时去重。
static NSInteger const kZYLFPSLabelTag = 2026071401;

/// FPS 文案刷新间隔。
///
/// CADisplayLink 每一帧都会回调,如果每帧都改 UI,数字会抖动得很厉害,
/// 也会让监控本身引入不必要的额外开销。0.25 秒是一个比较灵敏、同时又稳定的窗口。
static CFTimeInterval const kZYLFPSUpdateInterval = 0.25;

@interface ZYLFPSLabel ()

/// CADisplayLink 会在屏幕每次准备刷新时向 target 发送 selector。
/// 它的 timestamp 是当前这次回调对应的时间点,适合用来计算一段时间内实际刷新了多少次。
@property (nonatomic, strong) CADisplayLink *displayLink;

/// 统计窗口内 CADisplayLink 回调的次数。
/// 理想 60Hz 屏幕上,1 秒大约回调 60 次;120Hz 屏幕上,1 秒大约回调 120 次。
@property (nonatomic, assign) NSUInteger frameCount;

/// 上一次刷新 FPS 文案的时间点。
/// 当 timestamp - lastTime 达到 kZYLFPSUpdateInterval 时,计算并展示一次 FPS。
@property (nonatomic, assign) CFTimeInterval lastTime;

@end

@implementation ZYLFPSLabel

+ (instancetype)sharedFPSLabel {
  static ZYLFPSLabel *label = nil;
  static dispatch_once_t onceToken;
  dispatch_once(&onceToken, ^{
    label = [[ZYLFPSLabel alloc] initWithFrame:CGRectMake(12, 88, 78, 28)];
  });
  return label;
}

- (instancetype)initWithFrame:(CGRect)frame {
  self = [super initWithFrame:frame];
  if (self) {
    self.tag = kZYLFPSLabelTag;
    self.userInteractionEnabled = NO;
    self.textAlignment = NSTextAlignmentCenter;
    self.font = [UIFont monospacedDigitSystemFontOfSize:13 weight:UIFontWeightSemibold];
    self.text = @"-- FPS";
    self.textColor = UIColor.systemGreenColor;
    self.backgroundColor = [[UIColor blackColor] colorWithAlphaComponent:0.65];
    self.layer.cornerRadius = 6.0;
    self.layer.masksToBounds = YES;
  }
  return self;
}

- (void)showInWindow:(UIWindow *)window {
  if (window == nil) {
    return;
  }

  UIView *oldLabel = [window viewWithTag:kZYLFPSLabelTag];
  if (oldLabel != nil && oldLabel != self) {
    [oldLabel removeFromSuperview];
  }

  self.hidden = NO;
  [self removeFromSuperview];//先从原来的父视图中移除
  [window addSubview:self];
  [window bringSubviewToFront:self];
  [self startDisplayLinkIfNeeded];//启动FPS监听
}

- (void)hide {
  self.hidden = YES;
  [self stopDisplayLink];//停止FPS监听
  [self removeFromSuperview];
}

- (void)didMoveToWindow {
  [super didMoveToWindow];

  // UILabel 从 window 移除后,如果 CADisplayLink 仍然挂在 RunLoop 上,
  // RunLoop 会继续持有 displayLink,displayLink 又会持有 target,最终导致标签无法释放。
  // 所以这里跟随 window 生命周期启停,既省性能,也避免循环持有。
  if (self.window != nil && self.hidden == NO) {
    [self startDisplayLinkIfNeeded];
  } else {
    [self stopDisplayLink];
  }
}

- (void)dealloc {
  [self stopDisplayLink];
}

#pragma mark - DisplayLink

- (void)startDisplayLinkIfNeeded {
  if (self.displayLink != nil) {
    return;
  }

  self.frameCount = 0;
  self.lastTime = 0;

  CADisplayLink *link = [CADisplayLink displayLinkWithTarget:self selector:@selector(displayLinkTick:)];

  // NSRunLoopCommonModes 可以让 FPS 监控在 UITrackingRunLoopMode 中也继续工作,
  // 例如用户正在滚动 scrollView 时,依然能看到滚动期间的帧率变化。
  [link addToRunLoop:NSRunLoop.mainRunLoop forMode:NSRunLoopCommonModes];
  self.displayLink = link;
}

- (void)stopDisplayLink {
  [self.displayLink invalidate];
  self.displayLink = nil;
  self.frameCount = 0;
  self.lastTime = 0;
}

- (void)displayLinkTick:(CADisplayLink *)link {
  if (self.lastTime <= 0) {
    // 第一帧只记录起点,不参与计算。
    // 因为没有上一段时间作为分母,直接计算会得到无意义的 FPS。
    self.lastTime = link.timestamp;
    return;
  }

  self.frameCount++;

  CFTimeInterval timePassed = link.timestamp - self.lastTime;
  if (timePassed < kZYLFPSUpdateInterval) {
    return;
  }

  // 核心公式:FPS = 统计窗口内的刷新次数 / 统计窗口经过的秒数。
  // 例如 0.25 秒内回调了 15 次,则 FPS = 15 / 0.25 = 60。
  CGFloat fps = self.frameCount / timePassed;

  // CADisplayLink.preferredFramesPerSecond 为 0 时表示使用屏幕默认刷新率。
  // 这里优先使用 UIScreen.maximumFramesPerSecond,这样 60Hz 和 120Hz 设备都能按各自上限判断。
  NSInteger maximumFPS = UIScreen.mainScreen.maximumFramesPerSecond;
  if (maximumFPS <= 0) {
    maximumFPS = 60;
  }

  [self updateTextWithFPS:fps maximumFPS:maximumFPS];

  self.frameCount = 0;
  self.lastTime = link.timestamp;
}

- (void)updateTextWithFPS:(CGFloat)fps maximumFPS:(NSInteger)maximumFPS {
  NSInteger roundedFPS = (NSInteger)lround(fps);
  roundedFPS = MAX(0, MIN(roundedFPS, maximumFPS));
  self.text = [NSString stringWithFormat:@"%zd FPS", roundedFPS];

  // 用当前设备最大刷新率做基准,而不是写死 60。
  // >= 80%:基本流畅,绿色;
  // >= 50%:出现明显掉帧,黄色;
  // <  50%:卡顿比较严重,红色。
  CGFloat smoothLine = maximumFPS * 0.8;
  CGFloat warningLine = maximumFPS * 0.5;

  if (fps >= smoothLine) {
    self.textColor = UIColor.systemGreenColor;
  } else if (fps >= warningLine) {
    self.textColor = UIColor.systemYellowColor;
  } else {
    self.textColor = UIColor.systemRedColor;
  }
}

@end

iOS渲染架构

App 可以调用 Core Graphics、Core Animation、Core Image 等框架完成二维绘制、图层管理、动画及图片处理。这些框架各自承担不同职责,并不一定全部通过 OpenGL ES 工作。Core Graphics 可以将绘制内容光栅化为位图,Core Animation 负责管理和合成图层,Core Image 可以使用 Metal 等后端执行图片处理。

OpenGL ES 和 Metal 是较底层的图形 API,可用于向 GPU 提交渲染任务。OpenGL ES 主要定义图形渲染接口,并不负责创建各操作系统的窗口,因此需要平台提供一个可显示的渲染表面。

在早期 iOS 中,OpenGL ES 通常通过 CAEAGLLayer 获得可显示的渲染存储,渲染结果再由 Core Animation 合成到屏幕。现代 iOS 中则通常使用 Metal,并通过 CAMetalLayer 或 MTKView 将 GPU 渲染结果接入界面系统。

iOS渲染的框架主要有下面几种:

现代iOS中,底层GPU的接口主要是Metal。

View与CALayer的关系

  • UIView

    • 属于UIKit,继承自UIResponder
    • 负责绘制图形和动画操作
    • 用于界面布局和子视图管理
    • 处理用户的点击事件
  • CALayer

    • 属于CoreAnimation
    • 只负责显示,并且显示的是位图
    • 即用于UIKit也用于APPKit
      • UIKit是iOS平台的渲染框架,APPkit是Mac OSX系统下的渲染框架

CoreAnimation流水线/计算机渲染原理

这里主要有四个层级:

  • Application应用处理阶段:获取图元(CPU负责)这个阶段指的就是图像在应用中被处理的阶段,此时还处于CPU负责的时期。这个阶段应用会对图像进行一些列操作,然后将图像传递给下一阶段。(图元:描述几何形状的基本元素,通常是三角形、线段、顶底等)
  • Geometry几何处理阶段:处理图元(GPU)进入这个阶段之后,就主要由GPU负责了,GPU获取到图元信息,对这部分图元通过顶点着色器、形状装配、几何着色器进行处理,输出新的图元。
    • 顶点着色器Vertex Shader:对图元中的顶点信息进行视角转换,添加光照信息,增加纹理等操作
    • 形状装配Shape Assemply:图元中的三角形、线段、点分别对旖旎个三个顶点、两个顶点、一个顶点。将顶点连接成对应形状
    • 几何着色器Geometry Shader:额外添加的顶点,将原始图元转换为新图元,以构建一个不一样的模型
    • Rasterization 光栅化阶段:图元转换为像素(GPU)

光栅化的主要目的就是将几何渲染之后的图形信息转换为一系列的像素,便于后续显示。

  • pixel像素处理阶段:处理生成的像素,得到位图。通过上述的光栅化阶段之后我们得到了图元所对应的像素,之后我们需要通过片段着色器(给每个像素Pixel赋予正确的颜色)和测试与混合(处理片段的前后位置以及透明度)给这些像素填充正确的颜色和效果得到位图。

下面我们正式看一下这个渲染流程:

  1. handle Events:这个过程会先处理点击事件(页面布局和层次可能会改变)
  2. Commit Transaction:此时Appp通过CPU处理显示内容的前置计算,如布局计算、图片解码等任务。之后将计算好的图层打包给Render Server
  3. Decode:打包好的图层会被传输到Render Server中,会先进行解码,解码之后需要等待下一个RunLoop才可以执行Draw Calls
  4. Draw Calls:解码完成之后,Core Animation会调用下层渲染框架的方法进行绘制,进而调用GPU
  5. Render:GPU进行渲染
  6. Display:显示阶段,需要等render结束的下一个runloop触发

在我们日常开发中,主要影响的部分是Handle Events和Commit Trasaction阶段,主要进行的是layout、Display、Prepare、commit四个具体步骤。

  • layout:只处理视图的构建
    • 调用重载的layoutSubviews方法
    • 创建视图,并通过addSubView方法添加子视图
    • 计算视图布局,即所有的Layout Constraint
  • Display:绘制视图
    • 从上面得到图元,如果重写了drawRect方法,那么就会调用重载的drawRect:方法

正常情况下,Display阶段只会得到图元信息,但是如果重写了drawrect:方法,这个方法会直接调用Core Graphics绘制方法得到bitmap数据,同时系统会额外申请一块内存存储绘制好的位图。

  • prepare CoreAnimation:只进行图片的解码和转换
  • Commit:打包且发送,是依赖图层树递归执行的,如果树很复杂,commit的开销就很大。所以希望减少视图层级。

离屏渲染

离屏渲染原理

在iOS系统中,图形渲染主要由CPU和GPU协同完成,CPU负责计算图形的位置和属性,并将这些信息传递给GPU,GPU负责实际的渲染工作,将接收的渲染数据转换为图像,并存储在帧缓冲区中,当需要进行离屏渲染时,GPU会在当前屏幕缓冲区之外开辟一个新的缓冲区,用于存放渲染过程的中间结果。完成离屏渲染之后,GPU再将结果合并到屏幕缓冲区中,最终显示在屏幕上。

我们看一下正常渲染加载流程和离屏渲染加载流程:

可以看出,主要就是在后缓冲区之间多了一个离屏缓冲区。

正常渲染流程

APP的数据经过CPU和GPU渲染之后,将结果直接放在帧缓冲区,利用视屏控制器从帧缓冲区读取并显示到屏幕上。

在CPU渲染流程中,显示到屏幕上的图像是遵循大画家算法按照由远及近的顺序一次将结果存储到帧缓冲区的。

视屏控制器从帧缓冲区中读取一帧数据将其显示到屏幕上后,会立即丢弃这帧数据,不做任何保留以节省空间。

离屏渲染流程

当App需要进行额外的渲染和合并时,例如按钮设置圆角,我们需要对UIButton空间中的所有图层都进行圆角+剪裁,然后将合并后的结果放到帧缓冲区中,再从缓冲区中取出交给屏幕控制器显示。在正常的渲染流程中,这个过程是没办法做到的,因为他是用一个丢一个。所以我们需要提前将处理好的结果放入离屏缓冲区中,经过叠加合并之后再拷贝到帧缓冲区中。

简单理解就是一个临时缓存区,存放数据用于后续使用。

离屏渲染虽然方便但是有严重的性能问题,由于离屏渲染中的离屏缓冲区是额外的存储空间,当他将数据转存到Frame Buffer时也是需要耗费时间的,所以转存过程中也可能导致掉帧。

离屏缓冲区的空间最大之能是屏幕的2.5倍

离屏渲染的本质就是裁剪的叠加导致对于所有的layer和所有的sublayer进行了一个二次处理。

离屏渲染的优点

  • 可以实现处理一些特殊的效果,这种效果不能一次就完成,需要使用离屏缓冲区保存中间状态。这些情况下离屏渲染是自动触发的:圆角、阴影、高斯模糊、光栅化等
  • 可以提升渲染的效率,如果一个效果是多次实现的,可以提前渲染保存到离屏缓冲区以实现复用,这种情况需要开发者手动触发

产生原因

  • 圆角:为UIView或者其子类设置圆角属性时,如果视图同时具有不透明背景色或者复杂的背景图像,可能会触发离屏渲染。
  • 阴影:设置layer的shadow属性会产生阴影效果,通常会触发离屏渲染
  • 透明度:视图的alpha小于1或者使用了CALayer的opacity属性时,如果有复杂混合层级,可能触发离屏渲染
  • 遮罩:使用了CALayer的mask属性或者UIView的maskView时,通常会触发离屏渲染
  • 非默认混合模式:视图或者图层使用非默认的混合模式,可能触发
  • 多重渲染目标:需要多次渲染才能完成的效果,可能需要触发离屏渲染

重写drawRect:方法不会触发离屏渲染。虽然重写这个方法会将GPU中的渲染操作拿到CPU'中完成,并且需要额外开辟空间,但是和标准意义上的离屏渲染并不一样。

光栅化

将图形描述转化成为屏幕上一个个的像素。我们通常所说的光栅化是指将一个图层及其所有子图层提前合并为一张位图缓存,后续直接复用这张位图,减少重复合成。如果视图内容频繁变化就不适合了

objc 复制代码
self.cardView.layer.shouldRasterize = YES;
self.cardView.layer.rasterizationScale = UIScreen.mainScreen.scale;//位图有分辨率要求

我们使用离屏渲染的一个原因就是光栅化。当我们使用光栅化时,会将layer渲染成位图保存在缓存中,这样下次使用时就可以直接复用提高效率。需要注意下面几点:

  • 如果layer不能被复用,没有必要开启光栅化
  • 如果layer不是静态,需要被频繁修改,此时开启光栅化会影响效率
  • 离屏渲染缓存内容有时间限制,如果100ms内没有被使用就丢弃了
  • 缓存空间有限,是屏幕的2.5倍,超过这个限制像素大小的话也会失效

圆角中的离屏渲染触发时机

objc 复制代码
	btn.layer.cornerradius = 20;
  btn.layer.borderWidth = 5;
	btn.layer.borderColor = [UIColor redColor]

如果我们使用了上面的代码来设置圆角,可以发现并没生效,这是因为我们没有设置masktoBounds为true。

一个CALayer是由backgroundColor、contents、borderWidth&borderColor构成的

我们设置cornerradius只会设置背景色、border的圆角,不会设置contents的圆角。

所以圆角不生效的根本原因就是因为没有设置contents的圆角,而按钮的image是在contents中的,所以并没有产生想要的效果。

如果只设置backgroundColor、border而contents中没有子视图时,不论怎么设置mask~、clip~都不会触发离屏渲染。

如果有子视图的话,此时设置圆角和裁切就会触发离屏渲染。

但是这种情况在UIIMageView中不适用,当UIImageView中只设置图片+maskToBounds / clipsToBounds是不会触发离屏渲染。苹果对UIImageView优化可能只是世界将image画在了contents上,设置纸需要渲染一个layer,用不到离屏缓冲区。但是如果有背景色,就需要了

如何避免的离屏渲染

  • 避免不必要的圆角
  • 使用图片替代:用圆角图片代替代码设置圆角
  • 合理使用光栅化:对于复杂但是静态的视图可以使用光栅化
  • 减少透明度:避免多层半透明堆叠
  • 使用 layer.shouldRasterize:对复杂静态视图开启光栅化缓存
  • 使用贝塞尔曲线绘制闭合带圆角的矩形,在上下文中设置仅内部可见,将不带圆角的layer渲染成图片添加到贝塞尔矩形中
  • 重写drawRect:使用CoreGraphics相关方法进行手动绘制(CPU完成)
    • 渲染不是CPU强项,调用CoreGraphics会消耗计算时间,为了步阻塞用户操作,一般CPU渲染都是在后台线程完成,最后回到主线程将结果传给coreanimation
    • CPU渲染不够快,更适合渲染静态内容。(CPU预绘制相当于将多个元素拍成一张照片)
    • 渲染结果的bitmap数据量大,消耗内存多,需要及时释放
    • 如果采用·CPU渲染了,就不要触发GPU的离屏渲染了,避免出现两块内容相同的缓存
相关推荐
tink32 分钟前
告别 Xcode IDE:用 VS Code + SweetPad + XcodeGen 开发 iOS 应用的完整指南
ios·swiftui·xcode
传奇开心果编程2 小时前
【Compose Multiplatform 跨端开发学与练】第8课 资源管理与主题
android·windows·学习·ios·kotlin·web·composer
传奇开心果编程3 小时前
【Compose Multiplatform 跨端开发学与练】第9课 测试与调试
android·学习·macos·ios·kotlin·web·composer
传奇开心果编程3 小时前
【Compose Multiplatform 跨端开发学与练】第4课 导航与路由
android·windows·学习·ui·ios·kotlin·composer
黑科技iOS上架3 小时前
iOS隐私合规扫描工具
ios·审核
传奇开心果编程4 小时前
【Compose Multiplatform 跨端开发学与练】第6课 状态管理与架构
android·学习·ui·ios·架构·kotlin·composer
传奇开心果编程4 小时前
【Compose Multiplatform 跨端开发学与练】第2课 Compose 基础语法
android·windows·学习·ui·ios·kotlin·composer
可乐鸡翅yeah_4 小时前
hls.js 缓冲区参数 maxBufferLength、maxBufferSize 通俗讲解与业务调参
开发语言·javascript·ios·音视频·safari·m3u8
supabc1234 小时前
Celium:连接 Windows、Mac、Linux 与 Android,让远程访问和设备管理更简单
android·linux·windows·macos·远程访问·网络管理·celium
牛奔4 小时前
Mac 精准安装指定 macOS 版本完整教程(升级 + 降级通用)
macos