iOS-界面优化
这篇文章参考自https://juejin.cn/post/7113799052122128392
一、卡顿原理
1.1 界面显示原理
CPU:layout UI布局,文本计算,display绘制,prepare图片解码,commit位图给GPU GPU:用于渲染,将结果放入FrameBuffer FrameBuffer:帧缓存 Video Controller:根据Vsync(垂直同步)信号,逐步读去FrameBuffer中的数据,经过数模转化传递给Monitor Monitor:显示器,用于显示,对于显示模块来说,会按照手机刷新率以固定的频率:1/刷新率向FrameBuffer索要数据,这个索要数据的命令就是垂直同步信号Vsync(低帧60帧为16.67ms;高刷蔚8.33ms)
1.2 界面撕裂
显示端每16.67ms从FrameBuffer(帧缓存区)读取一帧数据,如果遇到耗时操作交付不了,那么当前画面还是旧一帧的画面,但显示过程中,下一帧的数据准备完毕,导致部分显示的又是新数据,这样就会造成屏幕撕裂。
1.3 界面卡顿
为了解决界面撕裂的问题,苹果采用了双缓冲机制+垂直同步信号,使用2个FrameBuffer存储GPU处理结果,显示端交替从这2个FrameBuffer中读取数据,一个被读取时另一个去缓存,但解决界面撕裂的问题也带来了新的问题:掉帧
掉帧:如果buffer2未渲染完第二帧,下一个16.67ms去buffer1拿第三帧;
情况1:第三帧渲染完;接下来需要第四帧,第二帧被丢弃 情况2:第三帧未渲染完,再来一个16.67ms去另一个缓存拿第二帧,但第一帧已经多停留16.67*2ms的时间了
1.4 小结
固定的时间间隔会收到垂直同步信号(Vsync),如果CPU和GPU还没有将下一帧数据放到对应的帧buffer中华,就会出现掉帧问题。
二、卡顿检测
2.1 CADisplayLink
系统在每次发送Vsync时,就会触发CADisplayLink,通过统计每秒发送VSync的数量来查看App的FPS是否稳定。
#import "ViewController.h" @interface ViewController () @property (nonatomic, strong) CADisplayLink *link; @property (nonatomic, assign) NSTimeInterval lastTime; // 每隔1秒记录一次时间 @property (nonatomic, assign) NSUInteger count; // 记录VSync1秒内发送的数量 @end @implementation ViewController - (void)viewDidLoad { [super viewDidLoad]; self.link = [CADisplayLink displayLinkWithTarget:self selector:@selector(linkAction:)]; [_link addToRunLoop:[NSRunLoop currentRunLoop] forMode:NSRunLoopCommonModes]; } - (void)linkAction: (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(@" FPS : %f ", fps); } @end
2.2 RunLoop检测
Runloop的退出和进入实质都是Observer的通知,我们可以监听RunLoop的状态,并在相关回调里发送信号,如果在设定的时间内能够收到信号说明是流畅的,如果在设定的时间内没有收到信号,说明发生了卡顿。
- 主线程监听KCFRunLoopBeforeSources(即将处理事件);和KCFRunLoopAfterWaiting(即将休眠);子线程监控时长,若连续两次1s内没有收到信号,说明发生了卡顿。
#import "LZBlockMonitor.h" @interface LZBlockMonitor (){ CFRunLoopActivity activity; } @property (nonatomic, strong) dispatch_semaphore_t semaphore; @property (nonatomic, assign) NSUInteger timeoutCount; @end @implementation LZBlockMonitor + (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]; } - (void)registerObserver{ CFRunLoopObserverContext context = {0, (__bridge void*)self, NULL, NULL}; //NSIntegerMax : 优先级最小 CFRunLoopObserverRef observer = CFRunLoopObserverCreate(kCFAllocatorDefault, kCFRunLoopAllActivities, YES, NSIntegerMax, &CallBack, &context); CFRunLoopAddObserver(CFRunLoopGetMain(), observer, kCFRunLoopCommonModes); } static void CallBack(CFRunLoopObserverRef observer, CFRunLoopActivity activity, void *info) { LZBlockMonitor *monitor = (__bridge LZBlockMonitor *)info; monitor->activity = activity; // 发送信号 dispatch_semaphore_t semaphore = monitor->_semaphore; dispatch_semaphore_signal(semaphore); } - (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
2.3 微信matrix
微信的matrix也是借助runloop实现,大体流程与上面的runloop方式相同,它使用退火算法优化卡顿的效率,防止连续捕获相同的卡顿,并且通过保存最近20个主线程堆栈信息,获取最近最耗时堆栈。
2.4 滴滴Doraemonkit
滴滴的Doraemonkit的卡顿检测方案不使用Runloop,它也是while循环中根据一定的状态判断,通过主线程中不断发送信号semaphore,循环中等待信号的时间为5s,等待超时则说明主线程卡顿,并进行相关上报。
三、优化方法
平时比较简单的优化方案有:
1. 避免使用透明的UIView 2. 尽量使用PNG图片 3.避免离屏渲染(圆角使用贝塞尔曲线等)
3.1 预排版
- 就是常规的Model层请求数据后提前将cell高度算好
3.2 预编码/解码
- UIImage是一个Model,二进制流数据存储在DataBuffer中,经过decode解码,加载到imageBuffer中,最终进入FrameBuffer才能被渲染
- 当使用UIImage或者CGImageSource的方法创建图片时,图片的数据不会立即解码,而是在设置UIImageView.image时解码
- 如果任由系统处理,这一步无法避免,并且会发生在主线程中,如果想避免这个机制,在子线程中将图片绘制到CGBitmapContext,然后从Bitmap中创建图片。
3.3 按需加载
- 如果目标行和当前行相差超过指定行数,只加载目标滚动方位的前后3行
- (void)scrollViewWillEndDragging:(UIScrollView *)scrollView withVelocity:(CGPoint)velocity targetContentOffset:(inout CGPoint *)targetContentOffset{ NSIndexPath *ip = [self indexPathForRowAtPoint:CGPointMake(0, targetContentOffset->y)]; NSIndexPath *cip = [[self indexPathsForVisibleRows] firstObject]; NSInteger skipCount = 8; if (labs(cip.row-ip.row)>skipCount) { NSArray *temp = [self indexPathsForRowsInRect:CGRectMake(0, targetContentOffset->y, self.width, self.height)]; NSMutableArray *arr = [NSMutableArray arrayWithArray:temp]; if (velocity.y<0) { NSIndexPath *indexPath = [temp lastObject]; if (indexPath.row+3<datas.count) { [arr addObject:[NSIndexPath indexPathForRow:indexPath.row+1 inSection:0]]; [arr addObject:[NSIndexPath indexPathForRow:indexPath.row+2 inSection:0]]; [arr addObject:[NSIndexPath indexPathForRow:indexPath.row+3 inSection:0]]; } } else { NSIndexPath *indexPath = [temp firstObject]; if (indexPath.row>3) { [arr addObject:[NSIndexPath indexPathForRow:indexPath.row-3 inSection:0]]; [arr addObject:[NSIndexPath indexPathForRow:indexPath.row-2 inSection:0]]; [arr addObject:[NSIndexPath indexPathForRow:indexPath.row-1 inSection:0]]; } } [needLoadArr addObjectsFromArray:arr]; } }
- 在滑动结束时进行cell的渲染;这种方式会导致滑动时候有空白内容,因此要做好占位内容。
- (BOOL)scrollViewShouldScrollToTop:(UIScrollView *)scrollView{ scrollToToping = YES; return YES; } - (void)scrollViewDidEndScrollingAnimation:(UIScrollView *)scrollView{ scrollToToping = NO; [self loadContent]; } - (void)scrollViewDidScrollToTop:(UIScrollView *)scrollView{ scrollToToping = NO; [self loadContent]; } //用户触摸时第一时间加载内容 - (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event{ if (!scrollToToping) { [needLoadArr removeAllObjects]; [self loadContent]; } return [super hitTest:point withEvent:event]; } - (void)loadContent{ if (scrollToToping) { return; } if (self.indexPathsForVisibleRows.count<=0) { return; } if (self.visibleCells && self.visibleCells.count>0) { for (id temp in [self.visibleCells copy]) { VVeboTableViewCell *cell = (VVeboTableViewCell *)temp; [cell draw]; } } }
3.4 异步渲染
1.异步渲染是在子线程中把需要绘制的图形提前处理好,然后将处理好的图形数据直接返回主线程使用 2.异步渲染操作的是layer层,将多层堆叠的控件门通过UIGraphics画成一张位图,然后展示在layer.content上
3.4.1 CALayer
1.CALayer基于CoreAnimation进而基于QuartCode,只负责显示,且显示的是位图,不能处理用户的触摸事件 2.不需要和用户交互时,使用UIView和CALayer都可以,甚至CALayer更简洁高效
简单例子:
- 继承CALayer
import "LZLayer.h" @implementation LZLayer //前面断点调用写下的代码 - (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
- 继承UIView
// - (CGContextRef)createContext 和 - (void)closeContext要在.h中声明 #import "LZView.h" #import "LZLayer.h" @implementation LZView - (void)drawRect:(CGRect)rect { // Drawing code, 绘制的操作, BackingStore(额外的存储区域产于的) -- GPU } //子视图的布局 - (void)layoutSubviews{ [super layoutSubviews]; } + (Class)layerClass{ return [LZLayer 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]; // 画个不规则图形 CGContextMoveToPoint(ctx, self.bounds.size.width / 2- 20, 20); CGContextAddLineToPoint(ctx, self.bounds.size.width / 2 + 20, 20); CGContextAddLineToPoint(ctx, self.bounds.size.width / 2 + 40, 80); CGContextAddLineToPoint(ctx, self.bounds.size.width / 2 - 40, 100); CGContextAddLineToPoint(ctx, self.bounds.size.width / 2 - 20, 20); CGContextSetFillColorWithColor(ctx, UIColor.magentaColor.CGColor); CGContextSetStrokeColorWithColor(ctx, UIColor.magentaColor.CGColor); // 描边 CGContextDrawPath(ctx, kCGPathFillStroke); // 画个红色方块 [[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); // 文字 [@"LZ" drawInRect:CGRectMake(self.bounds.size.width / 2 - 40, 100, 80, 24) withAttributes:@{NSFontAttributeName: [UIFont systemFontOfSize:20],NSForegroundColorAttributeName: UIColor.blueColor}]; // 图片 [[UIImage imageWithContentsOfFile:@"/Volumes/Disk_D/test code/Test/Test/yasuo.png"] drawInRect:CGRectMake(10, self.bounds.size.height/2, self.bounds.size.width - 20, self.bounds.size.height/2 -10)]; } //layer.contents = (位图) - (void)displayLayer:(CALayer *)layer{ UIImage *image = UIGraphicsGetImageFromCurrentImageContext(); dispatch_async(dispatch_get_main_queue(), ^{ layer.contents = (__bridge id)(image.CGImage); }); } - (void)closeContext{ UIGraphicsEndImageContext(); }
- 控件们被绘制成了一张图;此外,虽然将控件画到一张位图上,但是还有问题,就是控件的交互事件,这部分可以深入了解的话需要看graver的源码