1.界面显示原理
-
CPU
输出位图CPU
工作包括:Layout UI
布局、文本计算、Display
绘制、Prepare
图片解码、Commit
提交位图 -
GPU
图层渲染,纹理合成GPU
渲染管线(OpenGL
):顶点着色、图元装配、光栅化、片段着色、片段处理。 -
把结果放到帧缓冲区(
frame buffer
)中 -
再由视频控制器(
Video Controller
)根据vsync
信号,在指定时间之前,去提取帧缓冲区的屏幕显示内容 -
显示到屏幕(
Monitor
)上
界面显示流程见下图:
2.界面卡顿问题
因为CPU
的计算需要耗时,为了解决这个性能问题,引入了双缓冲的机制,前帧和后帧。
iOS
设备的硬件时钟会发出垂直同步信号(Vsync
),然后CPU
会去计算屏幕要显示的内容,之后将计算好的内容提交到GPU
去渲染。随后,GPU
将渲染结果提交到帧缓冲区,而此时垂直同步信号(VSync
)会在前帧缓存和后帧缓存之间切换,将缓冲区的帧显示到屏幕上。也就是说,一帧的显示是由CPU
和GPU
共同决定的。
一般来说,页面滑动流畅是60fps
(也就是人眼看到的是流畅的效果),也就是1s
有60
帧更新,即每隔16.7ms
就要产生一帧画面,而如果CPU
和GPU
加起来的处理时间超过了16.7ms
,就会造成掉帧甚至卡顿。
总而言之,在规定的16.7ms
内,CPU
和GPU
并没有在下一帧的Vsync
信号到来之前把当前的一帧画面生产完成,由此产生了掉帧卡顿。
3.卡顿检测
-
使用CADisplayLink
YYKit
是一组庞大、功能丰富的iOS
第三方组件,它提供了一个界面卡顿检测的功能。实现逻辑很简单,运用了CADisplayLink
。CADisplayLink
是绑定到垂直同步信号(Vsync
)的计时器类。实现逻辑参考下面代码:@interface ViewController () { CFTimeInterval _lastTime; CADisplayLink *_link; NSUInteger _count; } @end @implementation ViewController - (void)viewDidLoad { [super viewDidLoad]; _link = [CADisplayLink displayLinkWithTarget:self selector:@selector(dealDisPlayLink:)]; [_link addToRunLoop:[NSRunLoop currentRunLoop] forMode:NSRunLoopCommonModes]; } - (void)dealDisPlayLink:(CADisplayLink *)link { if (_lastTime == 0) { _lastTime = link.timestamp; return; } _count++; NSTimeInterval delta = link.timestamp - _lastTime; if (delta < 1) return; _lastTime = link.timestamp; float fps = _count / delta; _count = 0; NSLog(@"%f", fps); } @end
因为
CADisplayLink
是绑定到垂直同步信号(Vsync
)的计时器类,这里绑定了一个方法sel
,并将该link
添加到当前的RunLoop
中,它会触发每一个垂直同步信号,除非暂停,否则它会一直执行。在触发方法中,会记录上一次的时间,并判断两次时间间隔是否大于等于
1秒
,如果大于等于1秒
,计算这段时间内,该方法被执行的次数fps
。运行结果见下图: -
使用
RunLoop
和信号量
通过向
RunLoop
中注册一个Observer
来观察RunLoop
的运行状态,当RunLoop
生命周期状态发生改变时,会执行对应的回调函数,发送信号量,根据控制异步线程被唤醒的情况,来判断事务处理过程中是否存在卡顿显现。见下图实现代码:#import "BlockMonitor.h" @interface BlockMonitor (){ CFRunLoopActivity activity; } @property (nonatomic, strong) dispatch_semaphore_t semaphore; @property (nonatomic, assign) NSUInteger timeoutCount; @end @implementation BlockMonitor + (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{ // 创建信号 _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) { if (self->activity == kCFRunLoopBeforeSources || self->activity == kCFRunLoopAfterWaiting) { if (++self->_timeoutCount < 2){ NSLog(@"timeoutCount==%lu",(unsigned long)self->_timeoutCount); continue; } // 一秒左右的衡量尺度 很大可能性连续来 避免大规模打印! NSLog(@"检测到超过两次连续卡顿"); } } self->_timeoutCount = 0; } }); } @end
- 这里封装了一个
BlockMonitor
工具类,提供start
方法启动测试工具。 - 首选通过
registerObserver
方法向RunLoop
中注册了一个观察者,观察RunLoop
的状态,并设置回调事件callBack
。在回调函数中记录当前RunLoop
活动状态activity
,并发送一个信号量。 - 在
startMonitor
方法中,创建一个信号源,并发数
为0
,也就是说当调用dispatch_semaphore_wait
时,如果没有被signal
唤醒,都会按照一秒一次的超时运行,返回值为非0
;如果被signal
唤醒,返回值为0
。 - 当有
RunLoop
没有事务处理时,因为可并发数为0
,所以dispatch_semaphore_wait
会因超时而被唤醒,并返回非0
,因为没有事务Runloop
处于休眠状态,activity
也不是处理事务的状态,异步线程一秒钟一次,_timeoutCount
一直是0
。 - 当
RunLoop
被唤醒,会对调并执行callBack
函数,从而触发singal
发信号,dispatch_semaphore_wait
会返回0
;当开始处理事务时,如果事务复杂,RunLoop
的状态一直没有改变(一直在处理事务,状态没有发生改变),signal
没有发出信号,wait
超时返回非0
,同时因为此时activity
满足条节,会调用++_timeoutCount
,当连续两次超时_timeoutCount
等于2
,说明连续两次出现卡顿。 - 之后
_timeoutCount
清零,检测下一次卡顿。
这里运用了
RunLoop
和信号量,按照一秒作为时间单位,检测卡顿情况。 - 这里封装了一个
其他的例如微信matrix
、滴滴DoraemonThread
等卡顿检测方式,基本上也是通过子线程、信号量和RunLoop
等的使用,完成卡顿检测,这里不做详细说明。
4.界面优化
那么如何解决界面卡顿问题,实现界面优化呢?
-
预排版
通常在请求到网络数据后,获取对应的
Mode
,而在进行界面排版时比如UITableViewCell
,在heightForRowAtIndexPath
中计算cell
的高度,会在cellForRowAtIndexPath
中进行cell
的初始化,并完成排版工作。这种处理方式很常规,其实在获取cell
对应的Mode
后,多数情况下我们是可以提前知道cell
的高度和样式的,我们可以将这些内容提前计算好,做到预排版。下面通过一些核心代码来分析其处理流程:
- (void)loadData{ // 外面的异步线程:网络请求的线程 dispatch_async(dispatch_get_global_queue(0, 0), ^{ // 加载`JSON 文件`-模拟网络加载 NSString *path = [[NSBundle mainBundle] pathForResource:@"timeLine" ofType:@"json"]; NSData *data = [[NSData alloc] initWithContentsOfFile:path]; NSDictionary *dicJson=[NSJSONSerialization JSONObjectWithData:data options:NSJSONReadingMutableContainers error:nil]; // modes for (id json in dicJson[@"data"]) { LGTimeLineModel *timeLineModel = [LGTimeLineModel yy_modelWithJSON:json]; [self.timeLineModels addObject:timeLineModel]; } // 初始化layout for (LGTimeLineModel *timeLineModel in self.timeLineModels) { LGTimeLineCellLayout *cellLayout = [[LGTimeLineCellLayout alloc] initWithModel:timeLineModel]; [self.layouts addObject:cellLayout]; } // 主线程刷新 dispatch_async(dispatch_get_main_queue(), ^{ [self.timeLineTableView reloadData]; }); }); }
例如上面的代码模拟网络加载数据,在获取数据后形成数据列表
Modes
,并没有直接回到主线程调用刷新界面,而是继续在子线程完成layout
的初始化工作。layout
初始化做了什么呢?这里只贴出部分代码:@class LGTimeLineModel; @interface LGTimeLineCellLayout : NSObject - (instancetype)initWithModel:(LGTimeLineModel *)timeLineModel; @property (nonatomic, assign) CGRect iconRect; @property (nonatomic, assign) CGRect nameRect; @property (nonatomic, assign) CGRect contentRect; @property (nonatomic, assign) CGRect expandRect; @property (nonatomic, assign) BOOL expandHidden; @property (nonatomic, strong) NSMutableArray *imageRects; @property (nonatomic, assign) CGRect seperatorViewRect; @property (nonatomic, assign) CGFloat height; @property (nonatomic, strong) LGTimeLineModel *timeLineModel; @end static const CGFloat nameLeftSpaceToHeadIcon = 10; static const CGFloat titleFont = 15; static const CGFloat msgFont = 15; static const CGFloat msgExpandLimitHeight = 140; static const CGFloat timeAndLocationFont = 13; @interface LGTimeLineCellLayout () @end @implementation LGTimeLineCellLayout - (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; //文本信息高度计算 NSMutableParagraphStyle * paragraphStyle = [[NSMutableParagraphStyle alloc] init]; [paragraphStyle setLineSpacing:5]; NSDictionary *attributes = @{NSFontAttributeName: [UIFont systemFontOfSize:msgFont],NSForegroundColorAttributeName: [UIColor colorWithRed:26/255.0 green:26/255.0 blue:26/255.0 alpha:1],NSParagraphStyleAttributeName: paragraphStyle,NSKernAttributeName:@0}; NSMutableAttributedString *attrStr = [[NSMutableAttributedString alloc] initWithString:_timeLineModel.msgContent attributes:attributes]; msgHeight = [self caculateAttributeLabelHeightWithString:attrStr width:msgWidth]; if (attrStr.length > msgExpandLimitHeight) { if (_timeLineModel.isExpand) { self.contentRect = CGRectMake(10, CGRectGetMaxY(self.iconRect) + 10, msgWidth, msgHeight); } else { attrStr = [[NSMutableAttributedString alloc] initWithString:[_timeLineModel.msgContent substringToIndex:msgExpandLimitHeight] attributes:attributes]; msgHeight = [self caculateAttributeLabelHeightWithString:attrStr width:msgWidth]; self.contentRect = CGRectMake(10, CGRectGetMaxY(self.iconRect) + 10, msgWidth, msgHeight); } } else { self.contentRect = CGRectMake(10, CGRectGetMaxY(self.iconRect) + 10, msgWidth, msgHeight); } if (attrStr.length < msgExpandLimitHeight) { self.expandHidden = YES; self.expandRect = CGRectMake(10, CGRectGetMaxY(self.contentRect) - 20, 30, 20); } else { self.expandHidden = NO; self.expandRect = CGRectMake(10, CGRectGetMaxY(self.contentRect) + 10, 30, 20); } CGFloat timeWidth = [self calcWidthWithTitle:_timeLineModel.time font:timeAndLocationFont]; CGFloat timeHeight = [self calcLabelHeight:_timeLineModel.time fontSize:timeAndLocationFont width:timeWidth]; self.imageRects = [NSMutableArray array]; if (_timeLineModel.contentImages.count == 0) { // self.timeRect = CGRectMake(10, CGRectGetMaxY(self.expandRect) + 10, timeWidth, timeHeight); } else { if (_timeLineModel.contentImages.count == 1) { CGRect imageRect = CGRectMake(11, CGRectGetMaxY(self.expandRect) + 10, 250, 150); [self.imageRects addObject:@(imageRect)]; } else if (_timeLineModel.contentImages.count == 2 || _timeLineModel.contentImages.count == 3) { for (int i = 0; i < _timeLineModel.contentImages.count; i++) { CGRect imageRect = CGRectMake(11 + i * (10 + 90), CGRectGetMaxY(self.expandRect) + 10, 90, 90); [self.imageRects addObject:@(imageRect)]; } } else if (_timeLineModel.contentImages.count == 4) { for (int i = 0; i < 2; i++) { for (int j = 0; j < 2; j++) { CGRect imageRect = CGRectMake(11 + j * (10 + 90), CGRectGetMaxY(self.expandRect) + 10 + i * (10 + 90), 90, 90); [self.imageRects addObject:@(imageRect)]; } } } else if (_timeLineModel.contentImages.count == 5 || _timeLineModel.contentImages.count == 6 || _timeLineModel.contentImages.count == 7 || _timeLineModel.contentImages.count == 8 || _timeLineModel.contentImages.count == 9) { for (int i = 0; i < _timeLineModel.contentImages.count; i++) { CGRect imageRect = CGRectMake(11 + (i % 3) * (10 + 90), CGRectGetMaxY(self.expandRect) + 10 + (i / 3) * (10 + 90), 90, 90); [self.imageRects addObject:@(imageRect)]; } } } if (self.imageRects.count > 0) { CGRect lastRect = [self.imageRects[self.imageRects.count - 1] CGRectValue]; self.seperatorViewRect = CGRectMake(0, CGRectGetMaxY(lastRect) + 10, sWidth, 15); }else{ self.seperatorViewRect = CGRectMake(0, CGRectGetMaxY(self.expandRect) + 10, timeWidth, 15); } 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
通过
layout
的声明即可知道,layout
主要完成cell
中各个控件的frame
初始化,即在子线程中提前完成cell
排版、分割线、高度的加载。在调用cellForRowAtIndexPath
中获取cell
时,直接将layout
作为参数传入到cell
中,完成各个控件frame
的初始化工作。 -
预解码
预解码主要是针对
图片和音视频
,比如图片,首先需要明确的是UIImage
并不是控件,而是一个数据模型,控件是UIImageView
。在获取图片数据时返回的是二进制流,通过解码形成图片模型,也就是
UIImage
,最终渲染到UIImageView
上。这个过程中解码
是一个耗时又消耗资源的过程,所以通过在异步获取图片资源时,提前做好解码工作。例如我们常用的一些三方组件,如
SDWebImage
对图片的编解码,FFmpeg
对音视频的编解码,在性能优化方面做了很多工作。 -
按需加载
这种方式就比较好理解了,根据需要去加载相关数据。如
UITableView
在快速滚动过程中,会不停调用cellForRowAtIndexPath
方法。而有些位置上的cell
可能一闪而过,视觉上并不需要加载对应的数据,所以可以通过监听UITableView
的滚动状态,当UITableView
停止滚动时,初始化需要加载的items
,按需加载数据。