在日常开发中,我们最多遇到的就是UI绘制,内容展示等需求的开发,APP的UI展示是否流畅,也是用户最直接的感受。今天就针对UI界面卡顿原理进行分析,如何优化进行讨论。
一. 卡顿原理
计算机正常的渲染流畅:
通过CPU计算GPU生成
FrameBuffer
在进行Video Controller
显示到显示器上(monitor)
优化性能后:
在原理基础上增加了一个buffer缓冲区,显示刷帧率60fps/120fps 来回在两个缓冲区取帧。
卡顿原因
:如果GPU在某一帧生成中生成不及时,显示取帧时就会在buffer 1 和 buffer 2 来回跑,等待这一帧的生成。这时候就会发生界面UI卡顿。如果这帧没有生成,下一帧生成了就会直接跳过这一帧显示下一帧(这就是掉帧
情况)。
核心问题:
- 已经知道卡顿的原因
- 怎么监测卡顿呢
- 有哪些方法能够监测卡顿呢
1、卡顿检测
用什么来监测卡顿呢,我们这里用到RunLoop。我们知道RunLoop是主运行循环
,可以保存任务的生命周期。(60FPS=16.67ms=1/60)
我们大致了解一下RunLoop:
主要思路就是,添加监测任务到RunLoop中来监测Vsync(垂直同步信号)来判断UI是否卡顿。
这里借鉴了YYKit
的YYFPSLabel
运行代码如图:
创建一个工程NYMainThreadBlock:来感受监测卡顿的核心思想;
注册自定义observer任务到runloop并且发送信号量->NYBlockMonitor中,
NYBlockMonitor有一个子线程无限循环->等待判断自定义observer的休眠
,唤醒的信号量,用来评判整个系统runloop的工作情况,因为UI的渲染工作也在runloop的系统任务中,其他优先级高的任务占用大量runloop的运行时间,我们自定义的observer任务就会发生等待休眠,这样我们就能够判断出UI是否卡顿。(如上图所示)
核心代码:
// NYBlockMonitor.m
// NYMainThreadBlock
//
// Created by ning on 2022/7/12.
//
#import "NYBlockMonitor.h"
@interface NYBlockMonitor(){
CFRunLoopActivity acticity; //状态集
}
@property (nonatomic,strong) dispatch_semaphore_t semaphore;
@property (nonatomic,assign) NSUInteger timeoutCount;
@end
@implementation NYBlockMonitor
+ (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 CallBock(CFRunLoopObserverRef observer,CFRunLoopActivity activity,void *info)
{
NYBlockMonitor *monitor = (__bridge NYBlockMonitor *)info;
monitor->acticity = activity;
//发送信号
dispatch_semaphore_t semaphore = monitor->_semaphore;
dispatch_semaphore_signal(semaphore); //信号+1
}
- (void)registerObserver
{
CFRunLoopObserverContext context = {0,(__bridge void*)self,NULL,NULL};
//NSIntegerMax :优先级最小
CFRunLoopObserverRef observer = CFRunLoopObserverCreate(kCFAllocatorDefault,
kCFRunLoopAllActivities,
YES,
NSIntegerMax,
&CallBock,
&context);
CFRunLoopAddObserver(CFRunLoopGetMain(), observer, kCFRunLoopCommonModes);
}
- (void)startMonitor{
//创建信号
_semaphore = dispatch_semaphore_create(0);
//在子线程监控时长
dispatch_async(dispatch_get_global_queue(0, 0), ^{
while (YES) //死循环监听
{
// 超时时间是 1 秒,没有等到信号量, st 就不等于 0 ,RunLoop 所有的任务
long st = dispatch_semaphore_wait(self->_semaphore, dispatch_time(DISPATCH_TIME_NOW, 1 * NSEC_PER_SEC));
if (st != 0) {
// 即将处理 Source , 刚从休眠中唤醒 进入判断
if (self->acticity == kCFRunLoopBeforeSources || self->acticity == kCFRunLoopAfterWaiting) {
if (++self->_timeoutCount < 2) {
NSLog(@"timeoutCount=%lu",(unsigned long)self->_timeoutCount);
continue;
}
// 一秒左右的衡量尺度 很大可能性连续来 避免大规模打印!
NSLog(@"检测到超过两次连续卡顿 - %ld",(unsigned long)self->_timeoutCount);
}
}
self->_timeoutCount = 0;
}
});
}
@end
运行效果:
二. 界面优化
1、预排版
常规MVC模式中,有可能在view层中计算frame的大小,及相关UI的size。这在 UI显示熏染中会损耗性能。怎么解决这一问题?就是把view的大小及排版归类到model中在子线程中就把view的排版计算好了,这样可以减少UI view 的渲染损耗。
上一段小代码:
@implementation NYTimeLineCellLayout
- (instancetype)initWithModel:(LGTimeLineModel *)timeLineModel
{
if (!timeLineModel) return nil;
self = [super init];
if (self) {
_timeLineModel = timeLineModel;
[self layout];
}
return self;
}
- (void)setTimeLineModel:(LGTimeLineModel *)timeLineModel
{
_timeLineModel = timeLineModel;
[self layout];
}
- (void)layout
{
CGFloat sWidth = [UIScreen mainScreen].bounds.size.width;
self.iconRect = CGRectMake(10, 10, 45, 45);
CGFloat nameWidth = [self calcWidthWithTitle:_timeLineModel.name font:titleFont];
CGFloat nameHeight = [self calcLabelHeight:_timeLineModel.name fontSize:titleFont width:nameWidth];
self.nameRect = CGRectMake(CGRectGetMaxX(self.iconRect) + nameLeftSpaceToHeadIcon, 17, nameWidth, nameHeight);
CGFloat msgWidth = sWidth - 10 - 16;
CGFloat msgHeight = 0;
//文本信息高度计算
//**********************省略代码***********************//
self.height = CGRectGetMaxY(self.seperatorViewRect);
}
#pragma mark **-- Caculate Method**
- (CGFloat)calcWidthWithTitle:(NSString *)title font:(CGFloat)font {
NSStringDrawingOptions options = NSStringDrawingUsesLineFragmentOrigin | NSStringDrawingUsesFontLeading;
CGRect rect = [title boundingRectWithSize:CGSizeMake(MAXFLOAT,MAXFLOAT) options:options attributes:@{NSFontAttributeName:[UIFont systemFontOfSize:font]} context:nil];
CGFloat realWidth = ceilf(rect.size.width);
return realWidth;
}
- (CGFloat)calcLabelHeight:(NSString *)str fontSize:(CGFloat)fontSize width:(CGFloat)width {
NSStringDrawingOptions options = NSStringDrawingUsesLineFragmentOrigin | NSStringDrawingUsesFontLeading;
CGRect rect = [str boundingRectWithSize:CGSizeMake(width,MAXFLOAT) options:options attributes:@{NSFontAttributeName:[UIFont systemFontOfSize:fontSize]} context:nil];
CGFloat realHeight = ceilf(rect.size.height);
return realHeight;
}
- (int)caculateAttributeLabelHeightWithString:(NSAttributedString *)string width:(int)width {
int total_height = 0;
CTFramesetterRef framesetter = CTFramesetterCreateWithAttributedString((CFAttributedStringRef)string); //string 为要计算高度的NSAttributedString
CGRect drawingRect = CGRectMake(0, 0, width, 100000); //这里的高要设置足够大
CGMutablePathRef path = CGPathCreateMutable();
CGPathAddRect(path, NULL, drawingRect);
CTFrameRef textFrame = CTFramesetterCreateFrame(framesetter,CFRangeMake(0,0), path, NULL);
CGPathRelease(path);
CFRelease(framesetter);
NSArray *linesArray = (NSArray *) CTFrameGetLines(textFrame);
CGPoint origins[[linesArray count]];
CTFrameGetLineOrigins(textFrame, CFRangeMake(0, 0), origins);
int line_y = (int) origins[[linesArray count] -1].y; //最后一行line的原点y坐标
CGFloat ascent;
CGFloat descent;
CGFloat leading;
CTLineRef line = (__bridge CTLineRef) [linesArray objectAtIndex:[linesArray count]-1];
CTLineGetTypographicBounds(line, &ascent, &descent, &leading);
total_height = 100000 - line_y + (int) descent +1; //+1为了纠正descent转换成int小数点后舍去的值
CFRelease(textFrame);
return total_height;
}
@end
//TableViewCell 添加配置NYTimeLineCellLayout 方法
- (void)configureLayout:(NYTimeLineCellLayout *)layout{
//**********************省略代码***********************//
}
这样就达到预排版的目的了(挺简单的手段,大家都能想到吧)。
2、预编码解码
现在项目开发中,还有一种情况会对UI熏染性能造成消耗。就是图片的加载,为什么图片会对系统造成负担呢,要如何减少图片加载带来的过多消耗呢?
UIImage *image = [UIImage imageWithContentsOfFile:@"/xxxxx.png"];
self.kcImageView.image = image;
运行项目,查看占用内存情况。
可实际图片大小是31.4MB
如果改为如下代码(苹果官方文档的下采样方式):
// Objective-C: 大图缩小为显示尺寸的图
- (UIImage *)downsampleImageAt:(NSURL *)imageURL to:(CGSize)pointSize scale:(CGFloat)scale {
// 利用图像文件地址创建 image source
NSDictionary *imageSourceOptions = @{(__bridge NSString *)kCGImageSourceShouldCache: @NO // 原始图像不要解码
};
CGImageSourceRef imageSource =
CGImageSourceCreateWithURL((__bridge CFURLRef)imageURL, (__bridge CFDictionaryRef)imageSourceOptions);
// 下采样
CGFloat maxDimensionInPixels = MAX(pointSize.width, pointSize.height) * scale;
NSDictionary *downsampleOptions =
@{
(__bridge NSString *)kCGImageSourceCreateThumbnailFromImageAlways: @YES,
(__bridge NSString *)kCGImageSourceShouldCacheImmediately: @YES, // 缩小图像的同时进行解码
(__bridge NSString *)kCGImageSourceCreateThumbnailWithTransform: @YES,
(__bridge NSString *)kCGImageSourceThumbnailMaxPixelSize: @(maxDimensionInPixels)
};
CGImageRef downsampledImage =
CGImageSourceCreateThumbnailAtIndex(imageSource, 0, (__bridge CFDictionaryRef)downsampleOptions);
UIImage *image = [[UIImage alloc] initWithCGImage:downsampledImage];
CGImageRelease(downsampledImage);
CFRelease(imageSource);
return image;
}
运行效果:
通过下采样解码减少系统对图片加载的消耗。
3、异步渲染
异步渲染是什么意思呢,异步渲染做了什么呢?我们通过一个案例来研究了解一下:
运行项目发现,怎么只有一个图层。正常开放我们在view上创建多种控件,组成了某个界面然后每个控件都有自己的图层。而我们的案例只有一层,这是为什么呢?我们慢慢解开谜底。
运行项目查看堆栈信息:
我们看到有用到图层的地方都会有
CA::Transaction::commit()
这样的代码。Transaction作了什么呢?
iOS中UIKit能显示内容主要依赖的框架如图:
从Core Animation到GPU渲染过程:
Application
中布局 UIKit 视图控件间接的关联Core Animation 图层Core Animation
图层相关的数据提交到iOS Render Server
,即 OpenGL ES & Core GraphicsRender Server
将与 GPU通信把数据经过处理之后传递给 GPUGPU 调用
iOS 当前设备渲染相关的图形设备
Display
Commit Transaction做了什么?
Layout
,构建视图
,frame
,遍历的操作[UIView layerSubview],[CALayer layoutSubLayers]Display
,绘制视图
,display - drawReact(),displayLyaer:(位图的绘制)Prepare
,额外的 Core Animation 工作,比如解码Commit
,打包图层并将它们发送到Render Server
代码:
@implementation NYView
- (void)drawRect:(CGRect)rect {
// Drawing code, 绘制的操作, BackingStore(额外的存储区域产于的) -- GPU
}
+ (Class)layerClass{
return [NYLayer class];
}
- (void)layoutSublayersOfLayer:(CALayer *)layer
{
[super layoutSublayersOfLayer:layer];
[self layoutSubviews];
}
- (CGContextRef)createContext
{
UIGraphicsBeginImageContextWithOptions(self.bounds.size, self.layer.opaque, self.layer.contentsScale);
CGContextRef context = UIGraphicsGetCurrentContext();
return context;
}
- (void)layerWillDraw:(CALayer *)layer{
//绘制的准备工作,do nontihing
}
//////绘制的操作
- (void)drawLayer:(CALayer *)layer inContext:(CGContextRef)ctx{
[super drawLayer:layer inContext:ctx];
[[UIColor redColor] set];
//Core Graphics
UIBezierPath *path = [UIBezierPath bezierPathWithRect:CGRectMake(self.bounds.size.width / 2- 20, self.bounds.size.height / 2- 20, 40, 40)];
CGContextAddPath(ctx, path.CGPath);
CGContextFillPath(ctx);
}
//layer.contents = (位图)
- (void)displayLayer:(CALayer *)layer{
UIImage *image = UIGraphicsGetImageFromCurrentImageContext();
dispatch_async(dispatch_get_main_queue(), ^{
layer.contents = (__bridge id)(image.CGImage);
});
}
- (void)closeContext{
UIGraphicsEndImageContext();
}
@end
@implementation NYLayer
//前面断点调用写下的代码
- (void)layoutSublayers{
if (self.delegate && [self.delegate respondsToSelector:@selector(layoutSublayersOfLayer:)]) {
//UIView
[self.delegate layoutSublayersOfLayer:self];
}else{
[super layoutSublayers];
}
}
//绘制流程的发起函数
- (void)display{
// Graver 实现思路
CGContextRef context = (__bridge CGContextRef)([self.delegate performSelector:@selector(createContext)]);
[self.delegate layerWillDraw:self];
[self drawInContext:context];
[self.delegate displayLayer:self];
[self.delegate performSelector:@selector(closeContext)];
}
@end
运行效果:
绘制的顺序:
layoutSublayersOfLayer
->createContext
-> layerWillDraw
-> drawLayer
-> displayLayer
-> closeContext
也可研究一下
美团开源Graver框架:用“雕刻”诠释iOS端UI界面的高效渲染
给朋友打个广告: