iOS性能优化01 -- 卡顿优化

  • 在探讨iOS屏幕卡顿优化之前,首先我们来介绍屏幕成像的基本原理;
CPU与GPU
  • CPU:是计算机设备的运算中心与控制中心,其主要负责对象的创建和销毁、对象属性的调整、布局计算、文本排版、图片的格式转换和解码、图像的绘制(Core Graphics);
  • GPU:专门用来进行图像绘制与渲染的处理器,支持单元计算与高并发,处理效率非常高,其主要负责接收提交的纹理(Texture)和顶点描述(三角形),应用变换(transform)、图像的混合(合成)并渲染;
屏幕成像
  • 目前计算机设备将图像数据进行GPU渲染,采用的是光栅化技术;
  • 光栅化是将一个图元转变为一个二维图像的过程,二维图像上每个点都包含了颜色、深度和纹理数据,二维图像数据的本质就是一个二维像素矩阵;
  • CRT的电子枪从上到下逐行扫描,扫描完成后显示器就呈现一帧画面,然后电子枪会回到初始位置准备进行下一次的扫描;
  • 拿到图像数据后,首先进行CPU的计算,完成之后将计算结果传递给GPU,GPU进行图像的渲染,最后将渲染的结果放入帧缓冲区,接着视频控制器从帧缓冲区中读取数据,进行数模转换,最后显示到屏幕上;
image.png
图像的相关概念
  • 帧(Frame):简单理解就是视频或者动画中的每一张画面;
  • 帧数(Frames):即帧的总数量,视频或者动画生成的总的静态画面数量;
  • 帧率(Frame rate):播放视频或者动画时,每秒显示的帧数量称之为帧率,其与GPU以及显卡每秒能计算出的画面数量有关,是根据硬件性能决定的;
  • 屏幕的刷新率:指屏幕每秒刷新的次数,是固定的,一般为60HZ,也就是说每隔16.7毫秒会刷新一次屏幕;
  • FPS:Frames Per Second,表示GPU每秒渲染的帧数,通过用于衡量画面的流畅度,数值越高则表示画面越流畅;
  • 帧率与屏幕的刷新率在大多数情况下是不相等的,是造成图像显示异常的根本原因;
  • 帧率与屏幕的刷新率可以看成是典型的生产者--消费者模式,帧率可看成生产者,快速生成图像帧数,屏幕的刷新率可看成消费者,获取图像帧在屏幕上进行显示;
画面撕裂
  • 由上述可知,图像的显示是视频控制器从帧缓冲区中取出数据,显示器经过扫描才会显示到屏幕上,若CPU与显卡硬件性能很强大,也就是说帧率 > 屏幕的刷新率,会出现屏幕在绘制一帧数据时,才绘制了一半,新的帧数已经产生,并且放入了帧缓冲区,此时视频控制器从帧缓冲区取出的是新的帧数据,这就导致了在屏幕上的上半部分显示的是上一帧的数据,下半部分显示的是新的一帧数据,这种现象称之为画面撕裂
image.png
画面跳帧
  • 若CPU与显卡硬件性能极其强大,帧率 远远大于 屏幕的刷新率,会导致当前帧数据才开始绘制,下一帧的数据就已经生成放进了缓冲区,在屏幕上当前帧的数据就被下一帧数据覆盖了,也就说当前帧被跳过了,这种现象称之为画面跳帧
画面闪烁
  • 若CPU与显卡硬件性能不高,帧率 小于 屏幕的刷新率,那么屏幕在绘制数据完一帧数据后,下一帧的数据还没生成完毕,这就导致用户每次在屏幕看到的是不完整的图形,每次看到的图形比上次要完整一些,在用户看来整个画面存在卡顿,闪烁,不顺滑;
解决方案
  • iOS官方,采用垂直同步信号+双缓冲区来解决以上三种问题,其中垂直同步信号是用来解决画面撕裂画面跳帧双缓冲区是用来解决画面闪烁的;
  • 垂直同步信号:垂直同步信号开启后,CPU与GPU会等待显示器的VSync信号发出后再进行新的一帧数据的CPU计算和GPU渲染以及缓冲区的更新;
  • 双缓冲区:采用两个帧缓冲区来存储GPU的处理结果,分别为Back Buffer(后缓冲区--主要用于后台的绘制渲染)Frame Buffer(显示缓冲区),GPU向Back Buffer写入数据,一个非常重要的注意点在于Back Buffer是一个不断写入的过程,里面存储的图像数据是逐渐趋向于完整的图像,也就说如果直接从Back Buffer取出图像数据给视频控制器,那么屏幕上显示的是不完整的图像,当Back Buffer数据写完了之后,会将完整的图像帧数据复制拷贝一份到Frame Buffer中,也就是说Frame Buffer中存储的是完整的图像帧数据,然后视图控制器指向Frame Buffer;
  • 注意⚠️:这里说的复制拷贝,底层是通过交换两个缓冲区的内存地址来实现的;
  • 缓冲区工作流程的总结:
    • 显示器在发出垂直同步信号之后,Back Buffer会将数据复制到Frame Buffer(缓冲区的交换),并通知CPU/GPU计算渲染下一帧的图像;
    • 视频控制器读取Frame Buffer中当前帧的图像数据,将其显示到屏幕上;
画面(屏幕)卡顿
  • 上述采用垂直同步信号 + 双缓冲区机制解决了画面撕裂,画面跳帧和画面闪烁的问题,但依然存在一个问题,那就是画面的掉帧;
  • 当显示器发出垂直信号时,正常情况下GPU会将渲染完成的帧数据从Back Buffer复制到Frame Buffer,但如果图像数据过于复杂,计算量很大,GPU仍然处于渲染处理(写入Back Buffer)数据中,也就是说GPU处理数据的时间超过了16.7ms,即在一个屏幕刷新周期内还没渲染完成,那么两个缓冲区的数据不会发生交换;
  • 当屏幕进入下一个刷新周期时,视频控制器从Frame Buffer取出的数据,仍然是上一帧的数据,也就是说在两个屏幕刷新周期内显示的是同一帧数据,也就是所谓的掉帧(Jank),给用户的体验就是画面屏幕的卡顿,如下图所示:
image.png
  • B帧数据的CPU+GPU的处理时间超过了屏幕刷新周期时间(16.7ms),导致A帧数据在屏幕上显示了两次;
  • 解决方案:可采用三重缓冲区,减少画面的掉帧频率,但不能从根本上解决问题,且增加了CPU与GPU的计算,原理图如下所示:
image.png
  • 在第二个A展示,VSync信号发出后,直接绘制C帧数据到Back Buffer1中;
  • 在第一个B展示,VSync信号发出后,绘制A帧数据到Back Buffer2中;
  • 当B显示完成,接收到VSync信号后,因为C帧数据已经在Back Buffer1中了,复制给Frame Buffer,然后直接显示在屏幕上,
  • 当C显示完成,接收到VSync信号后,因为A帧数据已经在Back Buffer2中了,复制给Frame Buffer,然后直接显示在屏幕上,以此类推;
  • 三重缓冲区的本质是在每次发出VSync信号后,多了一个Back Buffer(后缓冲区)来缓存帧数据;
iOS中卡顿的监测
  • iOS手机默认的屏幕刷新率为60HZ,所以GPU的渲染帧率只要达到60FPS就不会产生卡顿,若低于60FPS,出现掉帧,给用户的体验就是有屏幕的卡顿;
卡顿监测的第一种方案:利用CADisplayLink计算GPU的帧率是否达到60FPS
  • 原理:CADisplayLink是一个类似于NSTimer的定时器,但它比较特殊与GPU的绘制渲染机制有关,默认每秒执行60次回调方法,其必须加入RunLoop中才能正常运行(这里我们让它加入主RunLoop),我们可利用它来统计在1秒内执行 回调的次数 是否达到60次,来判定主线程是否卡顿,代码实现如下:
#import "ViewController.h"

@interface ViewController ()

@property(nonatomic,strong)UILabel *FPSLabel;

@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    
    //主线程 注意有内存泄漏
    [[CADisplayLink displayLinkWithTarget:self selector:@selector(displayLinkAction:)] addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes];
    
    [self.view addSubview:self.FPSLabel];
}

- (void)displayLinkAction:(CADisplayLink *)link {
    //静态变量记录上次执行回调方法的时间戳
    static NSTimeInterval lastTime = 0;
    //静态变量记录回调方法执行的次数
    static NSInteger frameCount = 0;
    if (lastTime == 0) {
        lastTime = link.timestamp;
        return;
    }
    frameCount ++;
    //当CADisplayLink的时间间隔累积到1秒时 计算回调方法执行的次数
    //计算得到 每秒钟 回调执行的次数 作为GPU的渲染帧率 看是否能达到60帧/s 由此可判定主线程是否卡顿;
    NSTimeInterval paseTime = link.timestamp - lastTime;
    if (paseTime >= 1) {
        NSInteger fps = frameCount / paseTime;
        lastTime = link.timestamp;
        frameCount = 0;
        NSLog(@"fps = %ld",fps);
        self.FPSLabel.text = [NSString stringWithFormat:@"%ldFPS",fps];
    }
}

- (UILabel *)FPSLabel{
    if (!_FPSLabel) {
        _FPSLabel = [[UILabel alloc]init];
        _FPSLabel.font = [UIFont systemFontOfSize:16];
        _FPSLabel.textColor = [UIColor whiteColor];
        _FPSLabel.backgroundColor = [UIColor grayColor];
        _FPSLabel.textAlignment = NSTextAlignmentCenter;
        _FPSLabel.frame = CGRectMake([UIScreen mainScreen].bounds.size.width - 100 - 30, [UIScreen mainScreen].bounds.size.height - 100, 100, 30);
    }
    return _FPSLabel;
}
@end
  • 优缺点:可以实时监测GPU的渲染帧率,但是无法精确采集到卡顿时函数调用堆栈信息,给开发者定位问题,优化代码带来困难,可以在开发阶段作为辅助手段使用;
卡顿监测的第二种方案:RunLoop监听应用程序卡顿
  • iOS内存管理10 -- RunLoop运行循环 这篇文章中对RunLoop有着非常详细的介绍,RunLoop的运行流程如下所示:
    Snip20211229_78.png
  • 从图中可以看出RunLoop在处理事件时主要集中在以下两个阶段:
    • kCFRunLoopBeforeSourceskCFRunLoopBeforeWaiting之间;
    • kCFRunLoopAfterWaiting之后;
  • 为了监听应用程序是否存在卡顿,只要查看主线程RunLoop在处理事件时是否存在耗时即可,那么我们必须要知晓主RunLoop的运行状态,逻辑步骤如下:
    • 第一步:通过创建RunLoop的观察者即CFRunLoopObserverRef类型的实例对象,在观察者的监听回调中获取主RunLoop的状态;
    • 第二步:在每次获取到RunLoop的状态之后,(在主线程中)通过dispatch_semphore_t发送一个信号量(dispatch_semaphore_signal),然后创建一个子线程,在子线程内部接收信号量(dispatch_semaphore_wait),并设置一个延迟时间,若在设置的延迟时间之内,子线程没有接收到信号量,则表明主线程可能正在执行耗时任务,可能引起应用的卡顿,主要是监听RunLoop的kCFRunLoopBeforeSourceskCFRunLoopAfterWaiting这两个状态;
  • 主要代码实现如下:
#import <Foundation/Foundation.h>
#define SHAREDMONITOR [LXDAppFluecyMonitor sharedMonitor]
/*!
 *  @brief  监听UI线程卡顿
 */
@interface LXDAppFluecyMonitor : NSObject
+ (instancetype)sharedMonitor;
- (void)startMonitoring;
- (void)stopMonitoring;
@end
#import "LXDAppFluecyMonitor.h"

#define LXD_DEPRECATED_POLLUTE_MAIN_QUEUE

@interface LXDAppFluecyMonitor ()
@property (nonatomic, assign) int timeOut;
@property (nonatomic, assign) BOOL isMonitoring;
@property (nonatomic, assign) CFRunLoopObserverRef observer;
@property (nonatomic, assign) CFRunLoopActivity currentActivity;
@property (nonatomic, strong) dispatch_semaphore_t semphore;
@property (nonatomic, strong) dispatch_semaphore_t eventSemphore;
@end

#define LXD_SEMPHORE_SUCCESS 0
static NSTimeInterval lxd_restore_interval = 5;
static NSTimeInterval lxd_time_out_interval = 1;
static int64_t lxd_wait_interval = 200 * NSEC_PER_MSEC;

/*!
 *  @brief  监听runloop状态在after waiting和before sources之间
 */
static inline dispatch_queue_t lxd_fluecy_monitor_queue() {
    static dispatch_queue_t lxd_fluecy_monitor_queue;
    static dispatch_once_t once;
    dispatch_once(&once, ^{
        lxd_fluecy_monitor_queue = dispatch_queue_create("com.sindrilin.lxd_monitor_queue", NULL);
    });
    return lxd_fluecy_monitor_queue;
}

#define LOG_RUNLOOP_ACTIVITY 0
static void lxdRunLoopObserverCallback(CFRunLoopObserverRef observer, CFRunLoopActivity activity, void * info) {
    SHAREDMONITOR.currentActivity = activity;
    dispatch_semaphore_signal(SHAREDMONITOR.semphore);
#if LOG_RUNLOOP_ACTIVITY
    switch (activity) {
        case kCFRunLoopEntry:
            NSLog(@"runloop entry");
            break;
        case kCFRunLoopExit:
            NSLog(@"runloop exit");
            break;
        case kCFRunLoopAfterWaiting:
            NSLog(@"runloop after waiting");
            break;
        case kCFRunLoopBeforeTimers:
            NSLog(@"runloop before timers");
            break;
        case kCFRunLoopBeforeSources:
            NSLog(@"runloop before sources");
            break;
        case kCFRunLoopBeforeWaiting:
            NSLog(@"runloop before waiting");
            break;
        default:
            break;
    }
#endif
};

@implementation LXDAppFluecyMonitor

#pragma mark - Singleton override
+ (instancetype)sharedMonitor {
    static LXDAppFluecyMonitor * sharedMonitor;
    static dispatch_once_t once;
    dispatch_once(&once, ^{
        sharedMonitor = [[super allocWithZone: NSDefaultMallocZone()] init];
        [sharedMonitor commonInit];
    });
    return sharedMonitor;
}

+ (instancetype)allocWithZone: (struct _NSZone *)zone {
    return [self sharedMonitor];
}

- (void)dealloc {
    [self stopMonitoring];
}

- (void)commonInit {
    self.semphore = dispatch_semaphore_create(0);
}

#pragma mark - Public
- (void)startMonitoring {
    if (_isMonitoring) { return; }
    _isMonitoring = YES;
    CFRunLoopObserverContext context = {
        0,
        (__bridge void *)self,
        NULL,
        NULL
    };
    //创建监听者
    _observer = CFRunLoopObserverCreate(kCFAllocatorDefault, kCFRunLoopAllActivities, YES, 0, &lxdRunLoopObserverCallback, &context);
    //监听主RunLoop
    CFRunLoopAddObserver(CFRunLoopGetMain(), _observer, kCFRunLoopCommonModes);
    
    //创建子线程
    dispatch_async(lxd_fluecy_monitor_queue(), ^{
        NSLog(@"%@",[NSThread currentThread]);
        while (SHAREDMONITOR.isMonitoring) {
            //成功为0,表示在指定时间内接收到主线程发出的信号
            //不成功非0,表示在指定时间内没有接收到主线程发出的信号,主线程可能在执行耗时任务,有可能造成应用程序的卡顿
            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;
                }
                //kCFRunLoopBeforeSources 主RunLoop开始处理事件
                //kCFRunLoopAfterWaiting  主RunLoop结束休眠
                //状态判断 即在kCFRunLoopBeforeSources或kCFRunLoopAfterWaiting这两个状态区间内出现耗时
                if (SHAREDMONITOR.currentActivity == kCFRunLoopBeforeSources || SHAREDMONITOR.currentActivity == kCFRunLoopAfterWaiting) {
                    //出现5次耗时 则上传主线程的函数调用栈
                    if (++SHAREDMONITOR.timeOut < 5) {
                        continue;
                    }
                    [LXDBacktraceLogger lxd_logMain];
                    [NSThread sleepForTimeInterval: lxd_restore_interval];
                }
            }
            SHAREDMONITOR.timeOut = 0;
        }
    });
}

- (void)stopMonitoring {
    if (!_isMonitoring) { return; }
    _isMonitoring = NO;
    
    CFRunLoopRemoveObserver(CFRunLoopGetMain(), _observer, kCFRunLoopCommonModes);
    CFRelease(_observer);
    _observer = nil;
}
@end
  • 新建测试类代码如下:
#import "ViewController.h"
#import "LXDAppFluecyMonitor.h"

@interface ViewController ()<UITableViewDelegate, UITableViewDataSource>

@property (weak, nonatomic) IBOutlet UITableView *tableView;

@end

@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];
    [[LXDAppFluecyMonitor sharedMonitor] startMonitoring];
    [self.tableView registerClass: [UITableViewCell class] forCellReuseIdentifier: @"cell"];
}

- (void)viewDidAppear: (BOOL)animated {
    [super viewDidAppear: animated];
}

- (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) {
       //等价于sleep(2)
        usleep(2 * 1000 * 1000);
    }
    return cell;
}

- (void)tableView: (UITableView *)tableView didSelectRowAtIndexPath: (NSIndexPath *)indexPath {
    usleep(2 * 1000 * 1000);
}
@end
  • 在点击cell与设置cell的代码中添加了耗时操作usleep(2 * 1000 * 1000),发现并不能监听到卡顿;
  • 于是继续探索,借鉴了他人一种新的方案:创建一个子线程进行循环检测,每次检测时设置标记位为YES,然后派发任务到主线程中(切换到主线程)将标记位设置为NO,接着子线程沉睡超时阙值时长,判断标志位是否成功设置成NO,如果没有设置成功为NO,说明主线程发生了卡顿,无法处理派发任务,代码实现如下:
dispatch_async(lxd_event_monitor_queue(), ^{
    NSLog(@"%@",[NSThread currentThread]);
    while (SHAREDMONITOR.isMonitoring) {
        //主线程的RunLoop 即将进入休眠
        if (SHAREDMONITOR.currentActivity == kCFRunLoopBeforeWaiting) {
            //默认超时
            __block BOOL timeOut = YES;
            NSLog(@"0");
            
            dispatch_async(dispatch_get_main_queue(), ^{
                //切换到主线程 执行任务
                //若主线程没有出现卡顿 能正常执行任务 将timeOut设置为NO
                //若主线程出现卡顿 不能能正常执行任务
               timeOut = NO;
                //发送信号量 +1
               dispatch_semaphore_signal(SHAREDMONITOR.eventSemphore);
               NSLog(@"1");
            });
            
            NSLog(@"2");
            //当前子线程休眠1秒钟
            [NSThread sleepForTimeInterval: lxd_time_out_interval];
            NSLog(@"3");
            //超时打印函数调用栈
            if (timeOut) {
               NSLog(@"4");
               [LXDBacktraceLogger lxd_logMain];
            }
            NSLog(@"5");
            //释放信号量 -1 此时的信号量为-1<0 下面的逻辑不会执行 循环依然执行
            dispatch_wait(SHAREDMONITOR.eventSemphore, DISPATCH_TIME_FOREVER);
            NSLog(@"6");
        }
    }
});
  • 再次测试,发现能监听到点击时的卡顿,且能监听到滚动时的卡顿了,完整的代码工程请参考LXDAppFluecyMonitor
  • 卡顿时的函数调用堆栈如下所示:
image.png
卡顿监测的第三种方案:使用Instrument工具实时监测App
  • 可利用Time Profiler,查看App的CPU的使用情况,定位方法耗时,具体操作步骤如下:
  • 首先配置项目的Scheme,如下所示:
image.png
  • 其次配置项目,如下所示:
image.png
  • 做如上的配置主要是为了,在定位耗时方法时,能看到方法名,否则全是内存地址;
  • 启动Instrument,打开Time Profiler工具,操作如下:
image.png
image.png
image.png
卡顿的优化
  • 从上文易知导致屏幕卡顿的根本原因在于CPU/GPU的负担过重(资源消耗过大),没能在指定的时间内生成渲染数据,导致显示器上仍然显示的是上一帧的数据,即掉帧现象,所以卡顿的优化主要在于如何减轻CPU与GPU的资源消耗
CPU的资源消耗与解决方案
  • 对象的创建:对象的创建会分配内存、调整属性、甚至还有读取文件等操作,比较消耗 CPU 资源。尽量用轻量的对象代替重量的对象,可以对性能有所优化。比如 CALayer 比 UIView 要轻量许多,那么不需要响应触摸事件的控件,用 CALayer 显示会更加合适。如果对象不涉及 UI 操作,则尽量放到后台线程去创建,但可惜的是包含有 CALayer 的控件,都只能在主线程创建和操作。通过 Storyboard 创建视图对象时,其资源消耗会比直接通过代码创建对象要大非常多,在性能敏感的界面里,Storyboard 并不是一个好的技术选择,尽量推迟对象创建的时间,并把对象的创建分散到多个任务中去。尽管这实现起来比较麻烦,并且带来的优势并不多,但如果有能力做,还是要尽量尝试一下。如果对象可以复用,并且复用的代价比释放、创建新对象要小,那么这类对象应当尽量放到一个缓存池里复用;
  • 对象的调整:对象的调整也经常是消耗 CPU 资源的地方。这里特别说一下 CALayer:CALayer 内部并没有属性,当调用属性方法时,它内部是通过运行时 resolveInstanceMethod 为对象临时添加一个方法,并把对应属性值保存到内部的一个 Dictionary 里,同时还会通知 delegate、创建动画等等,非常消耗资源。UIView 的关于显示相关的属性(比如 frame/bounds/transform)等实际上都是 CALayer 属性映射来的,所以对 UIView 的这些属性进行调整时,消耗的资源要远大于一般的属性。对此你在应用中,应该尽量减少不必要的属性修改。当视图层次调整时,UIView、CALayer 之间会出现很多方法调用与通知,所以在优化性能时,应该尽量避免调整视图层次、添加和移除视图
  • 对象的销毁:对象的销毁虽然消耗资源不多,但累积起来也是不容忽视的。通常当容器类持有大量对象时,其销毁时的资源消耗就非常明显。同样的,如果对象可以放到后台线程去释放,那就挪到后台线程去。这里有个小 Tip:把对象捕获到 block 中,然后扔到后台队列去随便发送个消息以避免编译器警告,就可以让对象在后台线程销毁了;
NSArray *tmp = self.array;
self.array = nil;
dispatch_async(queue, ^{
    [tmp class];
});
  • 布局计算:视图布局的计算是 App 中最为常见的消耗 CPU 资源的地方。如果能在后台线程提前计算好视图布局、并且对视图布局进行缓存,那么这个地方基本就不会产生性能问题了。不论通过何种技术对视图进行布局,其最终都会落到对 UIView.frame/bounds/center 等属性的调整上。上面也说过,对这些属性的调整非常消耗资源,所以尽量提前计算好布局,在需要时一次性调整好对应属性,而不要多次、频繁的计算和调整这些属性;
  • Autolayout:Autolayout 是苹果本身提倡的技术,在大部分情况下也能很好的提升开发效率,但是 Autolayout 对于复杂视图来说常常会产生严重的性能问题。随着视图数量的增长,Autolayout 带来的 CPU 消耗会呈指数级上升。如果你不想手动调整 frame 等属性,你可以用一些工具方法替代(比如常见的 left/right/top/bottom/width/height 快捷属性),或者使用 ComponentKit、AsyncDisplayKit 等框架;
  • 文本计算:如果一个界面中包含大量文本(比如微博微信朋友圈等),文本的宽高计算会占用很大一部分资源,并且不可避免。如果你对文本显示没有特殊要求,可以参考下 UILabel 内部的实现方式:用 [NSAttributedString boundingRectWithSize:options:context:] 来计算文本宽高,用 -[NSAttributedString drawWithRect:options:context:] 来绘制文本。尽管这两个方法性能不错,但仍旧需要放到后台线程进行以避免阻塞主线程,如果你用 CoreText 绘制文本,那就可以先生成 CoreText 排版对象,然后自己计算了,并且 CoreText 对象还能保留以供稍后绘制使用;
  • 文本渲染:屏幕上能看到的所有文本内容控件,包括 UIWebView,在底层都是通过 CoreText 排版、绘制为 Bitmap 显示的。常见的文本控件 (UILabel、UITextView 等),其排版和绘制都是在主线程进行的,当显示大量文本时,CPU 的压力会非常大。对此解决方案只有一个,那就是自定义文本控件,用 TextKit 或最底层的 CoreText 对文本异步绘制。尽管这实现起来非常麻烦,但其带来的优势也非常大,CoreText 对象创建好后,能直接获取文本的宽高等信息,避免了多次计算(调整 UILabel 大小时算一遍、UILabel 绘制时内部再算一遍);CoreText 对象占用内存较少,可以缓存下来以备稍后多次渲染;
  • 图片的解码:当你用 UIImage 或 CGImageSource 的那几个方法创建图片时,图片数据并不会立刻解码。图片设置到 UIImageView 或者 CALayer.contents 中去,并且 CALayer 被提交到 GPU 前,CGImage 中的数据才会得到解码。这一步是发生在主线程的,并且不可避免。如果想要绕开这个机制,常见的做法是在后台线程先把图片绘制到 CGBitmapContext 中,然后从 Bitmap 直接创建图片。目前常见的网络图片库都自带这个功能;
  • 图像的绘制:图像的绘制通常是指用那些以 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;
        });
    });
}
GPU的资源消耗与解决方案
  • GPU主要负责将数据转成位图,完成图形的渲染,最后提交到帧缓冲区,其详细步骤有:顶点数据存储->顶点着色器处理(将顶点转成图元)->图元装配->光栅化(图元转换为像素)->处理像素,得到位图->片段着色器(给每一个像素 Pixel 赋予正确的颜色) -> 测试与混合(处理片段的前后位置以及透明度)->图像帧;
  • 纹理的渲染:若设置了CALayer 的 border、圆角、阴影、遮罩(mask),CASharpLayer 的矢量图形显示,通常会触发离屏渲染(offscreen rendering),而离屏渲染通常发生在 GPU 中,当一个列表视图中出现大量圆角的 CALayer,并且快速滑动时,可以观察到 GPU 资源已经占满,而 CPU 资源消耗很少。这时界面仍然能正常滑动,但平均帧数会降到很低。为了避免这种情况,可以尝试开启 CALayer.shouldRasterize 属性,但这会把原本离屏渲染的操作转嫁到 CPU 上去。对于只需要圆角的某些场合,也可以用一张已经绘制好的圆角图片覆盖到原本视图上面来模拟相同的视觉效果。最彻底的解决办法,就是把需要显示的图形在后台线程绘制为图片,避免使用圆角、阴影、遮罩等属性。
  • 视图的混合:当多个视图(或者说 CALayer)重叠在一起显示时,GPU 会首先把他们混合到一起。如果视图结构过于复杂,混合的过程也会消耗很多 GPU 资源。为了减轻这种情况的 GPU 消耗,应用应当尽量减少视图数量和层次,并在不透明的视图里标明 opaque 属性以避免无用的 Alpha 通道合成。当然,这也可以用上面的方法,把多个视图预先渲染为一张图片来显示;
离屏渲染
  • 在默认的帧缓冲区中渲染对象,这叫做当前屏幕渲染(On-screen Rendering);
  • 将渲染计算结果放在非默认帧缓冲区中,这叫做离屏渲染(Off-screen Rendering);
  • 也就是说在进行当前屏幕渲染的时候,若触发了离屏渲染,会额外的开辟一个离屏缓冲区,与当前的双帧缓冲区没有关系,互不影响;
  • 离屏渲染是比较消耗GPU性能的,具体表现在以下两个方面:
    • 离屏渲染会开辟一个单独的离屏缓冲区,其拥有自己的一套渲染通道(渲染管线--渲染流水线);
    • 当前帧缓冲区与离屏缓冲区的渲染通道之间的环境切换,是比较耗时的;
  • 在iOS中,模拟器提供了一个检测页面是否产生离屏渲染的工具,开启如下:选中模拟器->Debug->Color Off-screen Rendered,若页面出现黄色区域,说明有离屏渲染,下面来探索哪些情况下有可能导致离屏渲染;
圆角图片引发离屏渲染的探索
  • 使用Xcode12.4,iOS14.4模拟器,研究结果如下:
image.png
  • 当设置图片的圆角+裁剪时,不会触发离屏渲染;
  • 当设置图片的圆角+边框+裁剪时,会触发离屏渲染;
  • 当设置图片的圆角+背景颜色+裁剪时,会触发离屏渲染;
  • 当设置无图片内容+背景+边框+裁剪时,不会触发离屏渲染;
  • 上述情况,触发离屏渲染的真正原因究竟是什么???
  • 首先我们来介绍一下油画算法:绘制多个图层时,会先绘制场景中的离观察者较远的物体,再绘制较近的物体,也就是按照由远及近的顺序进行绘制,如下所示:
image.png
  • 圆角图片中的子图层有背景图层图片内容图层以及边框,如下所示:
image.png
  • 按照正常的绘制流程,依次绘制背景图层,图片内容,边框,每绘制完一个子图层,就会将其丢弃销毁,为的是节约内存,但是现在要对所有子图层就行圆角的裁剪处理,那么子图层不能直接丢弃,所以就触发了离屏渲染,新开辟了一个离屏缓冲区,用来保存所有绘制的子图层,进行所有子图层的圆角裁剪,最后进行合并,生成最终的图层

  • iOS官方针对UIImageView关于离屏有如下优化:

    • 在iOS9之前,UIImageView和UIButton通过cornerRadius+masksToBounds设置圆角都会触发离屏渲染;
    • 在UIImageView在iOS9以后,针对UIImageView中的image设置圆角并不会触发离屏渲染,如果加上了背景色或者阴影等其他效果还是会触发离屏渲染的;
毛玻璃效果会引发离屏渲染
image.png
阴影效果会引发离屏渲染
image.png
遮罩效果会引发离屏渲染
image.png

参考文章如下:
iOS开发优化篇之卡顿检测
iOS卡顿监测方案总结
iOS 保持界面流畅的技巧
iOS应用千万级架构:性能优化与卡顿监控
iOS 性能优化总结
IOS面试考察(九):性能优化相关问题
iOS圆角的离屏渲染,你真的弄明白了吗
iOS 渲染原理解析
iOS-底层原理39-离屏渲染
深入剖析【离屏渲染】原理

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,324评论 6 498
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,356评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 162,328评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,147评论 1 292
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,160评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,115评论 1 296
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,025评论 3 417
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,867评论 0 274
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,307评论 1 310
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,528评论 2 332
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,688评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,409评论 5 343
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,001评论 3 325
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,657评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,811评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,685评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,573评论 2 353

推荐阅读更多精彩内容