界面优化无非就是解决卡顿问,优化界面流畅度,以下就通过先分析卡顿的原因,然后再介绍具体的优化方案,来分析如何做界面优化。
界面渲染流程
具体流程可以参考图片渲染初探[1]这里就大概讲一下图片渲染的流程,大体上可以分为三个阶段就是 CPU处理阶段 GPU处理阶段和视频控制器显示阶段。
大致流程图解如下:
苹果为了解决图片撕裂的问题使用了 VSync + 双缓冲区的形式,就是显示器显示完成一帧的渲染的时候会向 发送一个垂直信号 VSync,收到这个这个垂直信号之后显示器开始读取另外一个帧缓冲区中的数据而 App接到垂直信号之后开始新一帧的渲染。
- CPU主要是计算出需要渲染的模型数据
- GPU主要是根据 CPU提供的渲染模型数据渲染图片然后存到帧缓冲区
- 视频控制器冲帧缓冲区中读取数据最后成像
卡顿原理
通过上文张的界面渲染流程知道,在图一帧渲染完成之后会发送一个垂直信号此时开始读取另外一个帧缓冲区中的数据,加入此时 CPU和 GPU的工作还没有完成,也就是另外一个帧缓冲区还是加锁状态没有数据的时候,此时显示器显示的还是上一帧的图像那么这种情况就会一直等待下一帧绘制完成然后视频控制器再读取另外一个帧缓冲区中的数据然后成像,中间这个等待的过程就造成了掉帧,也就是会卡顿。
卡顿图解如下:
这种情况随会造成卡顿
卡顿检测
1.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唤醒则会消耗性能。
2.通过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
3.微信matrix
此方案也是借助 runloop实现的大体流程和方案三相同,不过微信加入了堆栈分析,能够定位到耗时的方法调用堆栈,所以需要准确的分析卡顿原因可以借助微信matrix来分析卡顿。当然也可以在方案2中使用 PLCrashReporter这个开源的第三方库来获取堆栈信息
4.滴滴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
- }
最终效果图如下:
其他
- 减少图层的层级
- 减少离屏渲染
- 图片显示的话图片的大小设置(不要太大)
- 少使用addView 给cell动态添加view
- 尽量避免使用透明view,因为使用透明view,会导致在GPU中计算像素时,会将透明view下层图层的像素也计算进来,即颜色混合处理(当有两个图层的时候一个是半透明一个是不透明如果半透明的层级更高的话此时就会触发颜色混合,底层的混合并不是仅仅的将两个图层叠加而是会将两股颜色混合计算出新的色值显示在屏幕中)
原文地址:https://juejin.cn/post/6977666830114816030