美文网首页
iOS-性能优化-卡顿优化

iOS-性能优化-卡顿优化

作者: Imkata | 来源:发表于2019-12-27 16:15 被阅读0次

    一. CPU和GPU的作用

    在屏幕成像的过程中,CPU和GPU起着至关重要的作用:

    • CPU(Central Processing Unit,中央处理器)
      CPU的工作:对象的创建和销毁、对象属性的调整、布局计算、文本的计算和排版、图片的格式转换和解码、图像的绘制(Core Graphics)

    • GPU(Graphics Processing Unit,图形处理器)
      GPU的工作:纹理的渲染

    具体流程:
    CPU计算文字大小、位置、颜色,图片解码等等,计算好之后将数据提交给GPU,GPU拿到这些数据进行渲染,渲染之后将数据放到帧缓存里面,然后视频控制器再从帧缓存读取数据,读取到数据之后直接显示到屏幕上。

    显示.png

    在iOS中是双缓冲机制,有前帧缓存、后帧缓存。就比如上图的帧缓存有两块区域,当一块区域满了或者一块区域正在忙其他事情,那么GPU可以先用另外一块缓存,这样效率比较高。

    二. 屏幕成像原理

    先看下图:

    屏幕成像原理.png
    1. 虽然手机屏幕上的动画是可以动的,其实它都是通过一帧一帧(或者说一页)数据组成的。
    2. 当屏幕想显示一帧数据的时候,就会发送一个垂直同步信号,一旦发送一个垂直同步信号就表明它要显示一帧数据了,接下来,首先它会发送一个水平同步信号,接着再发送下一行水平同步信号,再下一行,直到填充整个屏幕,这时候一帧数据就显示完成。
    3. 接下来再发送一个垂直同步信号,同理,也是一个一个发送水平同步信号,直到完成这一帧。
    4. 当所有帧数据发送完成之后,这些帧连起来就是屏幕上的动画了。

    三. 卡顿产生的原因

    卡顿一般就是某个列表拖拽起来不是很流畅,那么卡顿产生的原因是什么呢?

    卡顿产生的原因.png

    上面说了,每一帧的显示都需要CPU和GPU共同操作,如上图,红色的是CPU计算需要的时间,蓝色是GPU渲染需要的时间。

    1. 第一帧的数据要显示,接下来垂直同步信号来了,就将GPU渲染好放在帧缓存里面的数据显示到屏幕上,就完成了第一帧的显示。
      注意:一旦来一个垂直同步信号就会立马把GPU渲染好放在帧缓存里面的数据显示到屏幕上,并且马上开始下一帧的操作。
    2. 第二帧的数据CPU计算和GPU渲染的比较快,所以在下一次垂直同步信号来之前,CPU和GPU的工作早早就完成了,这样下一个垂直同步信号来了就会把帧缓存里面的数据拿出来显示到屏幕上,完成了第二帧的显示。
    3. 第三帧的数据CPU计算和GPU渲染的比较慢,在垂直同步信号来到之后还没渲染完,这时候帧缓存里面还是第二帧的数据,所以取出数据显示到屏幕上的还是第二帧的数据,就会产生掉帧现象,也就是我们说的卡顿。
    4. 第三帧辛辛苦苦计算的数据会在下一帧的垂直信号来到之后再显示到屏幕上,所以整整慢了一帧的时间。

    四. 卡顿解决的主要思路

    尽可能减少CPU、GPU资源消耗。

    一般FPS达到60FPS就会感觉不到卡顿,按照60FPS的刷帧率,每隔16ms就会有一次VSync信号(1000ms / 60 = 16.667ms)。

    五. 卡顿优化 CPU

    1. 尽量用轻量级的对象,比如用不到事件处理的地方,可以考虑使用CALayer取代UIView,能用int就不用NSNumber。
    2. 不要频繁地调用UIView的相关属性,比如frame、bounds、transform等属性,尽量减少不必要的修改,因为每次修改都要重新计算和渲染,消耗性能比较多。
    3. 尽量提前计算好布局,在有需要时一次性调整对应的属性,不要多次修改属性,因为多次修改也会重新计算和渲染。
    4. Autolayout会比直接设置frame消耗更多的CPU资源,因为Autolayout本身性能就不是很高。
    5. 图片的size最好刚好跟UIImageView的size保持一致,如果不一致CPU就会对图片进行伸缩操作,这样比较消耗CPU资源。
    6. 控制一下线程的最大并发数量,不要无限制的并发,这样会让CPU很忙。
    7. 尽量把耗时的操作放到子线程,这样可以充分利用CPU的多核,这样CPU的资源消耗分担的也比较合理。

    那么哪些操作比较耗时呢?

    ① 文本尺寸计算、绘制

    比如boundingRectWithSize计算文字宽高,或者drawWithRect文本绘制,都是可以放到子线程去处理的,如下:

    - (void)text
    {
        //下面操作都可以放到子线程
        // 文字计算
        [@"text" boundingRectWithSize:CGSizeMake(100, MAXFLOAT) options:NSStringDrawingUsesLineFragmentOrigin attributes:nil context:nil];
        
        // 文字绘制
        [@"text" drawWithRect:CGRectMake(0, 0, 100, 100) options:NSStringDrawingUsesLineFragmentOrigin attributes:nil context:nil];
    }
    

    ② 图片解码、绘制

    我们经常会写如下代码加载图片:

    - (void)image
    {
        UIImageView *imageView = [[UIImageView alloc] init];
        imageView.frame = CGRectMake(100, 100, 100, 56);
        imageView.image = [UIImage imageNamed:@"timg"]; //加载图片
        [self.view addSubview:imageView];
        self.imageView = imageView;
    }
    
    1. 其实通过imageNamed加载图片,加载完成后是不会直接显示到屏幕上面的,因为加载后的是经过压缩的图片二进制,当真正想要渲染到屏幕上的时候再拿到图片二进制解码成屏幕显示所需要的那种格式,然后渲染显示,而这种解码一般默认是在主线程操作的,如果图片数据比较多比较大的话也会产生卡顿。
    2. 一般我们的做法是在子线程提前解码图片二进制,主线程就不需要解码,这样在图片渲染显示之前就已经解码出来了,主线程拿到解码后的数据进行渲染显示就可以了,这样主线程就不会卡顿了。

    其实网上好多图片处理框架都有这个异步解码功能的,下面演示一下:

    - (void)image
    {
        UIImageView *imageView = [[UIImageView alloc] init];
        imageView.frame = CGRectMake(100, 100, 100, 56);
        //    imageView.image = [UIImage imageNamed:@"timg"];
        [self.view addSubview:imageView];
        self.imageView = imageView;
        
        //异步图片解码
        dispatch_async(dispatch_get_global_queue(0, 0), ^{
            // 获取CGImage
            CGImageRef cgImage = [UIImage imageNamed:@"timg"].CGImage;
            // 获取网络图片
            // CGImageRef cgImage = [UIImage imageWithContentsOfFile:@"www.baidu.com"].CGImage;        
    
            // alphaInfo
            CGImageAlphaInfo alphaInfo = CGImageGetAlphaInfo(cgImage) & kCGBitmapAlphaInfoMask;
            BOOL hasAlpha = NO;
            if (alphaInfo == kCGImageAlphaPremultipliedLast ||
                alphaInfo == kCGImageAlphaPremultipliedFirst ||
                alphaInfo == kCGImageAlphaLast ||
                alphaInfo == kCGImageAlphaFirst) {
                hasAlpha = YES;
            }
            
            // bitmapInfo
            CGBitmapInfo bitmapInfo = kCGBitmapByteOrder32Host;
            bitmapInfo |= hasAlpha ? kCGImageAlphaPremultipliedFirst : kCGImageAlphaNoneSkipFirst;
            
            // size
            size_t width = CGImageGetWidth(cgImage);
            size_t height = CGImageGetHeight(cgImage);
            
            // context
            CGContextRef context = CGBitmapContextCreate(NULL, width, height, 8, 0, CGColorSpaceCreateDeviceRGB(), bitmapInfo);
            
            // draw
            CGContextDrawImage(context, CGRectMake(0, 0, width, height), cgImage);
            
            // get CGImage
            cgImage = CGBitmapContextCreateImage(context);
            
            // into UIImage
            UIImage *newImage = [UIImage imageWithCGImage:cgImage];
            
            // release
            CGContextRelease(context);
            CGImageRelease(cgImage);
            
            // 回到主线程
            dispatch_async(dispatch_get_main_queue(), ^{
                self.imageView.image = newImage;
            });
        });
    }
    

    上面代码,不单单通过imageNamed加载的本地图片可以提前渲染,通过imageWithContentsOfFile加载的网络图片也可以这样进行提前渲染,只要获取到UIImage对象都可以对UIImage对象进行提前渲染。

    六. 卡顿优化 GPU

    1. 尽量避免短时间内大量图片的显示,尽可能将多张图片合成一张进行显示,这样只渲染一张图片,渲染更快。
    2. GPU能处理的最大纹理尺寸是4096x4096,一旦超过这个尺寸,就会占用CPU资源进行处理,所以纹理尽量不要超过这个尺寸。
    3. 尽量减少视图数量和层级,视图层级太多会增加渲染时间。
    4. 减少透明的视图(alpha<1),不透明的就设置opaque为YES,因为一旦有透明的视图就会进行很多混合计算增加渲染的资源消耗。
    5. 尽量避免出现离屏渲染

    那么什么是离屏渲染呢?

    在OpenGL中,GPU有2种渲染方式:

    1. On-Screen Rendering:当前屏幕渲染,在当前用于显示的屏幕缓冲区进行渲染操作
    2. Off-Screen Rendering:离屏渲染,在当前屏幕缓冲区以外新开辟一个缓冲区进行渲染操作

    当前用于显示的屏幕缓冲区就是下图的帧缓存。

    显示.png

    为什么离屏渲染消耗性能?

    1. 需要创建新的缓冲区
    2. 离屏渲染的整个过程,需要多次切换上下文环境,先是从当前屏幕(On-Screen)切换到离屏(Off-Screen),等到离屏渲染结束以后,想要将离屏缓冲区的渲染结果显示到当前屏幕上,就又需要将上下文环境从离屏切换到当前屏幕。

    哪些操作会触发离屏渲染?

    1. 光栅化:layer.shouldRasterize = YES
    2. 遮罩:layer.mask
    3. 圆角:同时设置layer.masksToBounds = YES、layer.cornerRadius大于0。
      可以考虑通过CoreGraphics绘制裁剪圆角,或者叫美工提供圆角图片。
    4. 阴影:layer.shadowXXX。
      如果设置了layer.shadowPath就不会产生离屏渲染。

    为什么要开辟新的缓冲区?
    因为上面进行的那些操作比较耗性能、资源,当前屏幕缓冲区不够用(就算是双缓冲机制也不够用),所以才会开辟新的缓冲区。

    七. 卡顿检测

    平时所说的“卡顿”主要是因为在主线程执行了比较耗时的操作,可以添加Observer到主线程RunLoop中,通过监听RunLoop状态切换的耗时,以达到监控卡顿的目的。

    上面两句话是什么意思呢?
    如下图,主线程的大部分操作,比如点击事件的处理,view的计算、 绘制等基本上都在source0和source1。我们只要监控一下从结束休眠(下图08)处理soure1(下图08-03)一直到绕回来处理source0(下图05), 如果发现中间消耗的时间比较长,那么就有可能可以证明这些操作比较耗时。

    RunLoop的运行逻辑.png

    1. LXDAppFluecyMonitor

    我们自己写比较复杂,文末的Demo有一个别人写的可以监控哪个方法卡顿的第三方库LXDAppFluecyMonitor,下面介绍一下LXDAppFluecyMonitor是如何使用的:

    - (void)viewDidLoad {
        [super viewDidLoad];
        //开启卡顿检测
        [[LXDAppFluecyMonitor sharedMonitor] startMonitoring]; 
        [self.tableView registerClass: [UITableViewCell class] forCellReuseIdentifier: @"cell"];
    }
    
    - (void)viewDidAppear: (BOOL)animated {
        [super viewDidAppear: animated];
    }
    
    #pragma mark - UITableViewDataSource
    - (NSInteger)tableView: (UITableView *)tableView numberOfRowsInSection: (NSInteger)section {
        return 1000;
    }
    
    - (UITableViewCell *)tableView: (UITableView *)tableView cellForRowAtIndexPath: (NSIndexPath *)indexPath {
        UITableViewCell * cell = [tableView dequeueReusableCellWithIdentifier: @"cell"];
        cell.textLabel.text = [NSString stringWithFormat: @"%lu", indexPath.row];
        if (indexPath.row > 0 && indexPath.row % 30 == 0) {
    //        usleep(2000000);
            sleep(2.0); //模拟卡顿
        }
        return cell;
    }
    

    上面代码,开启卡顿检测后,在cellForRowAtIndexPath方法里面休眠2s模拟卡顿,运行代码,打印如下:

    2019-12-27 15:40:18.132792+0800 LXDAppFluecyMonitor[77494:12090145] Backtrace of Thread 771:
    ======================================================================================
    libsystem_kernel.dylib         0x109fe6f32 __semwait_signal + 10
    libsystem_c.dylib              0x109dcac92 sleep + 41
    LXDAppFluecyMonitor            0x1070a3911 -[ViewController tableView:cellForRowAtIndexPath:] + 353
    UIKitCore                      0x10b392f60 -[UITableView _createPreparedCellForGlobalRow:withIndexPath:willDisplay:] + 764
    UIKitCore                      0x10b393499 -[UITableView _createPreparedCellForGlobalRow:willDisplay:] + 73
    UIKitCore                      0x10b35b654 -[UITableView _updateVisibleCellsNow:isRecursive:] + 2870
    UIKitCore                      0x10b37b76b -[UITableView layoutSubviews] + 165
    UIKitCore                      0x10b635e69 -[UIView(CALayerDelegate) layoutSublayersOfLayer:] + 1417
    QuartzCore                     0x10cbb8d22 -[CALayer layoutSublayers] + 173
    QuartzCore                     0x10cbbd9fc _ZN2CA5Layer16layout_if_neededEPNS_11TransactionE + 396
    QuartzCore                     0x10cbc9d58 _ZN2CA5Layer28layout_and_display_if_neededEPNS_11TransactionE + 72
    QuartzCore                     0x10cb3924a _ZN2CA7Context18commit_transactionEPNS_11TransactionE + 328
    QuartzCore                     0x10cb70606 _ZN2CA11Transaction6commitEv + 610
    QuartzCore                     0x10caa58a7 _ZN2CA7Display11DisplayLink14dispatch_itemsEyyy + 951
    QuartzCore                     0x10cb745a9 _ZL22display_timer_callbackP12__CFMachPortPvlS1_ + 297
    CoreFoundation                 0x10831b266 __CFMachPortPerform + 150
    CoreFoundation                 0x1083475e9 __CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE1_PERFORM_FUNCTION__ + 41
    CoreFoundation                 0x108346c4b __CFRunLoopDoSource1 + 459
    CoreFoundation                 0x1083411da __CFRunLoopRun + 2490
    CoreFoundation                 0x1083404d2 CFRunLoopRunSpecific + 626
    GraphicsServices               0x1109842fe GSEventRunModal + 65
    UIKitCore                      0x10b156fc2 UIApplicationMain + 140
    LXDAppFluecyMonitor            0x1070a4e50 main + 112
    libdyld.dylib                  0x109cc5541 start + 1
    ======================================================================================
    

    上面打印从下往上就是方法调用栈,其中有一行打印:

    LXDAppFluecyMonitor            0x1070a3911 -[ViewController tableView:cellForRowAtIndexPath:] + 353
    

    可以发现,的确可以检测到cellForRowAtIndexPath卡顿了。

    2. LXDAppFluecyMonitor框架的核心代码

    LXDAppFluecyMonitor框架里面就两个文件,LXDBacktraceLogger文件里面是关于方法调用栈的一些代码,LXDAppFluecyMonitor文件就是卡顿检测文件,进入LXDAppFluecyMonitor文件的startMonitoring方法:

    - (void)startMonitoring {
        if (_isMonitoring) { return; }
        _isMonitoring = YES;
        CFRunLoopObserverContext context = {
            0,
            (__bridge void *)self,
            NULL,
            NULL
        };
        //创建observer,监听所有状态
        _observer = CFRunLoopObserverCreate(kCFAllocatorDefault, kCFRunLoopAllActivities, YES, 0, &lxdRunLoopObserverCallback, &context);
        //添加observer
        CFRunLoopAddObserver(CFRunLoopGetMain(), _observer, kCFRunLoopCommonModes);
        
        dispatch_async(lxd_event_monitor_queue(), ^{
            while (SHAREDMONITOR.isMonitoring) { //一直监听
                if (SHAREDMONITOR.currentActivity == kCFRunLoopBeforeWaiting) {
                    __block BOOL timeOut = YES;
                    dispatch_async(dispatch_get_main_queue(), ^{
                        timeOut = NO;
                        dispatch_semaphore_signal(SHAREDMONITOR.eventSemphore);
                    });
                    [NSThread sleepForTimeInterval: lxd_time_out_interval];
                    if (timeOut) {
                        [LXDBacktraceLogger lxd_logMain];
                    }
                    dispatch_wait(SHAREDMONITOR.eventSemphore, DISPATCH_TIME_FOREVER);
                }
            }
        });
        
        dispatch_async(lxd_fluecy_monitor_queue(), ^{
            while (SHAREDMONITOR.isMonitoring) {
                long waitTime = dispatch_semaphore_wait(self.semphore, dispatch_time(DISPATCH_TIME_NOW, lxd_wait_interval));
                if (waitTime != LXD_SEMPHORE_SUCCESS) {
                    if (!SHAREDMONITOR.observer) {
                        SHAREDMONITOR.timeOut = 0;
                        [SHAREDMONITOR stopMonitoring];
                        continue;
                    }
                    if (SHAREDMONITOR.currentActivity == kCFRunLoopBeforeSources || SHAREDMONITOR.currentActivity == kCFRunLoopAfterWaiting) {
                        //连续卡顿次数小于5,继续
                        if (++SHAREDMONITOR.timeOut < 5) { 
                            continue;
                        }
                        //连续卡顿次数大于等于5,打印主线程的方法调用栈
                        [LXDBacktraceLogger lxd_logMain];
                        [NSThread sleepForTimeInterval: lxd_restore_interval];
                    }
                }
                SHAREDMONITOR.timeOut = 0;
            }
        });
    }
    

    参考上面注释简单看一下。

    面试题

    1. 优化你是从哪几方面着手?
      卡顿优化、耗电优化、启动优化、APP瘦身
    2. 造成tableView卡顿的原因大致有哪些?你平时是怎么优化的?
      一般列表卡顿就是一些耗时的操作放在主线程了,具体可参考上面针对CPU、GPU卡顿优化的方式。

    Demo地址:卡顿优化

    相关文章

      网友评论

          本文标题:iOS-性能优化-卡顿优化

          本文链接:https://www.haomeiwen.com/subject/gjsloctx.html