OC底层原理38:界面优化方案

张建 lol

前言

本文主要讲:

  • 卡顿原理:卡顿的原因 - 掉帧
  • 卡顿检测工具
  • 实战项目优化

界面卡顿

通常来说,计算机中的显示过程是下面这样的,通过 CPU、GPU、显示器 协同工作来将图片显示到屏幕上的

  • CPU 计算好显示的 内容,提交到 GPU

  • GPU 经过 渲染 完成后,将渲染的结果放入 FrameBuffer(帧缓存区)

  • 随后 视频控制器 会按照 VSync(垂直同步) 信号逐行读取 FrameBuffer 的数据

  • 经过可能的 数模转换 传递给 显示器 进行显示

最开始时,FrameBuffer只有一个,这种情况下 FrameBuffer 的读取和刷新有很大的 效率 问题,为了解决这个问题,引入了 双缓存区,即 双缓冲机制,即 前FrameBuffer后FrameBuffer,GPU渲染结果来回切换放入 前、后FrameBuffer 中。视频控制器来回切换读取 前后FrameBuffer,交给 显示器显示

双缓存机制 虽然解决了 效率问题,但是随之而言的是新的问题,当视频控制器还未 读取的频率渲染的频率 不一致时,就会产生 掉帧,例如屏幕内容显示一半,GPU将新的一帧内容提交到 FrameBuffer,并将两个FrameBuffer而进行交换后,视频控制器就将新的一帧数据的下半段显示到屏幕上,造成 屏幕撕裂 现象

为了解决这个问题,采用了 垂直同步信号机制,即固定屏幕刷新率 每秒刷新60次,当开启垂直同步后,GPU会等待显示器的VSync信号发出后,才进行新的一帧渲染和FrameBuffer更新,而目前iOS设备中采用的正是 双缓存区+VSync

屏幕卡顿原因

下面我们来说说,屏幕卡顿的原因

VSync 信号到来后,系统图形服务 会通过 CADisplayLink 等机制通知 App,App 主线程开始在 CPU 中计算显示内容,随后CPU会将计算好的内容提交到GPU去。由GPU进行变换、合成、渲染。随后 GPU 会把渲染结果提交到 帧缓冲区 去,等待下一次 VSync 信号到来时显示到屏幕上。由于垂直同步信号的机制,如果在一个VSync时间内,CPU后者GPU没有完成内容提交,则那一帧就会被丢弃,等待下一次机会再显示,而这时显示屏会保留之前的内容不改变。所以可以简单理解 掉帧过时不候

如下图所示,是一个显示过程,第1个在VSync到来期间,GPU渲染提前完成,正常显示;第2个在VSync期间,仍在处理中,此时屏幕不刷新,依旧显示第1帧;第3个VSync期间,切换到另一个FrameBuffer,让CPU渲染;此时就出现了 掉帧2 情况,渲染时就会出现明显的 卡顿现象

从图中可以看出,CPU和GPU不论是哪个阻碍了显示流程,都会造成 掉帧 现象,所以为了给用户提供更好的体验,在开发中,我们需要进行 卡顿检测 以及相应的 优化

卡顿监控

卡顿监控的方案一般有三种:

  • YYFPSLabel:通过 CADisplayLink + YYWeakProxy

  • FPS监控:为了保持流程的UI交互,App的刷新频率应该保持在 60fps 左右,其原因是因为 iOS 设备默认的刷新频率是 60次/秒,而1次刷新(即VSync信号发出)的间隔是 1000ms/60次= 16.67ms/1次 ,所以如果在 16.67ms 内没有准备好下一帧数据,就会产生卡顿

  • 主线程卡顿监控:通过 子线程监测主线程Runloop,判断两个状态(kCFRunLoopBeforeSourcekCFRunLoopAfrerWaiting)之间的耗时是否达到一定阀值

FPS监控

FPS的监控,主要是通过 CADislayLink 实现,借助 link 的时间差,来计算一次刷新所需要的时间,然后通过 刷新次数/时间差 得到刷新频次,并判断是否在其范围,通过显示不同的文字颜色来表示卡顿严重成都。

  • 创建一个 SPSDisplay 工具类,实现如下代码
1
2
3
4
5
#import <UIKit/UIKit.h>

@interface FPSDisplay: NSObject
+ (instancetype)shareFPSDisplay;
@end
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
#import "FPSDisplay.h"

#define SCREEN_HEIGHT [UIScreen mainScreen].bounds.size.height
#define SCREEN_WIDTH [UIScreen mainScreen].bounds.size.width

@interface FPSDisplay ()

@property (strong, nonatomic) UILabel *displayLabel;
@property (strong, nonatomic) CADisplayLink *link;
@property (assign, nonatomic) NSInteger count;
@property (assign, nonatomic) NSTimeInterval lastTime;
@property (strong, nonatomic) UIFont *font;
@property (strong, nonatomic) UIFont *subFont;

@end

@implementation FPSDisplay

+ (instancetype)shareFPSDisplay {
static FPSDisplay *shareDisplay;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
shareDisplay = [[FPSDisplay alloc] init];
});

return shareDisplay;
}

- (instancetype)init {
self = [super init];
if (self) {
[self initDisplayLabel];
}
return self;
}

- (void)initDisplayLabel {
CGRect frame = CGRectMake(SCREEN_WIDTH - 100, 44, 80, 30);
self.displayLabel = [[UILabel alloc] initWithFrame: frame];
self.displayLabel.layer.cornerRadius = 5;
self.displayLabel.clipsToBounds = YES;
self.displayLabel.textAlignment = NSTextAlignmentCenter;
self.displayLabel.userInteractionEnabled = NO;
self.displayLabel.backgroundColor = [UIColor colorWithWhite:0.000 alpha:0.700];
_font = [UIFont fontWithName:@"Menlo" size:14];
if (_font) {
_subFont = [UIFont fontWithName:@"Menlo" size:4];
} else {
_font = [UIFont fontWithName:@"Courier" size:14];
_subFont = [UIFont fontWithName:@"Courier" size:4];
}

[self initCADisplayLink];

[[self keyWindow] addSubview:self.displayLabel];
}

-(UIWindow*)keyWindow
{
UIWindow *foundWindow = nil;
NSArray *windows = [[UIApplication sharedApplication]windows];
for (UIWindow *window in windows) {
if (window.isKeyWindow) {
foundWindow = window;
break;
}
}
return foundWindow;
}

- (void)initCADisplayLink {
self.link = [CADisplayLink displayLinkWithTarget:self selector:@selector(tick:)];
[self.link addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes];
}

- (void)tick:(CADisplayLink *)link {
if (self.lastTime == 0) { //对LastTime进行初始化
self.lastTime = link.timestamp;
return;
}

self.count += 1; //记录tick在1秒内执行的次数
NSTimeInterval delta = link.timestamp - self.lastTime; //计算本次刷新和上次更新FPS的时间间隔

//大于等于1秒时,来计算FPS
if (delta >= 1) {
self.lastTime = link.timestamp;
float fps = self.count / delta; // 次数 除以 时间 = FPS (次/秒)
self.count = 0;
[self updateDisplayLabelText: fps];
}
}

- (void)updateDisplayLabelText: (float) fps {
CGFloat progress = fps / 60.0;
UIColor *color = [UIColor colorWithHue:0.27 * (progress - 0.2) saturation:1 brightness:0.9 alpha:1];
self.displayLabel.text = [NSString stringWithFormat:@"%d FPS",(int)round(fps)];
self.displayLabel.textColor = color;
}

- (void)dealloc {
[_link invalidate];
}

@end
  • 使用
1
[FPSDisplay shareFPSDisplay];

主线程卡顿监控

除了FPS,还可以通过 RunLoop 来监控,因为卡顿的是事务,而事务是交由 主线程RunLoop 处理的。

也可以直接使用三方库

  • OC 可以使用 微信matrix 滴滴DoramonKit

  • Swift 的卡顿检测第三方 ANREye ,其主要思路是:创建子线程进行循环监测,每次检测时设置标记置为true,然后派发任务到主线程,标记置为false,接着子线程睡眠超过阀值时,判断标记是否为false,如果没有,说明主线程发生了卡顿

界面优化

CPU层面的优化

  • 尽量 用轻量级的对象 代替重量级的对象,可以对性能有所优化,例如: 不需要相应 触摸 时间的控件,用 CALayer 代替 UIView

  • 尽量减少 对 UIViewCALayer 的属性修改

    • CALayer内部并没有属性,当调用属性方法时,其内部是通过运行时 resolveInstanceMethod 为对象临时添加一个方法,并将对应属性值保存在内部的一个 Dictionary中,同时还会通知delegate、创建动画等,非常haoshi

    • UIView 相关的显示属性,例如frame、bounds、transform等,实际上都是从CALayer映射来的,对齐进行调整时,消耗的资源比一般属性要大

  • 当有大量对象释放时,也是非常耗时的,尽量挪到后台线程去释放

  • 尽量 提前计算视图布局,即 预排版,例如cell的行高

  • Autolayout 在简单页面情况下我们可以很好的提升开发效率,但是对于复杂视图而言,会产生严重的性能问题,随着视图数量的增长,Autolayout 带来的CPU消耗是程指数上升的,所以尽量使用 代码布局,如果不想手动调整frame等,也可以借助三方库,例如Masonry(OC)、SnapKit(swift)等等

  • 文本处理的优化:当一个界面有大量文本时,其行高的计算、绘制也是非常耗时的

    • 如果对文本没有特殊要求,可以使用UILabel内部的实现方式,且需要放到子线程中进行,避免则塞主线程

      • 计算文本宽高:[NSAttributedString boundingRectWithSize:options:context:]

      • 文本绘制: [NSAttributedString drawWithRect:options:context:]

    • 自定义文本控件,利用 TextKit 或最底层的 CoreText 对文本 异步绘制。并且 CoreText 对象创建好后,能直接获取文本的宽高等信息,避免了多次计算(调整和绘制都需要计算一次),CoreText直接使用了 CoreGraphics 占用内存小,效率高

  • 图片处理(解码+绘制)

    • 当使用 UIImageCGImageSource 的方法创建图片时,图片的数据不会立即解码,而是在设置时解码(即图片设置到 UIImageView/CALayer.contents 中,然后在 CALayer 提交至GPU渲染前,CGImage 中的数据才进行解码)。这一步是 无可避免 的,且是发生在 主线程 中的。想要绕开这个机制,常见的做法是在子线程中优先将图片绘制到 CGBitmapContext,然后从 Bitmap 直接创建图片,例如 SDWebImage 三方框架对图片的编解码处理,这就是 Image预解码

    • 当使用 CG 开头的方法 绘制图像 到画布中,然后从画布中创建图片时,可以将图像的 绘制子线程 中进行

  • 图片优化

    • 尽量使用 PNG 格式图片,不适用 JPEG 图片

    • 通过 子线程预解码,主线程渲染,即通过 Bitmap 创建图片,在子线程赋值 image

    • 优化图片大小,尽量避免动态缩放

    • 尽量将多张图合并为一张进行显示

  • 尽量 避免使用透明的view,因为使用透明的view,会导致在GPU中计算像素时,会将透明view下层图层的像素也计算进来,即 颜色混合 处理

  • 按需加载 ,例如在TableView中滑动时不加载图片,使用默认占位图,而是在滑动停止时加载

  • 少使用 addViewcell 动态添加 view

GPU层面的优化

相对于CPU而言,GPU主要是接收CPU提交的 纹理+顶点,经过一系列 transform,最终 混合并渲染,输出到 屏幕上

  • 尽量 减少在短时间内大量图片的显示,尽可能将 多张图片合为一张显示,主要是因为当有大量图片进行显示时,无论是CPU计算还是GPU的渲染,都是非常耗时的,很可能出现掉帧的情况

  • 尽量 避免图片的尺寸超过4096x4096,因为当图片超过这个尺寸时,会先由CPU进行预处理,然后再提交给GPU处理,导致额外CPU资源消耗

  • 尽量 减少视图数量和层次,主要是因为视图过多且重叠时,GPU会将其混合,混合的过程也是非常耗时的

  • 尽量避免离屏渲染

  • 异步渲染,例如可以将cell中的所有控件、视图合并成一张图片进行显示。可以参考 Graver 三方框架

注:上述这些优化方式的落地实现,需要根据自身项目进行评估,合理的使用进行优化

  • Post title:OC底层原理38:界面优化方案
  • Post author:张建
  • Create time:2021-06-09 14:49:41
  • Post link:https://redefine.ohevan.com/2021/06/09/OC底层原理/OC底层原理38:界面优化方案/
  • Copyright Notice:All articles in this blog are licensed under BY-NC-SA unless stating additionally.