iOS底层学习——界面优化

503 阅读8分钟

1.界面显示原理

  1. CPU输出位图

    CPU工作包括:Layout UI布局、文本计算、Display绘制、Prepare图片解码、Commit提交位图

  2. GPU图层渲染,纹理合成

    GPU渲染管线(OpenGL):顶点着色、图元装配、光栅化、片段着色、片段处理。

  3. 把结果放到帧缓冲区(frame buffer)中

  4. 再由视频控制器(Video Controller)根据vsync信号,在指定时间之前,去提取帧缓冲区的屏幕显示内容

  5. 显示到屏幕(Monitor)上

界面显示流程见下图:

image.png

2.界面卡顿问题

因为CPU的计算需要耗时,为了解决这个性能问题,引入了双缓冲的机制,前帧和后帧。

iOS设备的硬件时钟会发出垂直同步信号(Vsync),然后CPU会去计算屏幕要显示的内容,之后将计算好的内容提交到GPU去渲染。随后,GPU将渲染结果提交到帧缓冲区,而此时垂直同步信号(VSync)会在前帧缓存和后帧缓存之间切换,将缓冲区的帧显示到屏幕上。也就是说,一帧的显示是由CPUGPU共同决定的。

一般来说,页面滑动流畅是60fps(也就是人眼看到的是流畅的效果),也就是1s60帧更新,即每隔16.7ms就要产生一帧画面,而如果CPUGPU加起来的处理时间超过了16.7ms,就会造成掉帧甚至卡顿。

image.png

总而言之,在规定的16.7ms内,CPUGPU并没有在下一帧的Vsync信号到来之前把当前的一帧画面生产完成,由此产生了掉帧卡顿。

3.卡顿检测

  • 使用CADisplayLink

    YYKit是一组庞大、功能丰富的iOS第三方组件,它提供了一个界面卡顿检测的功能。实现逻辑很简单,运用了CADisplayLinkCADisplayLink是绑定到垂直同步信号(Vsync)的计时器类。实现逻辑参考下面代码:

        @interface ViewController ()
        {
            CFTimeInterval _lastTime;
            CADisplayLink *_link;
            NSUInteger _count;
        }
        @end
    
        @implementation ViewController
    
        - (void)viewDidLoad {
            [super viewDidLoad];
    
            _link = [CADisplayLink displayLinkWithTarget:self selector:@selector(dealDisPlayLink:)];
            [_link addToRunLoop:[NSRunLoop currentRunLoop] forMode:NSRunLoopCommonModes];
        }
    
        - (void)dealDisPlayLink:(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(@"%f", fps);
        }
        @end
    

    因为CADisplayLink是绑定到垂直同步信号(Vsync)的计时器类,这里绑定了一个方法sel,并将该link添加到当前的RunLoop中,它会触发每一个垂直同步信号,除非暂停,否则它会一直执行。

    在触发方法中,会记录上一次的时间,并判断两次时间间隔是否大于等于1秒,如果大于等于1秒,计算这段时间内,该方法被执行的次数fps。运行结果见下图:

    image.png

  • 使用RunLoop信号量

    通过向RunLoop中注册一个Observer来观察RunLoop的运行状态,当RunLoop生命周期状态发生改变时,会执行对应的回调函数,发送信号量,根据控制异步线程被唤醒的情况,来判断事务处理过程中是否存在卡顿显现。见下图实现代码:

    #import "BlockMonitor.h"
    
    @interface BlockMonitor (){
        CFRunLoopActivity activity;
    }
    @property (nonatomic, strong) dispatch_semaphore_t semaphore;
    @property (nonatomic, assign) NSUInteger timeoutCount;
    @end
    
    @implementation BlockMonitor
    
    + (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;
            }
        });
    }
    @end    
    
    • 这里封装了一个BlockMonitor工具类,提供start方法启动测试工具。
    • 首选通过registerObserver方法向RunLoop中注册了一个观察者,观察RunLoop的状态,并设置回调事件callBack。在回调函数中记录当前RunLoop活动状态activity,并发送一个信号量。
    • startMonitor方法中,创建一个信号源,并发数0,也就是说当调用dispatch_semaphore_wait时,如果没有被signal唤醒,都会按照一秒一次的超时运行,返回值为非0;如果被signal唤醒,返回值为0
    • 当有RunLoop没有事务处理时,因为可并发数为0,所以dispatch_semaphore_wait会因超时而被唤醒,并返回非0,因为没有事务Runloop处于休眠状态,activity也不是处理事务的状态,异步线程一秒钟一次,_timeoutCount一直是0
    • RunLoop被唤醒,会对调并执行callBack函数,从而触发singal发信号,dispatch_semaphore_wait会返回0;当开始处理事务时,如果事务复杂,RunLoop的状态一直没有改变(一直在处理事务,状态没有发生改变),signal没有发出信号,wait超时返回非0,同时因为此时activity满足条节,会调用++_timeoutCount,当连续两次超时_timeoutCount等于2,说明连续两次出现卡顿。
    • 之后_timeoutCount清零,检测下一次卡顿。

    这里运用了RunLoop和信号量,按照一秒作为时间单位,检测卡顿情况。

其他的例如微信matrix滴滴DoraemonThread等卡顿检测方式,基本上也是通过子线程、信号量和RunLoop等的使用,完成卡顿检测,这里不做详细说明。

4.界面优化

那么如何解决界面卡顿问题,实现界面优化呢?

  • 预排版

    通常在请求到网络数据后,获取对应的Mode,而在进行界面排版时比如UITableViewCell,在heightForRowAtIndexPath中计算cell的高度,会在cellForRowAtIndexPath中进行cell的初始化,并完成排版工作。这种处理方式很常规,其实在获取cell对应的Mode后,多数情况下我们是可以提前知道cell的高度和样式的,我们可以将这些内容提前计算好,做到预排版。

    下面通过一些核心代码来分析其处理流程:

    - (void)loadData{
    
       // 外面的异步线程:网络请求的线程
        dispatch_async(dispatch_get_global_queue(0, 0), ^{
    
                   // 加载`JSON 文件`-模拟网络加载
                  NSString *path = [[NSBundle mainBundle] pathForResource:@"timeLine" ofType:@"json"];
                  NSData *data = [[NSData alloc] initWithContentsOfFile:path];
                  NSDictionary *dicJson=[NSJSONSerialization JSONObjectWithData:data options:NSJSONReadingMutableContainers error:nil];
    
                  // modes
                  for (id json in dicJson[@"data"]) {
                      LGTimeLineModel *timeLineModel = [LGTimeLineModel yy_modelWithJSON:json];
                      [self.timeLineModels addObject:timeLineModel];
                  }
    
                  // 初始化layout
                  for (LGTimeLineModel *timeLineModel in self.timeLineModels) {
                       LGTimeLineCellLayout *cellLayout = [[LGTimeLineCellLayout alloc] initWithModel:timeLineModel];
                       [self.layouts addObject:cellLayout];
                  }
    
                   // 主线程刷新
                   dispatch_async(dispatch_get_main_queue(), ^{
                        [self.timeLineTableView reloadData];
                   });
           });
    }
    

    例如上面的代码模拟网络加载数据,在获取数据后形成数据列表Modes,并没有直接回到主线程调用刷新界面,而是继续在子线程完成layout的初始化工作。layout初始化做了什么呢?这里只贴出部分代码:

    @class LGTimeLineModel;
    @interface LGTimeLineCellLayout : NSObject
    - (instancetype)initWithModel:(LGTimeLineModel *)timeLineModel;
    @property (nonatomic, assign) CGRect iconRect;
    @property (nonatomic, assign) CGRect nameRect;
    @property (nonatomic, assign) CGRect contentRect;
    @property (nonatomic, assign) CGRect expandRect;
    @property (nonatomic, assign) BOOL expandHidden;
    @property (nonatomic, strong) NSMutableArray *imageRects;
    @property (nonatomic, assign) CGRect seperatorViewRect;
    @property (nonatomic, assign) CGFloat height;
    @property (nonatomic, strong) LGTimeLineModel *timeLineModel;
    @end
    
    
    static const CGFloat nameLeftSpaceToHeadIcon = 10;
    static const CGFloat titleFont = 15;
    static const CGFloat msgFont = 15;
    static const CGFloat msgExpandLimitHeight = 140;
    static const CGFloat timeAndLocationFont = 13;
    
    @interface LGTimeLineCellLayout ()
    @end
    
    @implementation LGTimeLineCellLayout
    
    - (instancetype)initWithModel:(LGTimeLineModel *)timeLineModel{
        if (!timeLineModel) return nil;
        self = [super init];
        if (self) {
            _timeLineModel = timeLineModel;
            [self layout];
        }
        return self;
    }
    
    - (void)setTimeLineModel:(LGTimeLineModel *)timeLineModel{
        _timeLineModel = timeLineModel;
        [self layout];
    }
    
    - (void)layout{
        CGFloat sWidth = [UIScreen mainScreen].bounds.size.width;
        self.iconRect = CGRectMake(10, 10, 45, 45);
        CGFloat nameWidth = [self calcWidthWithTitle:_timeLineModel.name font:titleFont];
        CGFloat nameHeight = [self calcLabelHeight:_timeLineModel.name fontSize:titleFont width:nameWidth];
        self.nameRect = CGRectMake(CGRectGetMaxX(self.iconRect) + nameLeftSpaceToHeadIcon, 17, nameWidth, nameHeight);
        CGFloat msgWidth = sWidth - 10 - 16;
        CGFloat msgHeight = 0;
    
        //文本信息高度计算
        NSMutableParagraphStyle * paragraphStyle = [[NSMutableParagraphStyle alloc] init];
        [paragraphStyle setLineSpacing:5];
        NSDictionary *attributes = @{NSFontAttributeName: [UIFont systemFontOfSize:msgFont],NSForegroundColorAttributeName: [UIColor colorWithRed:26/255.0 green:26/255.0 blue:26/255.0 alpha:1],NSParagraphStyleAttributeName: paragraphStyle,NSKernAttributeName:@0};
    
        NSMutableAttributedString *attrStr = [[NSMutableAttributedString alloc] initWithString:_timeLineModel.msgContent attributes:attributes];
        msgHeight = [self caculateAttributeLabelHeightWithString:attrStr width:msgWidth];
    
        if (attrStr.length > msgExpandLimitHeight) {
            if (_timeLineModel.isExpand) {
                self.contentRect = CGRectMake(10, CGRectGetMaxY(self.iconRect) + 10, msgWidth, msgHeight);
            } else {
                attrStr = [[NSMutableAttributedString alloc] initWithString:[_timeLineModel.msgContent substringToIndex:msgExpandLimitHeight] attributes:attributes];
                msgHeight = [self caculateAttributeLabelHeightWithString:attrStr width:msgWidth];
                self.contentRect = CGRectMake(10, CGRectGetMaxY(self.iconRect) + 10, msgWidth, msgHeight);
            }
        } else {
            self.contentRect = CGRectMake(10, CGRectGetMaxY(self.iconRect) + 10, msgWidth, msgHeight);
        }
        
        if (attrStr.length < msgExpandLimitHeight) {
            self.expandHidden = YES;
            self.expandRect = CGRectMake(10, CGRectGetMaxY(self.contentRect) - 20, 30, 20);
        } else {
            self.expandHidden = NO;
            self.expandRect = CGRectMake(10, CGRectGetMaxY(self.contentRect) + 10, 30, 20);
        }
    
        CGFloat timeWidth = [self calcWidthWithTitle:_timeLineModel.time font:timeAndLocationFont];
        CGFloat timeHeight = [self calcLabelHeight:_timeLineModel.time fontSize:timeAndLocationFont width:timeWidth];
        self.imageRects = [NSMutableArray array];
        if (_timeLineModel.contentImages.count == 0) {
    //        self.timeRect = CGRectMake(10, CGRectGetMaxY(self.expandRect) + 10, timeWidth, timeHeight);
        } else {
            if (_timeLineModel.contentImages.count == 1) {
                CGRect imageRect = CGRectMake(11, CGRectGetMaxY(self.expandRect) + 10, 250, 150);
                [self.imageRects addObject:@(imageRect)];
            } else if (_timeLineModel.contentImages.count == 2 || _timeLineModel.contentImages.count == 3) {
                for (int i = 0; i < _timeLineModel.contentImages.count; i++) {
                    CGRect imageRect = CGRectMake(11 + i * (10 + 90), CGRectGetMaxY(self.expandRect) + 10, 90, 90);
                    [self.imageRects addObject:@(imageRect)];
                }
            } else if (_timeLineModel.contentImages.count == 4) {
                for (int i = 0; i < 2; i++) {
                    for (int j = 0; j < 2; j++) {
                        CGRect imageRect = CGRectMake(11 + j * (10 + 90), CGRectGetMaxY(self.expandRect) + 10 + i * (10 + 90), 90, 90);
                        [self.imageRects addObject:@(imageRect)];
                    }
                }
            } else if (_timeLineModel.contentImages.count == 5 || _timeLineModel.contentImages.count == 6 || _timeLineModel.contentImages.count == 7 || _timeLineModel.contentImages.count == 8 || _timeLineModel.contentImages.count == 9) {
                for (int i = 0; i < _timeLineModel.contentImages.count; i++) {
                    CGRect imageRect = CGRectMake(11 + (i % 3) * (10 + 90), CGRectGetMaxY(self.expandRect) + 10 + (i / 3) * (10 + 90), 90, 90);
                    [self.imageRects addObject:@(imageRect)];
                }
            }
        }
    
        if (self.imageRects.count > 0) {
            CGRect lastRect = [self.imageRects[self.imageRects.count - 1] CGRectValue];
            self.seperatorViewRect = CGRectMake(0, CGRectGetMaxY(lastRect) + 10, sWidth, 15);
        }else{
            self.seperatorViewRect = CGRectMake(0, CGRectGetMaxY(self.expandRect) + 10, timeWidth, 15);
        }
        self.height = CGRectGetMaxY(self.seperatorViewRect);
    }
    
    #pragma mark **-- Caculate Method**
    - (CGFloat)calcWidthWithTitle:(NSString *)title font:(CGFloat)font {
        NSStringDrawingOptions options =  NSStringDrawingUsesLineFragmentOrigin | NSStringDrawingUsesFontLeading;
        CGRect rect = [title boundingRectWithSize:CGSizeMake(MAXFLOAT,MAXFLOAT) options:options attributes:@{NSFontAttributeName:[UIFont systemFontOfSize:font]} context:nil];
        CGFloat realWidth = ceilf(rect.size.width);
        return realWidth;
    }
    
    - (CGFloat)calcLabelHeight:(NSString *)str fontSize:(CGFloat)fontSize width:(CGFloat)width {
        NSStringDrawingOptions options =  NSStringDrawingUsesLineFragmentOrigin | NSStringDrawingUsesFontLeading;
        CGRect rect = [str boundingRectWithSize:CGSizeMake(width,MAXFLOAT) options:options attributes:@{NSFontAttributeName:[UIFont systemFontOfSize:fontSize]} context:nil];
        CGFloat realHeight = ceilf(rect.size.height);
        return realHeight;
    }
    
    - (int)caculateAttributeLabelHeightWithString:(NSAttributedString *)string  width:(int)width {
        int total_height = 0;
        CTFramesetterRef framesetter = CTFramesetterCreateWithAttributedString((CFAttributedStringRef)string);    //string 为要计算高度的NSAttributedString
        CGRect drawingRect = CGRectMake(0, 0, width, 100000);  //这里的高要设置足够大
        CGMutablePathRef path = CGPathCreateMutable();
        CGPathAddRect(path, NULL, drawingRect);
        CTFrameRef textFrame = CTFramesetterCreateFrame(framesetter,CFRangeMake(0,0), path, NULL);
        CGPathRelease(path);
        CFRelease(framesetter);
        NSArray *linesArray = (NSArray *) CTFrameGetLines(textFrame);
        CGPoint origins[[linesArray count]];
        CTFrameGetLineOrigins(textFrame, CFRangeMake(0, 0), origins);
        int line_y = (int) origins[[linesArray count] -1].y;  //最后一行line的原点y坐标
        CGFloat ascent;
        CGFloat descent;
        CGFloat leading;
        CTLineRef line = (__bridge CTLineRef) [linesArray objectAtIndex:[linesArray count]-1];
        CTLineGetTypographicBounds(line, &ascent, &descent, &leading);
        total_height = 100000 - line_y + (int) descent +1;    //+1为了纠正descent转换成int小数点后舍去的值
        CFRelease(textFrame);
        return total_height;
    }
    @end
    

    通过layout的声明即可知道,layout主要完成cell中各个控件的frame初始化,即在子线程中提前完成cell排版、分割线、高度的加载。在调用cellForRowAtIndexPath中获取cell时,直接将layout作为参数传入到cell中,完成各个控件frame的初始化工作。

  • 预解码

    预解码主要是针对图片和音视频,比如图片,首先需要明确的是UIImage并不是控件,而是一个数据模型,控件是UIImageView

    image.png

    在获取图片数据时返回的是二进制流,通过解码形成图片模型,也就是UIImage,最终渲染到UIImageView上。这个过程中解码是一个耗时又消耗资源的过程,所以通过在异步获取图片资源时,提前做好解码工作。

    例如我们常用的一些三方组件,如SDWebImage对图片的编解码,FFmpeg对音视频的编解码,在性能优化方面做了很多工作。

  • 按需加载

    这种方式就比较好理解了,根据需要去加载相关数据。如UITableView在快速滚动过程中,会不停调用cellForRowAtIndexPath方法。而有些位置上的cell可能一闪而过,视觉上并不需要加载对应的数据,所以可以通过监听UITableView的滚动状态,当UITableView停止滚动时,初始化需要加载的items,按需加载数据。