iOS界面卡顿原理及优化

在日常开发中,我们最多遇到的就是UI绘制,内容展示等需求的开发,APP的UI展示是否流畅,也是用户最直接的感受。今天就针对UI界面卡顿原理进行分析,如何优化进行讨论。

一.  卡顿原理

计算机正常的渲染流畅: image.png 通过CPU计算GPU生成FrameBuffer在进行Video Controller 显示到显示器上(monitor) 优化性能后: image.png 在原理基础上增加了一个buffer缓冲区,显示刷帧率60fps/120fps 来回在两个缓冲区取帧。

卡顿原因:如果GPU在某一帧生成中生成不及时,显示取帧时就会在buffer 1 和 buffer 2 来回跑,等待这一帧的生成。这时候就会发生界面UI卡顿。如果这帧没有生成,下一帧生成了就会直接跳过这一帧显示下一帧(这就是掉帧情况)。

核心问题

  • 已经知道卡顿的原因
  • 怎么监测卡顿呢
  • 有哪些方法能够监测卡顿呢

1、卡顿检测

用什么来监测卡顿呢,我们这里用到RunLoop。我们知道RunLoop是主运行循环,可以保存任务的生命周期。(60FPS=16.67ms=1/60)

我们大致了解一下RunLoop: image.png 主要思路就是,添加监测任务到RunLoop中来监测Vsync(垂直同步信号)来判断UI是否卡顿。

这里借鉴了YYKitYYFPSLabel运行代码如图: image.png 创建一个工程NYMainThreadBlock:来感受监测卡顿的核心思想; image.png 注册自定义observer任务到runloop并且发送信号量->NYBlockMonitor中,NYBlockMonitor有一个子线程无限循环->等待判断自定义observer的休眠,唤醒的信号量,用来评判整个系统runloop的工作情况,因为UI的渲染工作也在runloop的系统任务中,其他优先级高的任务占用大量runloop的运行时间,我们自定义的observer任务就会发生等待休眠,这样我们就能够判断出UI是否卡顿。(如上图所示)

核心代码:

//  NYBlockMonitor.m
//  NYMainThreadBlock
//
//  Created by ning on 2022/7/12.
//

#import "NYBlockMonitor.h"
@interface NYBlockMonitor(){
    CFRunLoopActivity acticity; //状态集
}

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

@end

@implementation NYBlockMonitor

+ (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 CallBock(CFRunLoopObserverRef observer,CFRunLoopActivity activity,void *info)
{
    NYBlockMonitor *monitor = (__bridge NYBlockMonitor *)info;
    monitor->acticity = activity;
    //发送信号
    dispatch_semaphore_t semaphore = monitor->_semaphore;
    dispatch_semaphore_signal(semaphore); //信号+1
}

- (void)registerObserver
{
    CFRunLoopObserverContext context = {0,(__bridge  void*)self,NULL,NULL};
    //NSIntegerMax :优先级最小
    CFRunLoopObserverRef observer = CFRunLoopObserverCreate(kCFAllocatorDefault,
                                                            kCFRunLoopAllActivities,
                                                            YES,
                                                            NSIntegerMax,
                                                            &CallBock,
                                                            &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) {
                // 即将处理 Source , 刚从休眠中唤醒 进入判断
                if (self->acticity == kCFRunLoopBeforeSources || self->acticity == kCFRunLoopAfterWaiting) {
                    if (++self->_timeoutCount < 2) {
                        NSLog(@"timeoutCount=%lu",(unsigned long)self->_timeoutCount);
                        continue;
                    }
                    // 一秒左右的衡量尺度 很大可能性连续来 避免大规模打印!
                    NSLog(@"检测到超过两次连续卡顿 - %ld",(unsigned long)self->_timeoutCount);
                }
            }
            self->_timeoutCount = 0;
        }
    });

}
@end
复制代码

运行效果: image.png

二.  界面优化

1、预排版

常规MVC模式中,有可能在view层中计算frame的大小,及相关UI的size。这在 UI显示熏染中会损耗性能。怎么解决这一问题?就是把view的大小及排版归类到model中在子线程中就把view的排版计算好了,这样可以减少UI view 的渲染损耗。

上一段小代码:

@implementation NYTimeLineCellLayout

- (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;
    //文本信息高度计算
  //**********************省略代码***********************//
    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

//TableViewCell 添加配置NYTimeLineCellLayout 方法
- (void)configureLayout:(NYTimeLineCellLayout *)layout{
//**********************省略代码***********************//
}
复制代码

这样就达到预排版的目的了(挺简单的手段,大家都能想到吧)。

2、预编码解码

现在项目开发中,还有一种情况会对UI熏染性能造成消耗。就是图片的加载,为什么图片会对系统造成负担呢,要如何减少图片加载带来的过多消耗呢?

UIImage *image = [UIImage imageWithContentsOfFile:@"/xxxxx.png"];
self.kcImageView.image = image;
复制代码

运行项目,查看占用内存情况。 image.png 可实际图片大小是31.4MB image.png 如果改为如下代码(苹果官方文档的下采样方式):

// Objective-C: 大图缩小为显示尺寸的图
- (UIImage *)downsampleImageAt:(NSURL *)imageURL to:(CGSize)pointSize scale:(CGFloat)scale {
    // 利用图像文件地址创建 image source
    NSDictionary *imageSourceOptions = @{(__bridge NSString *)kCGImageSourceShouldCache: @NO // 原始图像不要解码
    };
    CGImageSourceRef imageSource =
    CGImageSourceCreateWithURL((__bridge CFURLRef)imageURL, (__bridge CFDictionaryRef)imageSourceOptions);
    // 下采样
    CGFloat maxDimensionInPixels = MAX(pointSize.width, pointSize.height) * scale;
    NSDictionary *downsampleOptions =
    @{
      (__bridge NSString *)kCGImageSourceCreateThumbnailFromImageAlways: @YES,
      (__bridge NSString *)kCGImageSourceShouldCacheImmediately: @YES,  // 缩小图像的同时进行解码
      (__bridge NSString *)kCGImageSourceCreateThumbnailWithTransform: @YES,
      (__bridge NSString *)kCGImageSourceThumbnailMaxPixelSize: @(maxDimensionInPixels)
       };
    CGImageRef downsampledImage =
    CGImageSourceCreateThumbnailAtIndex(imageSource, 0, (__bridge CFDictionaryRef)downsampleOptions);
    UIImage *image = [[UIImage alloc] initWithCGImage:downsampledImage];
    CGImageRelease(downsampledImage);
    CFRelease(imageSource);
    return image;
}
复制代码

运行效果: image.png 通过下采样解码减少系统对图片加载的消耗。

3、异步渲染

异步渲染是什么意思呢,异步渲染做了什么呢?我们通过一个案例来研究了解一下: image.png 运行项目发现,怎么只有一个图层。正常开放我们在view上创建多种控件,组成了某个界面然后每个控件都有自己的图层。而我们的案例只有一层,这是为什么呢?我们慢慢解开谜底。

运行项目查看堆栈信息: image.png 我们看到有用到图层的地方都会有CA::Transaction::commit()这样的代码。Transaction作了什么呢?

iOS中UIKit能显示内容主要依赖的框架如图: image.png 从Core Animation到GPU渲染过程: image.png

  • Application 中布局 UIKit 视图控件间接的关联Core Animation 图层
  • Core Animation 图层相关的数据提交到 iOS Render Server,即 OpenGL ES & Core Graphics
  • Render Server 将与 GPU通信把数据经过处理之后传递给 GPU
  • GPU 调用 iOS 当前设备渲染相关的图形设备 Display

Commit Transaction做了什么?

  • Layout构建视图frame,遍历的操作[UIView layerSubview],[CALayer layoutSubLayers]
  • Display绘制视图,display - drawReact(),displayLyaer:(位图的绘制)
  • Prepare,额外的 Core Animation 工作,比如解码
  • Commit,打包图层并将它们发送到 Render Server

代码:

@implementation NYView
- (void)drawRect:(CGRect)rect {
    // Drawing code, 绘制的操作, BackingStore(额外的存储区域产于的) -- GPU
}

+ (Class)layerClass{
    return [NYLayer class];
}

- (void)layoutSublayersOfLayer:(CALayer *)layer
{
    [super layoutSublayersOfLayer:layer];
    [self layoutSubviews];
}

- (CGContextRef)createContext
{
    UIGraphicsBeginImageContextWithOptions(self.bounds.size, self.layer.opaque, self.layer.contentsScale);
    CGContextRef context = UIGraphicsGetCurrentContext();
    return context;
}

- (void)layerWillDraw:(CALayer *)layer{
    //绘制的准备工作,do nontihing
}

//////绘制的操作
- (void)drawLayer:(CALayer *)layer inContext:(CGContextRef)ctx{
    [super drawLayer:layer inContext:ctx];
    [[UIColor redColor] set];
       //Core Graphics
    UIBezierPath *path = [UIBezierPath bezierPathWithRect:CGRectMake(self.bounds.size.width / 2- 20, self.bounds.size.height / 2- 20, 40, 40)];
    CGContextAddPath(ctx, path.CGPath);
    CGContextFillPath(ctx);
}
//layer.contents = (位图)
- (void)displayLayer:(CALayer *)layer{
    UIImage *image = UIGraphicsGetImageFromCurrentImageContext();
    dispatch_async(dispatch_get_main_queue(), ^{
        layer.contents = (__bridge id)(image.CGImage);
    });
}
- (void)closeContext{
    UIGraphicsEndImageContext();
}
@end


@implementation NYLayer
//前面断点调用写下的代码
- (void)layoutSublayers{
    if (self.delegate && [self.delegate respondsToSelector:@selector(layoutSublayersOfLayer:)]) {
        //UIView
        [self.delegate layoutSublayersOfLayer:self];
    }else{
        [super layoutSublayers];
    }
}

//绘制流程的发起函数
- (void)display{
    // Graver 实现思路
    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)];
}

@end
复制代码

运行效果: image.png 绘制的顺序: layoutSublayersOfLayer ->createContext-> layerWillDraw-> drawLayer-> displayLayer-> closeContext image.png 也可研究一下 美团开源Graver框架:用“雕刻”诠释iOS端UI界面的高效渲染

猜你喜欢

转载自juejin.im/post/7119858546405195783