iOS底层-界面优化

4,109 阅读10分钟

在日常开发中经常会出现卡顿的现象(丢帧),给用户的感觉很不好。那么这个现象是怎样产生的,如何检测到掉帧,要怎样去优化呢?本文将针对这几个问题进行分析

界面渲染流程

在界面的渲染过程中CPUGPU起了比较重要的作用

CPU与GPU

  • CPU全名是Central Processing Unit(中央处理器),程序在加载资源、对象的创建和销毁、对象属性的调整布局计算Autolayout文本渲染,文本的计算和排版图片格式转码和解码、图像的绘制(Core Graphics)时,都需要依赖CPU来执行

  • GPU全名是Graphics Processing Unit(图像处理器),它是一个专门为图形高并发计算而量身定做的处理单元,比CPU使用更少的电来完成工作并且GPU的浮点计算能力要超出CPU很多。相对于CPU来说,GPU能干的事情比较单一:接收提交的纹理(Texture)和顶点描述(三角形),应用变换(transform)、混合(合成)并渲染,然后输出到屏幕上。通常你所能看到的内容,主要也就是纹理(图片)和形状(三角模拟的矢量图形)两类。GPU的渲染性能要比CPU高效很多,同时对系统的负载和消耗也更低一些

在开发中,我们应该尽量让CPU负责主线程的UI调动,把图形显示相关的工作交给GPU来处理,当涉及到光栅化等一些工作时,CPU也会参与进来。

渲染过程

  • 在讲渲染原理之前先介绍下CRT显示器原理。

    • CRT的电子枪从上到下逐行扫描,扫描完成后显示器就呈现一帧画面。然后电子枪回到初始位置进行下一次扫描。为了同步显示器的显示过程和系统的视频控制器,显示器会用硬件时钟产生一系列的定时信号。

    • 当电子枪换行进行扫描时,显示器会发出一个 水平同步信号(horizonal synchronization),简称HSync

    • 而当一帧画面绘制完成后,电子枪回复到原位,准备画下一帧前,显示器会发出一个 垂直同步信号(vertical synchronization),简称VSync。显示器通常以固定频率进行刷新,这个刷新率就是VSync信号产生的频率。

    • CRT的电子枪扫描过程如下图所示: ios-screen-scan.png

    • 虽然现在的显示器基本都是液晶显示屏了,但其原理基本一致。

  • 界面渲染的流程如下:CPU计算 -> GPU渲染 -> 帧缓冲区 -> 视频控制器读取帧缓冲区的数据 -> 显示器,如下图:

    截屏2021-11-30 17.48.24.png

双缓冲机制+VSync

  • 如果GPU渲染后,因为某些原因没有存入帧缓冲区,这样就形成了等待,为了解决了这个问题,就产生了双缓冲机制,也就是前帧和后帧

    • GPU渲染完一帧后就会存入帧缓存区,然后视频控制器去读取缓冲帧缓存,同时GPU去渲染另一帧并存入另一个帧缓存区,这样来回的切换读取来显示界面,如下图:

    截屏2021-11-30 19.08.34.png

    • 这个切换也不是任意时间切的,我们常见的都是一秒渲染60帧,也就是VSync每隔16.67ms发送一次信号
    • 所以,视频控制器会按照VSync信号逐帧读取帧缓冲区的数据

卡顿

卡顿产生原理

  • 我们知道VSync每隔16.67ms发送一次信号,两次发送信号之间cpu进行了计算,gpu渲染后存入帧缓冲区,那么如果计算的步骤花的时间比较长,那么存入帧缓存的渲染部分就比较少了,当视频控制器读取帧缓存时没有读全,此时就产生了丢帧,也就是卡顿

  • 卡顿过程如下图:

    截屏2021-11-30 22.48.36.png

卡顿检测

  • 每秒渲染帧数用FPS(Frames Per Second)来表示,通常60fps为最佳,我们可以检测AppFPS来观察App是否流畅。

可以使用以下几种方式检测AppFPS:

CADisplayLink

  • 系统在每次发送VSync时,就会触发CADisplayLink,我们可以统计每秒发送VSync的数量来查看AppFPS是否稳定,主要代码如下:

    @interface ViewController ()
    @property (nonatomic, strong) CADisplayLink *link;
    @property (nonatomic, assign) NSTimeInterval lastTime; // 每隔1秒记录一次时间
    @property (nonatomic, assign) NSUInteger count; // 记录VSync1秒内发送的数量
    @end
    
    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);
    }
    

RunLoop

  • Runloop原理 中,我们详细的分析了Runloop,它的退出和进入实质都是Observer的通知,我们可以监听Runloop的状态,并在相关回调里发送信号,如果在设定的时间内能够收到信号说明是流畅的;如果在设定的时间内没有收到信号,说明发生了卡顿。具体实现如下:

    @interface WSBlockMonitor () {
      CFRunLoopActivity activity;
    }
    
    @property (nonatomic, strong) dispatch_semaphore_t semaphore;
    @property (nonatomic, assign) NSUInteger timeoutCount;
    @end
    
    - (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)
    {
        WSBlockMonitor *monitor = (__bridge WSBlockMonitor *)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) // 即将处理sources,即将进入休眠
                    {
                        if (++self->_timeoutCount < 2) {
                            NSLog(@"timeoutCount==%lu",(unsigned long)self->_timeoutCount);
                            continue;
                        }
                        // 一秒左右的衡量尺度 很大可能性连续来 避免大规模打印!
                        // 可在此处记录卡顿堆栈信息,进行排查
                        NSLog(@"检测到超过两次连续卡顿");
                    }
                }
                self->_timeoutCount = 0;
            }
        });
    }
    
    // 调用方法
    - (void)start{
        [self registerObserver];
        [self startMonitor];
    }
    
    • 主要在主线程监听Runloop即将处理事物和即将休眠两种状态,子线程监控时长,如果连续两次1秒内没有收到信号,说明发生了卡顿,此时可以记录卡顿的堆栈以便于排查。

微信matrix

  • 微信的matrix也是借助runloop实现的,大体流程上面Runloop相同,它使用退火算法优化捕获卡顿的效率,防止连续捕获相同的卡顿,并且通过保存最近的20个主线程堆栈信息,获取最近最耗时堆栈。所以需要准确的分析卡顿原因可以借助微信matrix来分析卡顿。

滴滴DoraemonKit

  • DoraemonKit的卡顿检测方案并没有对runloop操作,它也是while循环中根据一定的状态判断,通过主线程中不断对发送信号semaphore,循环中等待信号的时间为5秒,等待超时则说明主线程卡顿,并进行相关上报。

优化方案

在检测到卡顿后,接下来就应该去进行相关的优化,我们可以通过以下几种方案

预排版

  • 在开发中,布局我们通常选择用MasonrySnapKit,他们都是基于AutoLayout来实现的,自动布局通常在赋值过后才决定UI控件的大小。而根据苹果的介绍,相对于AutoLayoutframe产生的消耗要小的多

  • 例如在复杂结构的UITableViewCell中,赋值过后会产生UI控件的大小,如果cell比较多会进行频繁的计算,这样就会消耗性能。这种情况我们可以在请求完数据时,就计算好各个控件的Rect,然后在数据赋值时,也将各个控件的frame进行赋值,这样会大大减少计算。这个方案也叫做预排版,具体代码如下:

  • 数据DataModel代码

    @interface DataModel : NSObject
    @property (nonatomic, strong) NSString *name;
    @end
    
  • 布局LayoutModel代码

    // .h
    @interface LayoutModel : NSObject
    @property (nonatomic, assign) CGRect nameRect;
    @property (nonatomic, strong) DataModel *data;
    @property (nonatomic, assign) CGFloat height;
    
    - (instancetype)initWithModel:(DataModel *)model; // 初始化代码
    @end
    
    // .m
    - (instancetype)initWithModel:(DataModel *)model {
      self = [super init];
      if (self) {
          self.data = model;
          // 根据数据计算相关控件的size
          CGSize size = [self getSizeWithContent:model.name];
          self.nameRect = CGRectMake(15, 100, size.width, size.height);
          self.height = 200; // 计算cell高度
      }
      return self;
    }
    
    - (CGSize)getSizeWithContent: (NSString *)content {
        //根据文字计算size ...
        return CGSizeMake(100, 40);
    }
    
    • layoutModel初始化时,要传入对应的model,然后根据model中的相关字段计算相应的size,然后讲相应的rect赋值给layoutModel中的相关字段。
  • cell代码

    // .h
    - (void)configCellWithModel: (LayoutModel *)model;
    
    // .m
    @interface TestCell ()
    @property (nonatomic, strong) UILabel *nameLbl;
    @end
    
    @implementation TestCell
    - (instancetype)initWithStyle:(UITableViewCellStyle)style reuseIdentifier:(NSString *)reuseIdentifier {
        self = [super initWithStyle:style reuseIdentifier:reuseIdentifier];
        if (self) {
            self.nameLbl = [UILabel new];
            [self.contentView addSubview:_nameLbl];
        }
        return self;
    }
    
    - (void)configCellWithModel:(LayoutModel *)model {
        self.nameLbl.frame = model.nameRect; // frame 赋值
        self.nameLbl.text = model.data.name; // 数据赋值
    }
    
    • cell中的控件创建完后设置相关的颜色字体,然后在数据赋值时同时对frame进行赋值
  • VC代码

    @property (nonatomic, strong) NSMutableArray<LayoutModel *> *dataSource;
    
    // 模拟网络请求
    dispatch_async(dispatch_get_global_queue(0, 0), ^{
        NSDictionary *dataDic;  // 网络请求数据
        NSMutableArray<DataModel *> *modelArray = [NSMutableArray array];
        for (NSDictionary *dic in dataDic[@"data"]) {
            DataModel *model = [DataModel yyModel: dic]; // 相关的json转model方法
            [modelArray addObject:model];
        }
      
        self.dataSource = [NSMutableArray arrayWithCapacity:modelArray.count];
        for (DataModel *model in modelArray) {
            LayoutModel *layout = [[LayoutModel alloc] initWithModel:model]; // 根据数据model,初始化layoutModel,并进行控件的layout计算
            [self.dataSource addObject:layout];
        }
      
        // 计算完成后reloadData
        dispatch_async(dispatch_get_main_queue(), ^{
            [self.tableView reloadData];
        });
    });
    
    - (NSInteger)tableView:(UITableView *)tableView numberOfRowsInSection:(NSInteger)section {
        return _dataSource.count;
    }
    
    - (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath {
        NSString *identifier = @"cellID";
        TestCell *cell = [tableView dequeueReusableCellWithIdentifier:identifier];
        if (!cell) {
            cell = [[TestCell alloc] initWithStyle:(UITableViewCellStyleDefault) reuseIdentifier:identifier];
        }
      
        [cell configCellWithModel:self.dataSource[indexPath.row]];
        return cell;
    }
    
    - (CGFloat)tableView:(UITableView *)tableView heightForRowAtIndexPath:(NSIndexPath *)indexPath {
        LayoutModel *model = self.dataSource[indexPath.row];
        return model.height;
    }
    
    • vc中主要是网络请求后创建数据model后,然后根据将dataModel创建layoutModel并进行相关计算,完成后再reloadData,这样就一次性将相关的布局计算好,滑动cell时只是进行赋值,无需额外的布局耗时计算

预解码&预渲染

  • 预解码主要是对图像视频类进行优化,例如UIImage,它的加载流程如下:

    截屏2021-12-01 19.15.35.png

    • Data Buffer(数据缓冲区)解码后缓存到Image Buffer(影响缓冲区),然后存入帧缓冲区再进行渲染。
    • 解码的过程是比较消耗资源的,所以可以将解码工作放到子线程,提前做好一些解码工作
    • 图片解码具体的做法可以参考SDWebImage中的处理,而音视频的解码可以参考FFmpeg

按需加载

  • 按需加载顾名思义就是需要时再加载,例如TableView,在滑动时每出现一个cell就会走cellForRow里的赋值方法,有些cell刚出现后又马上在界面消失,像这种可以监听滑动的状态,当滑动停止时根据tableViewvisibleCells获取当前可见cell,然后对这些cell进行赋值,这样也节省了很多的开销。

异步渲染

  • 异步渲染就是在子线程把需要绘制的图形提前处理好,然后将处理好的图像数据直接返给主线程使用,这样可以降低主线程的压力。

  • 异步渲染操作的是layer层,将展示的内容通过UIGraphics画成一张image然后展示在layer.content

  • 我们知道绘制会执行drawRect:方法,在方法中查看堆栈得知:

    截屏2021-12-02 10.26.48.png

    • 在堆栈中得知CALayer在调用display方法后回去调用绘制相关的方法,根据流程我们来实现一个简单的绘制:
    // WSLyer.m
    - (void)display {
        // 创建context
        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)]; // 结束绘制
    }
    
    
    // WSView.m
    - (void)drawRect:(CGRect)rect {
        // Drawing code
    }
    + (Class)layerClass {
        return [WsLayer class];
    }
    // 创建context
    - (CGContextRef)createContext {
        UIGraphicsBeginImageContextWithOptions(self.bounds.size, self.layer.opaque, self.layer.contentsScale);
        CGContextRef context = UIGraphicsGetCurrentContext();
        return context;
    }
    
    - (void)layerWillDraw:(CALayer *)layer {
        // 绘制的准备工作
    }
    
    - (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, 60);
        CGContextAddLineToPoint(ctx, self.bounds.size.width / 2 - 40, 60);
        CGContextAddLineToPoint(ctx, self.bounds.size.width / 2 - 20, 20);
        CGContextSetFillColorWithColor(ctx, UIColor.magentaColor.CGColor);
        CGContextSetStrokeColorWithColor(ctx, UIColor.magentaColor.CGColor); // 描边
        CGContextDrawPath(ctx, kCGPathFillStroke);
    
        // 文字
        [@"无双" drawInRect:CGRectMake(self.bounds.size.width / 2 - 40, 70, 80, 24) withAttributes:@{NSFontAttributeName: [UIFont systemFontOfSize:15],NSForegroundColorAttributeName: UIColor.blackColor}];
        // 图片
        [[UIImage imageNamed:@"buou"] drawInRect:CGRectMake(self.bounds.size.width / 2 - 40, 100, 60, 50)];
    }
    
    // 主线程渲染
    - (void)displayLayer:(CALayer *)layer {
        UIImage *image = UIGraphicsGetImageFromCurrentImageContext();
    
        dispatch_async(dispatch_get_main_queue(), ^{
            layer.contents = (__bridge id)(image.CGImage);
        });
    }
    
    // 结束context
    - (void)closeContext {
        UIGraphicsEndImageContext();
    }
    
    • VC中只需要添加view对象即可
    // ViewController.m
    WsView *view = [[WsView alloc] initWithFrame:CGRectMake(100, 100, 200, 200)];
    view.backgroundColor = UIColor.yellowColor;
    [self.view addSubview:view];
    

    运行结果和层级关系如下:

    截屏2021-12-02 14.38.39.png

  • 异步渲染处理起来相对要复杂些,具体的实践可以参照美团的 Graver,或者 YYAsyncLayer 这两个异步渲染框架。