iOS 底层原理:界面优化
界面优化无非就是解决卡顿问,优化界面流畅度,以下就通过先分析卡顿的原因,然后再介绍具体的优化方案,来分析如何做界面优化
-
界面渲染流程
具体流程可以参考图片渲染初探[1]这里就大概讲一下图片渲染的流程,大体上可以分为三个阶段就是
CPU
处理阶段GPU
处理阶段和视频控制器显示阶段。大致流程图解如下:
图片
苹果为了解决图片撕裂的问题使用了
VSync
+ 双缓冲区的形式,就是显示器显示完成一帧的渲染的时候会向 发送一个垂直信号VSync
,收到这个这个垂直信号之后显示器开始读取另外一个帧缓冲区中的数据而App
接到垂直信号之后开始新一帧的渲染。
-
CPU
主要是计算出需要渲染的模型数据 -
GPU
主要是根据CPU
提供的渲染模型数据渲染图片然后存到帧缓冲区 - 视频控制器冲帧缓冲区中读取数据最后成像
-
卡顿原理
通过上文张的界面渲染流程知道,在图一帧渲染完成之后会发送一个垂直信号此时开始读取另外一个帧缓冲区中的数据,加入此时
图片CPU
和GPU
的工作还没有完成,也就是另外一个帧缓冲区还是加锁状态没有数据的时候,此时显示器显示的还是上一帧的图像那么这种情况就会一直等待下一帧绘制完成然后视频控制器再读取另外一个帧缓冲区中的数据然后成像,中间这个等待的过程就造成了掉帧,也就是会卡顿。
卡顿图解如下:
这种情况随会造成卡顿
-
卡顿检测
-
FPS监控
苹果的iPhone推荐的刷新率是60Hz
,也就是每秒中刷新屏幕60次,也就是每秒中有60帧渲染完成,差不多每帧渲染的时间是1000/60 = 16.67
毫秒整个界面会比较流畅,一般刷新率低于45Hz
的就会出现明显的卡顿现象。这里可以通过YYFPSLabel
来实现FPS
的监控,该原理主要是依靠 CADisplayLink
来实现的,通过CADisplayLink
来监听每次屏幕刷新并获取屏幕刷新的时间,然后使用次数(也就是1)除以每次刷新的时间间隔得到FPS
,具体源码如下:
#import "YYFPSLabel.h"
#import "YYKit.h"
#define kSize CGSizeMake(55, 20)
@implementation YYFPSLabel {
CADisplayLink *_link;
NSUInteger _count;
NSTimeInterval _lastTime;
UIFont *_font;
UIFont *_subFont;
NSTimeInterval _llll;
}
- (instancetype)initWithFrame:(CGRect)frame {
if (frame.size.width == 0 && frame.size.height == 0) {
frame.size = kSize;
}
self = [super initWithFrame:frame];
self.layer.cornerRadius = 5;
self.clipsToBounds = YES;
self.textAlignment = NSTextAlignmentCenter;
self.userInteractionEnabled = NO;
self.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];
}
//YYWeakProxy 这里使用了虚拟类来解决强引用问题
_link = [CADisplayLink displayLinkWithTarget:[YYWeakProxy proxyWithTarget:self] selector:@selector(tick:)];
[_link addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes];
return self;
}
- (void)dealloc {
[_link invalidate];
}
- (CGSize)sizeThatFits:(CGSize)size {
return kSize;
}
- (void)tick:(CADisplayLink *)link {
if (_lastTime == 0) {
_lastTime = link.timestamp;
NSLog(@"sdf");
return;
}
//次数
_count++;
//时间
NSTimeInterval delta = link.timestamp - _lastTime;
if (delta < 1) return;
_lastTime = link.timestamp;
float fps = _count / delta;
_count = 0;
CGFloat progress = fps / 60.0;
UIColor *color = [UIColor colorWithHue:0.27 * (progress - 0.2) saturation:1 brightness:0.9 alpha:1];
NSMutableAttributedString *text = [[NSMutableAttributedString alloc] initWithString:[NSString stringWithFormat:@"%d FPS",(int)round(fps)]];
[text setColor:color range:NSMakeRange(0, text.length - 3)];
[text setColor:[UIColor whiteColor] range:NSMakeRange(text.length - 3, 3)];
text.font = _font;
[text setFont:_subFont range:NSMakeRange(text.length - 4, 1)];
self.attributedText = text;
}
@end
FPS
只用在开发阶段的辅助性的数值,因为他会频繁唤醒 runloop
如果 runloop
在闲置的状态被 CADisplayLink
唤醒则会消耗性能。
-
通过RunLoop检测卡顿
通过监听主线程 Runloop
一次循环的时间来判断是否卡顿,这里需要配合使用 GCD
的信号量来实现,设置初始化信号量为0,然后开一个子线程等待信号量的触发,也是就是在子线程的方法里面调用 dispatch_semaphore_wait
方法设置等待时间是1秒,然后主线程的 Runloop
的 Observer
回调方法中发送信号也就是调用 dispatch_semaphore_signal
方法,此时时间可以置为0了,如果是等待时间超时则看此时的 Runloop
的状态是否是 kCFRunLoopBeforeSources
或者是 kCFRunLoopAfterWaiting
,如果在这两个状态下两秒则说明有卡顿,详细代码如下:(代码中也有相关的注释)
#import "LGBlockMonitor.h"
@interface LGBlockMonitor (){
CFRunLoopActivity activity;
}
@property (nonatomic, strong) dispatch_semaphore_t semaphore;
@property (nonatomic, assign) NSUInteger timeoutCount;
@end
@implementation LGBlockMonitor
+ (instancetype)sharedInstance {
static id instance = nil;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
instance = [[self alloc] init];
});
return instance;
}
- (void)start{
[self registerObserver];
[self startMonitor];
}
static void CallBack(CFRunLoopObserverRef observer, CFRunLoopActivity activity, void *info)
{
LGBlockMonitor *monitor = (__bridge LGBlockMonitor *)info;
monitor->activity = activity;
// 发送信号
dispatch_semaphore_t semaphore = monitor->_semaphore;
dispatch_semaphore_signal(semaphore);
}
- (void)registerObserver{
CFRunLoopObserverContext context = {0,(__bridge void*)self,NULL,NULL};
//NSIntegerMax : 优先级最小
CFRunLoopObserverRef observer = CFRunLoopObserverCreate(kCFAllocatorDefault,
kCFRunLoopAllActivities,
YES,
NSIntegerMax,
&CallBack,
&context);
CFRunLoopAddObserver(CFRunLoopGetMain(), observer, kCFRunLoopCommonModes);
}
- (void)startMonitor{
// 创建信号c
_semaphore = dispatch_semaphore_create(0);
// 在子线程监控时长
dispatch_async(dispatch_get_global_queue(0, 0), ^{
while (YES)
{
// 超时时间是 1 秒,没有等到信号量,st 就不等于 0, RunLoop 所有的任务
// 没有接收到信号底层会先对信号量进行减减操作,此时信号量就变成负数
// 所以开始进入等到,等达到了等待时间还没有收到信号则进行加加操作复原信号量
// 执行进入等待的方法dispatch_semaphore_wait会返回非0的数
// 收到信号的时候此时信号量是1 底层是减减操作,此时刚好等于0 所以直接返回0
long st = dispatch_semaphore_wait(self->_semaphore, dispatch_time(DISPATCH_TIME_NOW, 1 * NSEC_PER_SEC));
if (st != 0)
{
if (self->activity == kCFRunLoopBeforeSources || self->activity == kCFRunLoopAfterWaiting)
{
//如果一直处于处理source0或者接受mach_port的状态则说明runloop的这次循环还没有完成
if (++self->_timeoutCount < 2){
NSLog(@"timeoutCount==%lu",(unsigned long)self->_timeoutCount);
continue;
}
// 如果超过两秒则说明卡顿了
// 一秒左右的衡量尺度 很大可能性连续来 避免大规模打印!
NSLog(@"检测到超过两次连续卡顿");
}
}
self->_timeoutCount = 0;
}
});
}
@end
-
微信matrix
此方案也是借助 runloop
实现的大体流程和方案三相同,不过微信加入了堆栈分析,能够定位到耗时的方法调用堆栈,所以需要准确的分析卡顿原因可以借助微信matrix来分析卡顿。当然也可以在方案2中使用 PLCrashReporter
这个开源的第三方库来获取堆栈信息
-
滴滴DoraemonKit
实现方案大概就是在子线程中一直 ping
主线程,在主线程卡顿的情况下,会出现断在的无响应的表现,进而检测卡顿
-
优化方案
上文中分析卡顿的原因我们知道主要就是在
CPU
和GPU
阶段占用时间太长导致了掉帧卡顿,所以界面优化主要工作就是给CPU
和GPU
减负 -
预排版
预排版主要是对
图片CPU
进行减负。
假设现在又个TableView
其中需要根据每个cell
的内容来定cell
的高度。我们知道TableView
有重用机制,如果复用池中有数据,即将滑入屏内的cell
就会使用复用池内的cell
,做到节省资源,但是还是要根据新数据的内容来计算cell
的高度,重新布局新cell
中内容的布局 ,这样反复滑动TableView
相同的cell
就会反复计算其frame
,这样也给CPU
带来了负担。如果在得到数据创建模型的时候就把cell
frame
算出,TableView
返回模型中的frame
这样的话同样的一条cell
就算来回反复滑动TableView
,计算frame
这个操作也就仅仅只会执行一次,所以也就做到了减负的功能,如下图:一个cell
的组成需要modal
找到数据,也需要layout
找到这个cell
如何布局: -
预解码 & 预渲染
图片的渲染流程,在
图片CPU
阶段拿到图片的顶点数据和纹理之后会进行解码生产位图,然后传递到GPU
进行渲染主要流程图如下如果图片很多很大的情况下解码工作就会占用主线程
图片RunLoop
导致其他工作无法执行比如滑动,这样就会造成卡顿现象,所以这里就可以将解码的工作放到异步线程中不占用主线程,可能有人会想只要将图片加载放到异步线程中在异步线程中生成一个UIImage
或者是CGImage
然后再主线程中设置给UIImageView
,此时可以写段代码使用instruments
的Time Profiler
查看一下堆栈信息发现图片的编解码还是在主线程。针对这种问题常见的做法是在子线程中先将图片绘制到
CGBitmapContext
,然后从Bitmap
直接创建图片,例如SDWebImage
三方框架中对图片编解码的处理。这就是Image
的预解码,代码如下:
dispatch_async(queue, ^{
CGImageRef cgImage = [UIImage imageWithData:[NSData dataWithContentsOfURL:[NSURL URLWithString:self]]].CGImage;
CGImageAlphaInfo alphaInfo = CGImageGetAlphaInfo(cgImage) & kCGBitmapAlphaInfoMask;
BOOL hasAlpha = NO;
if (alphaInfo == kCGImageAlphaPremultipliedLast ||
alphaInfo == kCGImageAlphaPremultipliedFirst ||
alphaInfo == kCGImageAlphaLast ||
alphaInfo == kCGImageAlphaFirst) {
hasAlpha = YES;
}
CGBitmapInfo bitmapInfo = kCGBitmapByteOrder32Host;
bitmapInfo |= hasAlpha ? kCGImageAlphaPremultipliedFirst : kCGImageAlphaNoneSkipFirst;
size_t width = CGImageGetWidth(cgImage);
size_t height = CGImageGetHeight(cgImage);
CGContextRef context = CGBitmapContextCreate(NULL, width, height, 8, 0, CGColorSpaceCreateDeviceRGB(), bitmapInfo);
CGContextDrawImage(context, CGRectMake(0, 0, width, height), cgImage);
cgImage = CGBitmapContextCreateImage(context);
UIImage * image = [[UIImage imageWithCGImage:cgImage] cornerRadius:width * 0.5];
CGContextRelease(context);
CGImageRelease(cgImage);
completion(image);
});
-
按需加载
顾名思义需要显示的加载出来,不需要显示的加载,例如
TableView
中的图片滑动的时候不加载,在滑动停止的时候加载(可以使用Runloop
,图片绘制设置defaultModal
就行) -
异步渲染
再说异步渲染之前先了解一下
UIView
和CALayer
的关系:
-
UIView
是基于UIKit
框架的,能够接受点击事件,处理用户的触摸事件,并管理子视图 -
CALayer
是基于CoreAnimation
,而CoreAnimation
是基于QuartzCode
的。所以CALayer
只负责显示,不能处理用户的触摸事件 -
UIView
是直接继承UIResponder
的,CALayer
是继承NSObject
的 -
UIVIew
的主要职责是负责接收并响应事件;而CALayer
的主要职责是负责显示UI
。UIView
依赖于CALayer
得以显示
总结:UIView
主要负责时间处理,CALayer
主要是视图显示 异步渲染的原理其实也就是在子线程将所有的视图绘制成一张位图,然后回到主线程赋值给 layer
的 contents
,例如 Graver
框架的异步渲染流程如下:
核心源码如下:
if (drawingFinished && targetDrawingCount == layer.drawingCount)
{
CGImageRef CGImage = context ? CGBitmapContextCreateImage(context) : NULL;
{
// 让 UIImage 进行内存管理
// 最终生成的位图
UIImage *image = CGImage ? [UIImage imageWithCGImage:CGImage] : nil;
void (^finishBlock)(void) = ^{
// 由于block可能在下一runloop执行,再进行一次检查
if (targetDrawingCount != layer.drawingCount)
{
failedBlock();
return;
}
//主线程中赋值完成显示
layer.contents = (id)image.CGImage;
// ...
}
if (drawInBackground) dispatch_async(dispatch_get_main_queue(), finishBlock);
else finishBlock();
}
// 一些清理工作: release CGImageRef, Image context ending
}
最终效果图如下:
图片
也可以使用 YYAsyncLayer
和
-
其他
- 减少图层的层级
- 减少离屏渲染
- 图片显示的话图片的大小设置(不要太大)
- 少使用
addView
给cell
动态添加view
- 尽量避免使用透明
view
,因为使用透明view
,会导致在GPU
中计算像素时,会将透明view
下层图层的像素也计算进来,即颜色混合处理(当有两个图层的时候一个是半透明一个是不透明如果半透明的层级更高的话此时就会触发颜色混合,底层的混合并不是仅仅的将两个图层叠加而是会将两股颜色混合计算出新的色值显示在屏幕中)