当前位置: 首页 > 科技观察

iOS-界面优化的底层原理

时间:2023-03-13 12:44:29 科技观察

界面优化无非就是解决卡顿问题,优化界面的流畅度。下面将通过先分析卡顿的原因,再介绍具体的优化方案,来分析如何优化界面。界面渲染过程的具体过程可以参考《图像渲染初探》[1],这里简单说一下图像渲染的过程,大致分为三个阶段:CPU处理阶段、GPU处理阶段和视频控制器显示阶段。大致流程图如下:苹果为了解决图像撕裂问题,采用了VSync+双缓冲的形式,即当显示器显示一帧渲染完成后,会发送一个垂直信号VSync到显示器。在接收到这个垂直信号后,显示器开始读取另一个帧缓冲区中的数据,并在应用程序接收到垂直信号后开始渲染新帧。CPU主要计算需要渲染的模型数据。GPU主要是根据CPU提供的渲染模型数据对图片进行渲染,然后保存在framebuffer中。渲染进程知道,一帧图片渲染完成后,会发送一个垂直信号,读取另一个帧缓冲区中的数据。这时候CPU和GPU的工作还没有完成,也就是另一个framebuffer还没有数据处于locked状态时,显示器还是显示上一帧的图像。在这种情况下,它会等待下一帧被绘制,然后视频控制器会读取另一个帧缓冲区中的数据,然后对其进行成像。中间的等待过程导致掉帧,也就是会卡顿。卡顿图如下:这种情况会引起卡顿和卡顿检测1.FPS监控苹果iPhone推荐的刷新率为60Hz,即每秒刷新60次画面,即每秒渲染60帧,每一帧的渲染时间差不多是1000/60=16.67毫秒。整个界面会比较流畅。一般刷新率低于45Hz就会出现明显的卡顿现象。这里可以使用YYFPSLabel实现FPS监控。这个原理主要是通过CADisplayLink来实现的。使用CADisplayLink监控每次屏幕刷新,获取屏幕刷新的时间,然后用次数(即1)除以每次刷新的时间。具体源码如下:#import"YYFPSLabel.h"#import"YYKit.h"#definekSizeCGSizeMake(55,20)@implementationYYFPSLabel{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=[superinitWithFrame:frame];self.layer。cornerRadius=5;self.clipsToBounds=YES;self.textAlignment=NSTextAlignmentCenter;self.userInteractionEnabled=NO;self.backgroundColor=[UIColorcolorWithWhite:0.000alpha:0.700];_font=[UIFontfontWithName:@"Menlo"size:14];if(_font){_subFont=[UIFontfontWithName:@"Menlo"size:4];}else{_font=[UIFontfontWithName:@"Courier"size:14];_subFont=[UIFontfontWithName:@"Courier"size:4];}//YYWeakProxy这里使用虚类解决强引用问题_link=[CADisplayLinkdisplayLinkWithTarget:[YYWeakProxyproxyWithTarget:self]selector:@selector(tick:)];[_linkaddToRunLoop:[NSRunLoopmainRunLoop]forMode:NSRunLoopCommonModes];returnsself;}-(void)dealloc{[_linkinvalidate];}-(CGSize)sizeThatFits:(CGSize)size{returnkSize;}-(void)tick:(CADisplayLink*)link{if(_lastTime==0){_lastTime=link.timestamp;NSLog(@"sdf");return;}//次数_count++;//时间NSTimeIntervaldelta=link.timestamp-_lastTime;if(delta<1)return;_lastTime=link.timestamp;floatfps=_count/delta;_count=0;CGFloatprogress=fps/60.0;UIColor*color=[UIColorcolorWithHue:0.27*(progress-0.2)saturation:1brightness:0.9alpha:1];NSMutableAttributedString*text=[[NSMutableAttributedStringalloc]initWithString:[NSStringstringWithFormat:@"%dFPS",(int)round(fps)]];[textsetColor:colorrange:NSMakeRange(0,text.length-3)];[textsetColor:[UIColorwhiteColor]range:NSMakeRange(text.length-3,3)];text.font=_font;[textsetFont:_subFontrange:NSMakeRange(text.length-4,1))];self.attributedText=text;}@endFPS在开发阶段只是作为辅助值,因为会频繁唤醒runloop。如果runloop在idle状态下被CADisplayLink唤醒,会消耗性能2.通过RunLoop检测卡死通过监控主线程Runloop循环的时间来判断是否卡死,这个需要配合GCD信号量的使用来实现。设置初始化信号量为0,然后开启一个子线程等待信号量的触发,即在子线程的方法中调用dispatch_semaphore_wait方法设置等待时间为1秒,然后发送一个主线程Runloop的Observer回调方法中的signal,就是调用dispatch_semaphore_signal方法。这个时候可以把时间设置为0,如果是等待时间Timeout就看此时Runloop的状态是kCFRunLoopBeforeSources还是kCFRunLoopAfterWaiting。如果是这两种状态持续两秒,就说明有卡顿。详细代码如下:(代码中也有相关注释)#import"LGBlockMonitor.h"@interfaceLGBlockMonitor(){CFRunLoopActivityactivity;}@property(nonatomic,strong)dispatch_semaphore_tsemaphore;@property(nonatomic,assign)NSUIntegertimeoutCount;@end@implementationLGBlockMonitor+(instancetype)sharedInstance{staticidinstance=nil;staticdispatch_once_tonceToken;dispatch_once(&onceToken,^{instance=[[selfalloc]init];});returninstance;}-(void)start{[selfregisterObserver];[selfstartMonitor];}staticvoidCallBack(CFRunLoopObserverRefobserver,CFRunLoopActivityactivity,void*info){LGBlockMonitor*monitor=(__bridgeLGBlockMonitor*)info;monitor->activity=activity;//发信dispatch_semaphore_tsemaphore=monitor->_semaphore;dispatch_semaphore_signal(semaphore);}-(void)registerObserver{CFRunLoopObserverContextcontext={0,(__bridgevoid*)self,NULL,NULL};,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,所以直接返回0longst=dispatch_semaphore_wait(self->_semaphore,dispatch_time(DISPATCH_TIME_NOW,1*NSEC_PER_SEC));0){if(self->activity==kCFRunLoopBeforeSources||self->activity==kCFRunLoopAfterWaiting){//如果一直在处理source0或者如果接受者接受了mach_port的状态,说明runloop的循环还没有完成if(++self->_timeoutCount<2){NSLog(@"timeoutCount==%lu",(unsignedlong)self->_timeoutCount);continue;}//如果超过两秒,说明卡住了。//一秒左右的量度很可能是连续的,避免大面积打印!NSLog(@"检测到超过两次连续冻结");}}self->_timeoutCount=0;}});}@end3.微信矩阵这个方案也是借助runloop实现的。大体流程和方案三一样,但是微信增加了栈分析,可以定位到耗时的方法调用栈,所以需要准确分析卡顿的原因可以借助微信矩阵进行分析。当然方案二也可以使用开源的第三方库PLCrashReporter来获取堆栈信息。在线程freeze的情况下,会出现断线、无响应的表现,然后检测freeze优化方案。上面卡顿的原因分析,我们知道主要是CPU和GPU耗时过长导致掉帧卡顿。因此,界面优化的主要工作是减轻CPU和GPU的负担。预排版和预排版主要是为了减轻CPU的负担。假设现在有另一个TableView,需要根据每个单元格的内容来确定单元格的高度。我们知道TableView是有复用机制的。如果复用池中有数据,即将滑入屏幕的cell会使用复用池中的cell来节省资源,但是cell的高度必须根据新数据的内容来计算。重新布局新cell中内容的布局,这样重复滑动TableView的同一个cell会重复计算它的frame,也给CPU带来了负担。如果在获取数据和创建模型时计算cellframe,而TableView在model中返回frame,即使同一个cell反复来回滑动TableView,计算frame的操作也只会执行一次,因此可以减轻负担。函数,如下图所示:组成一个cell需要modal找数据,layout也需要找cell是怎么布局的:pre-decoding&prerendering图像渲染过程,得到顶点数据后和纹理在CPU阶段,它会解码并产生位图,然后传递给GPU进行渲染。主要流程图如下。如果图片又多又大,解码工作会占用主线程RunLoop导致滑动等其他任务失败,从而造成卡顿现象,所以这里可以把解码工作放在异步线程不占用主线程。可能有人会认为只要在异步线程中加载图片,就会在异步线程中生成一个UIImage或者CGImage,然后在主线程中设置到UIImageView。这时候可以写一段代码,使用instruments的TimeProfiler查看栈信息,发现图片的codec还在主线程中。解决这个问题的一种常见方式是先在子线程中将图片绘制到CGBitmapContext中,然后直接从Bitmap中创建图片,比如SDWebImage三方框架中图片编解码的处理。这就是图像的预测码,代码如下:dispatch_async(queue,^{CGImageRefcgImage=[UIImageimageWithData:[NSDatadataWithContentsOfURL:[NSURLURLWithString:self]]].CGImage;CGImageAlphaInfoalphaInfo=CGImageGetAlphaInfo(cgImage)&kCGAlitmapAlphaInfoMaskpha(if;BOOLhas)=kCGImageAlphaPremultipliedLast||alphaInfo==kCGImageAlphaPremultipliedFirst||alphaInfo==kCGImageAlphaLast||alphaInfo==kCGImageAlphaFirst){hasAlpha=YES;}CGBitmapInfobitmapInfo=kCGBitmapByteOrder32Host;bitmapInfo|=hasAlpha?kCGImageAlphaPremultipliedFirst:kCGImageAlphaNoneSkipFirst;size_twidth=CGImageGetWidth(cgImage);size_theight=CGImageGetHeight(cgImage);CGContextRefcontext=CGBitmapContextCreate(NULL,width,height,8,0,CGColorSpaceCreateDeviceRGB(),bitmapInfo);CGContextDrawImage(上下文,CGRectMake(0,0,width,height),cgImage);cgImage=CGBitmapContextCreateImage(上下文);UIImage*image=[[UIImageimageWithCGImage:cgImage]cornerRadius:width*0.5];CGContextRelease(context);CGImageRelease(cgImage);完成(图像);});按需加载顾名思义,需要显示的加载,不需要显示的加载,比如TableView中的图片,滑动的时候不加载,滑动的时候加载滑动停止(可以使用Runloop,画图设置defaultModal。)异步渲染说异步渲染之前先说一下UIView和CALayer的关系:UIView基于UIKit框架,可以接受点击事件,处理用户触摸事件,并管理子视图。CALayer是基于CoreAnimation的,而CoreAnimation是基于QuartzCode的。CALayer只负责显示,不能处理用户触摸事件。UIView直接继承了UIResponder,CALayer继承了NSObject的UIVIew。主要职责是接收和响应事件;而CALayer的主要职责就是展示UI。UIView依赖于CALayer来显示。总结:UIView主要负责时间处理,CALayer主要负责视图显示。异步渲染的原理其实就是在子线程中将所有view绘制成位图,然后返回主线程将内容赋给layer。例如Graver框架的异步渲染流程如下:核心源码如下:if(drawingFinished&&targetDrawingCount==layer.drawingCount){CGImageRefCGImage=context?CGBitmapContextCreateImage(context):NULL;{//让UIImage执行内存管理//最终生成的位图UIImage*image=CGImage?[UIImageimageWithCGImage: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);elsefinishBlock();}//一些清理工作:releaseCGImageRef,Imagecontextending}最终效果图如下:显示(不要太大)使用较少的addView来动态ddviewstocells尽量避免使用透明视图,因为使用透明视图会导致GPU中像素的计算包含透明视图下层的像素,即混色处理(当有两层时,一个是半透明的,一个是不透明的,如果半透明的层数更高,此时会触发混色。底层混合不仅仅是将两层叠加,而是将两种颜色混合计算出一个新的颜色值并显示在屏幕上)