APM - iOS 卡顿原理及监控方案

应用的流畅度是衡量应用用户体验和性能的一个重要指标,一个流畅的应用能够给用户带来良好的使用感受,提高用户的满意度和忠诚度。对于开发者来说应该重视并不断优化应用的流畅度,提升用户的使用体验。本篇文章主要介绍一下如何监控卡顿,以及一些常用的优化手段。

屏幕成像原理

屏幕成像最底层的原理要从 CRT 显示器(Cathode Ray Tube 阴极射线管)的原理来说,CRT 的电子枪发出电子束来激发屏幕内表面的荧光粉来显示图像的,由于荧光粉被点亮后会很快熄灭,所以电子枪必须循环地不断激发这些点。如上图所示,电子枪会从上到下一行一行的扫描,扫描完最后一行后显示器就呈现了一帧画面,随后电子枪会回到开始的位置继续下一帧的扫描。

同步信号

为了将显示器和系统的视频控制器进行同步,会通过硬件时钟产生一系列的定时信号,分别是:

  • 水平同步信号(horizontal synchronization)

    当电子枪换到新的一行时,在扫描之前会发出一个水平同步信号,简称 HSync。

  • 垂直同步信号(vertical syncchronization)

    当电子枪回到最初位置时,在扫描下一帧之前会发出一个垂直同步信号,简称 VSync。显示器通常是以固定的频率刷新的,这个刷新频率就是 VSync 信号产生的频率。

渲染流水线

如上图,计算机系统中 CPU、GPU、显示器按照上面这种方式协同工作的。

  • CPU

    CPU 一般负责视图创建、销毁,布局计算、文本绘制、图片的解码等,最终将计算好的将要显示的内容提交给 GPU。

  • GPU

    GPU将 CPU 计算好的内容进行合成、渲染,然后将渲染结果放再帧缓冲区(FrameBuffer)中。

  • 视频控制器

    随后视频控制器会随着 VSync 信号的到来,逐行读取帧缓冲区的数据,经过数模转换后给显示器显示。

双缓冲机制

最初,帧缓冲区只有一个,此时帧缓冲去的读取和刷新都会有较大的效率问题。为了提升效率,在显示系统引入两个缓冲区,即双缓冲机制。

此时,GPU 会提前渲染好一帧放入一个缓冲区内,让视频控制器来读该缓冲区的数据。当下一帧渲染好后,GPU 会等待显示器的 VSync 信号的发出后,才会改变视频控制器的指针指向新的缓冲区。

卡顿的原因

CPU 与 GPU 的协同

正常情况下,iOS 系统默认刷新频率是 60Hz,即每秒会渲染 60 帧,每帧之间间隔 16.67 ms,这也是两个 VSync 信号的间隔。

当VSync 信号到来后,系统图形服务会通知当前应用,应用的主线程开始在CPU 中执行相关计算操作,然后 GPU 进行合成、渲染等一系列操作,最终 GPU 将渲染结果提交到帧缓冲区中,等待 VSync 信号到来时显示到屏幕上。

  • 正常显示

    当 Vsync 信号到来时, CPU 与 GPU 在规定时间内将渲染结果提交到了帧缓冲区。

  • 掉帧(卡顿)

    当 Vsync 信号到来时,CPU 或者 GPU 没有完成内容提交,则这一帧就会被丢弃了,等待下一次 VSync 再显示,而此时显示器上仍然保留着之前的内容不变,这就界面卡顿的原因。

因此,在做性能优化时,只要使 CPU 的计算和 GPU 的渲染能规定时间内完成,就能避免掉帧,从而解决卡顿问题。

具体原因

  • CPU

    CPU 提交计算结果是在主线程执行的,会影响 CPU 性能的因素大概有:

    • 主线程大量 I/O操作:(未考虑性能,方便coding,直接在主线程写入大量数据)
    • 主线程大量耗时操作:如图片编解码、复杂布局等
    • 过多线程抢占 CPU 资源
    • 高温导致 CPU 降频
  • GPU

    • 视图层级过多、离屏渲染
    • 大量计算以及渲染算法等等

卡顿监控

1.Xcode Instruments

在开发阶段,可直接使用内置工具 Instruments 来检测性能问题。一般使用使用 TimeProfiler 查看 CPU 相关的耗时操作,使用 Core Animation (FPS / Commits) 查看 GPU 的渲染以及提交情况。

2.主线程卡顿监控

了解了卡顿的原理,为了更好的解决卡顿问题,首先要定位到是哪些代码导致的卡顿,所以对于卡顿的监控是十分必要的。

FPS

一般情况,将 App 的 FPS 保持在 50~60 之间,用户就感觉不到卡顿。屏幕每次刷新时都会发出一个信号,通过向主线程添加 CADisplayLink 来注册一个与刷新频率相同的回调,从而统计出每秒屏幕刷新的次数。实现比较简单,可直接参考 YYFPSLabel

Objc 复制代码
@implementation YYFPSLabel 

- (instancetype)initWithFrame:(CGRect)frame {
    // 省略...		
    // 创建CADisplayLink并添加到主线程的RunLoop中
    _link = [CADisplayLink displayLinkWithTarget:[YYWeakProxy proxyWithTarget:self] selector:@selector(tick:)];
    [_link addToRunLoop:[NSRunLoop mainRunLoop] forMode:NSRunLoopCommonModes];
    return self;
}

//刷新回调时去计算fps
- (void)tick:(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;
     
    self.text = [NSString stringWithFormat:@"%d FPS",(int)round(fps)];
}

@end
  • 优点:

    直观,卡顿发生时,FPS 会明显下降,反馈及时, 适用于开发阶段及时发现问题。

  • 缺点:

    需要 CPU 空闲时才能处理, 无法采集调用堆栈

RunLoop

首先通过下图,看一下 RunLoop 运行过程中的状态变化过程。

从上图我们发现,RunLoop 基本是在 BeforceSources 和 AfterWaiting 两个状态后执行任务,因此可以在子线程中定时检测主线程中的 RunLoop 是否处于 BeforceSources 或 AfterWaiting 状态,如果一定时间后仍处于这两个状态,则可认为主线程发生了卡顿。

源码:

Objc 复制代码
//在主线程注册RunLoop观察者
- (void)registerMainRunLoopObserver {
    CFRunLoopObserverContext context = {0, (__bridge void*)self, NULL, NULL};
    self.runLoopObserver = CFRunLoopObserverCreate(kCFAllocatorDefault,
                                                   kCFRunLoopAllActivities,
                                                   YES,
                                                   0,
                                                   &runLoopObserverCallBack,
                                                   &context);
    CFRunLoopAddObserver(CFRunLoopGetMain(), self.runLoopObserver, kCFRunLoopCommonModes);
}

//RunLoop 回调方法
static void runLoopObserverCallBack(CFRunLoopObserverRef observer, CFRunLoopActivity activity, void *info)
{
    self.runLoopActivity = activity;
    //在主线程触发信号,使子线程不在阻塞
    if (self.semaphore != nil) {
        dispatch_semaphore_signal(self.semaphore);
    }
}

//创建一个子线程去监听主线程RunLoop状态
- (void)createRunLoopStatusMonitor {
    self.semaphore = dispatch_semaphore_create(0);
    if (self.semaphore == nil) {
        return;
    }
    
    //创建一个子线程,监测Runloop状态时长
    dispatch_async(dispatch_get_global_queue(0, 0), ^{
        while (YES){
            //如果观察者已经移除,则停止进行状态监测
            if (self.runLoopObserver == nil) {
                self.runLoopActivity = 0;
                self.semaphore = nil;
                return;
            }
            
            //信号量等待。状态不等于0,说明状态等待超时
        //方案一:设置单次超时时间为500毫秒
            long status = dispatch_semaphore_wait(self.semaphore, dispatch_time(DISPATCH_TIME_NOW, 500 * NSEC_PER_MSEC));
            if (status != 0) {
                if (self.runLoopActivity == kCFRunLoopBeforeSources || self.runLoopActivity == kCFRunLoopAfterWaiting) {
                    //发生超过500毫秒的卡顿,此时去记录调用栈信息
                }
            }
        /*
       //方案二:连续5次卡顿50ms上报
        long status = dispatch_semaphore_wait(semaphore, dispatch_time(DISPATCH_TIME_NOW, 50*NSEC_PER_MSEC));
        if (status != 0)
        {
            if (!observer)
            {
                timeoutCount = 0;
                semaphore = 0;
                activity = 0;
                return;
            }
            
            if (activity==kCFRunLoopBeforeSources || activity==kCFRunLoopAfterWaiting)
            {
                if (++timeoutCount < 5)
                    continue;
                //保存调用栈信息
            }
        }
        timeoutCount = 0;
        */
        }
    });
}
 
  • 优点:

    性能损耗较低,不会唤醒 RunLoop,优于 FPS;

    可及时捕获卡顿堆栈,可用于线上监控。

  • 缺点:

    无法确定卡顿的时长,定义卡顿的阈值不好控制(根据业务人为设置)。

子线程 Ping 主线程

Ping 主线程核心思想是:在子线程内,与主线程通信使某个状态在主线程改变,判断改状态发生改变后的耗时,以此判断主线程是否发生了卡顿。

Objc 复制代码
@implementation PingThread

- (void)main {
    self.semaphore = dispatch_semaphore_create(0);
    [self pingMainThread];
}

- (void)pingMainThread {
    while (!self.cancelled) {
        @autoreleasepool {
            dispatch_async(dispatch_get_main_queue(), ^{
                dispatch_semaphore_signal(self.semaphore);
            });
            
            CFAbsoluteTime pingTime = CFAbsoluteTimeGetCurrent();
            dispatch_semaphore_wait(self.semaphore, DISPATCH_TIME_FOREVER);
            if (CFAbsoluteTimeGetCurrent() - pingTime >= _threshold) {
                ......
            }
            [NSThread sleepForTimeInterval: _interval];
        }
    }
}

@end
  • 优点:

    可及时捕获卡顿堆栈;

    可以记录卡顿的时长;

  • 缺点:

    一直 ping 主线程比较消耗硬件资源,费电;

3.全局监控

堆栈回溯

由于函数的调用会发生入栈行为,如果调用栈总是停留在某个地址指令的状态,说明该地址的函数比较耗时。可通过对比两次调用栈的符号信息,如果后一次仍然包含前一次的地址符号,可认为发生了卡顿。

Objc 复制代码
@implementation StackBacktrace

- (void)main {
    [self backtraceStack];
}

- (void)backtraceStack {
    while (!self.cancelled) {
        @autoreleasepool {
            NSSet *curSymbols = //获取堆栈的符号集;
            if ([_saveSymbols isSubsetOfSet: curSymbols]) {
                //包含前一次的符号集,发生卡顿
            }
            _saveSymbols = curSymbols;
            [NSThread sleepForTimeInterval: _interval];
        }
    }
}

@end
  • 优点:

    由于符号的唯一性,直接通过调用栈的符号信息对比,准确性较高。

  • 缺点:

    需要频繁获取调用栈,对性能损耗高;

    实现难度相对较大,成本较高;

hook msgSend

主要是根据 OC 的消息机制,方法的调用最终转换成 msgSend 的调用,因此可通过 hook msgSend 方法,在函数的前后插入自定义的耗时监控函数,从而可统计每个函数的耗时。

  • 优点:

    采集精度高,覆盖面广,能准确记录每一个方法的耗时;

  • 缺点:

    整体性能损耗较高,统计了每个OC 函数;

    只适用 OC,不适用Swift 代码;

总结

本篇简要介绍了屏幕成像的原理,和发生卡顿的根本原因,并总结了 iOS 业界常见的一些卡顿监控的原理,具体选择哪种方法可根据自己项目选择最适合的即可。

参考

iOS 保持界面流畅的技巧
iOS界面卡顿原理及优化
Matrix 卡顿监控
RunLoop实战:实时卡顿监控
深入理解 RunLoop

相关推荐
苏三说技术2 小时前
Redis 性能优化的18招
数据库·redis·性能优化
贵州晓智信息科技2 小时前
如何优化求职简历从模板选择到面试准备
面试·职场和发展
百罹鸟2 小时前
【vue高频面试题—场景篇】:实现一个实时更新的倒计时组件,如何确保倒计时在页面切换时能够正常暂停和恢复?
vue.js·后端·面试
程序猿会指北4 小时前
【鸿蒙(HarmonyOS)性能优化指南】内存分析器Allocation Profiler
性能优化·移动开发·harmonyos·openharmony·arkui·组件化·鸿蒙开发
程序猿会指北7 小时前
【鸿蒙(HarmonyOS)性能优化指南】启动分析工具Launch Profiler
c++·性能优化·harmonyos·openharmony·arkui·启动优化·鸿蒙开发
古木201911 小时前
前端面试宝典
前端·面试·职场和发展
码农爱java18 小时前
设计模式--抽象工厂模式【创建型模式】
java·设计模式·面试·抽象工厂模式·原理·23种设计模式·java 设计模式
Jiude18 小时前
算法题题解记录——双变量问题的 “枚举右,维护左”
python·算法·面试
crasowas20 小时前
iOS - 超好用的隐私清单修复脚本(持续更新)
ios·app store
彭亚川Allen20 小时前
优化了2年的性能,没想到最后被数据库连接池坑了一把
数据库·后端·性能优化