iOS界面优化

1,496 阅读9分钟

一、卡顿的原理

22706962-a38363587d2a382f.png

从过去的 CRT 显示器原理说起。CRT 的电子枪按照上面方式,从上到下一行行扫描,扫描完成后显示器就呈现一帧画面,随后电子枪回到初始位置继续下一次扫描。为了把显示器的显示过程和系统的视频控制器进行同步,显示器(或者其他硬件)会用硬件时钟产生一系列的定时信号。当电子枪换到新的一行,准备进行扫描时,显示器会发出一个水平同步信号(horizonal synchronization),简称 HSync而当一帧画面绘制完成后,电子枪回复到原位,准备画下一帧前,显示器会发出一个垂直同步信号(vertical synchronization),简称 VSync。显示器通常以固定频率进行刷新,这个刷新率就是VSync信号产生的频率。尽管现在的设备大都是液晶显示屏,但原理仍然没有变。 22706962-48eb94b272f7a6b2.png

通常来说,计算机系统中 CPU、GPU、显示器是以上面这种方式协同工作的。CPU 计算好显示内容提交到 GPU,GPU 渲染完成后将渲染结果放入帧缓冲区,随后视频控制器会按照 VSync 信号逐行读取帧缓冲区的数据,经过可能的数模转换传递给显示器显示22706962-4b6c6628cad9f80e.png

在 VSync 信号到来后,系统图形服务会通过CADisplayLink(用于同步屏幕刷新频率的计时器)等机制通知 App,App 主线程开始在 CPU 中计算显示内容,比如视图的创建、布局计算、图片解码、文本绘制等。随后 CPU 会将计算好的内容提交到 GPU 去,由 GPU 进行变换、合成、渲染。随后 GPU 会把渲染结果提交到帧缓冲区去,等待下一次 VSync 信号到来时显示到屏幕上。由于垂直同步的机制,如果在一个 VSync 时间内,CPU 或者 GPU 没有完成内容提交,则那一帧就会被丢弃,等待下一次机会再显示,而这时显示屏会保留之前的内容不变。这就是界面卡顿的原因。

垂直同步时间16.67ms,60帧就是1s。最新的iPhone13 pro的刷新率是120Hz,所以同步时间是8.33ms

CPU 和 GPU 不论哪个阻碍了显示流程,都会造成掉帧现象。所以开发时,也需要分别对 CPU 和 GPU 压力进行评估和优化。

二、界面卡顿的检测

2.1、FPS监控

主要通过CADIsplayLink实现。参照YYKit中的YYFPSLabel,借助link的时间差,来计算一次刷新刷新所需的时间,然后通过 刷新次数 / 时间差 得到刷新频次,并判断是否其范围,通过显示不同的文字颜色来表示卡顿严重程度。

什么是CADisplayLink

CADisplayLink是CoreAnimation提供的另一个类似于NSTimer的类,它总是在屏幕完成一次更新之前启动,它的接口设计的和NSTimer很类似,所以它实际上就是一个内置实现的替代,但是和timeInterval以秒为单位不同,CADisplayLink有一个整型的frameInterval属性,指定了间隔多少帧之后才执行。默认值是1,意味着每次屏幕更新之前都会执行一次。但是如果动画的代码执行起来超过了六十分之一秒,你可以指定frameInterval为2,就是说动画每隔一帧执行一次(一秒钟30帧)或者3,也就是一秒钟20次,等等。

YYFPSLabel实现源理

CADisplayLink可以以屏幕刷新的频率调用指定selector,而且iOS系统中正常的屏幕刷新率为60Hz(60次每秒),所以使用 CADisplayLink 的 timestamp 属性,配合 timer 的执行次数计算得出FPS数。 刷新频率 = 次数/时间

2.2、RunLoop检测
@interface LGBlockMonitor (){
    CFRunLoopActivity activity;
}

@property (nonatomic, strong) dispatch_semaphore_t semaphore;
@property (nonatomic, assign) NSUInteger timeoutCount;

@end

@implementation LGBlockMonitor

+ (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;
        }
    });
}

创建个runloop观察者,在回调中发送信号量,在子线程接受信号量,无线循环等待,间隔一秒接受信号量,检测到事务执行_timeoutCount++,超过指定时间打印。

2.3、第三方库存

卡顿检测第三方库 swift:ANREye

OC:微信matrix滴滴DoraemonKit

三、界面卡顿优化

3.1、预排版

预排版提前计算布局,如cell的高度,(提前计算,后面直接使用)

我们可以单独在一个预排版的子线程去做一些事情:

  • frame的计算
  • 控件层级的部署
  • 渲染所需数据的处理
  • Model模型的数据解析等

尽量提前计算好布局,在需要时一次性调整好对应属性,而不要多次、频繁的计算和调整这些属性。

3.2、Autolayout

Autolayout在大部分情况下也能很好的提升开发效率,但是Autolayout对于复杂视图来说常常会产生严重的性能问题。随着视图数量的增长,Autolayout 带来的 CPU 消耗会呈指数级上升。

所以复杂的页面最好使用纯代码来布局。

3.3、预解码 & 预渲染

2414707-39aada76d49bd96f.jpeg

图片要显示,就要加载一个UIImage,UIImage是一个模型,里面包含Data BufferimageBuffer,然后由Controller控制UIImage显示在UIImageView上面的。其中Data Buffer 进行解码然后缓存到imageBuffer里面,然后才可以由Frame Buffer进行渲染。

图片的解码

用 UIImage 或 CGImageSource 的那几个方法创建图片时,图片数据并不会立刻解码。图片设置到 UIImageView 或者 CALayer.contents 中去,并且 CALayer 被提交到 GPU 前,CGImage 中的数据才会得到解码。这一步是发生在主线程的,并且不可避免。如果想要绕开这个机制,常见的做法是在后台线程先把图片绘制到 CGBitmapContext 中,然后从 Bitmap 直接创建图片。目前常见的网络图片库都自带这个功能。

sdwebimage 关于解码的处理

// decode the image in coder queue
dispatch_async(self.coderQueue, ^{
    @autoreleasepool {
        UIImage *image = SDImageLoaderDecodeImageData(imageData, self.request.URL, [[self class] imageOptionsFromDownloaderOptions:self.options], self.context);
        CGSize imageSize = image.size;
        if (imageSize.width == 0 || imageSize.height == 0) {
            [self callCompletionBlocksWithError:[NSError errorWithDomain:SDWebImageErrorDomain code:SDWebImageErrorBadImageData userInfo:@{NSLocalizedDescriptionKey : @"Downloaded image has 0 pixels"}]];
        } else {
            [self callCompletionBlocksWithImage:image imageData:imageData error:nil finished:YES];
        }
        [self done];
    }
});

苹果官方文档中,建议使用下采样(Downsampleing)的技术,来加载图片,减少imageBuffer的大小

图像的绘制

图像的绘制通常是指用那些以CG开头的方法把图像绘制到画布中,然后从画布创建图片并显示这样一个过程。这个最常见的地方就是[UIView drawRect:]里面了。由于CoreGraphic方法通常都是线程安全的,所以图像的绘制就可以很容易的放到后台线程进行。一个简单的异步绘制的过程大致如下(实际情况会比这个复杂得多,但原理基本一致):

- (void)display {
    dispatch_async(backgroundQueue, ^{
        CGContextRef ctx = CGBitmapContextCreate(...);
        // draw in context...
        CGImageRef img = CGBitmapContextCreateImage(ctx);
        CFRelease(ctx);
        dispatch_async(mainQueue, ^{
            layer.contents = img;
        });
    });
}

在列表中加载图片

我们在开发中,一般会对图片进行子线程异步加载,在后台进行解码和下采样。在列表中,有时会加载很多图片,此时应该注意线程爆炸问题。

线程爆炸

当我们要求系统去做比CPU能够做的工作更多的工作时 就会发生这种情况,比如我们要显示8张图片,但我们只有两个CPU,就不能一次完成所有这些工作,无法在不存在的CPU上进行并行处理,为了避免向一个全局队列中异步的分配任务时发生死锁,GCD 将创建新线程来捕捉我们要求它所做的工作,然后CPU将花费大量时间,在这些线程之间进行切换 ,尝试在所有工作上取得我们要求操作系统为我们做的渐进式进展,在这些线程之间不停切换,实际上是相当大的销,现在不是简单地将工作分派到全局异步队列之一,而是创建一个串行队列,在预取的方法中,异步的将工作分派到该队列,它的确意味着单个图像的加载,可能要比以前晚才能开始取得进展,但CPU将花费更少的时间,在它可以做的小任务之间来回切换。在SDWebImage中,解码的队_coderQueue.maxConcurrentOperationCount = 1就是一个串行队列。这样就很好的解决了多图片异步解码时,线程爆炸问题。

3.4、按需加载

例如在TableView中滑动时不加载图片,使用默认占位图,而是在滑动停止时加载,只加载前几行

按需加载一般配合缓存来使用

3.5、异步渲染

参考**Graver**

从文本计算、样式排版渲染、图片解码,再到绘制,实现了全程异步化。

3.6、对象创建、调整、销毁
  • 尽量做到懒加载。不用的对象不进行创建
  • 减少对UIViewCALayer属性修改
  • 大量对象释放时,是非常耗时的,尽量挪到子线程去释放
3.7、开发中的一些优化tips
  1. 尽量将多张图合为一张进行显示
  2. 尽量减少视图数量和层次
  3. 减少使用离屏渲染,离屏渲染:CALayer的border、圆角、阴影、遮罩(mask),最彻底的解决办法,就是把需要显示的图形在后台线程绘制为图片,避免使用圆角、阴影、遮罩等属性
  4. 避免使用透明view
  5. 避免使用addViewcell动态添加view