性能监控-①卡顿监控

卡顿监控

Instrument

一、卡顿的原因

卡顿的原因:掉帧。

通常设备以60Hz的频率刷新屏幕,即每16.67毫秒刷新一次。如果在16.67ms内帧缓冲区没有准备好下一帧数据就会使画面停留在上一帧,造成丢帧,没法达到60fps,从而形成卡顿的现象。

掉帧的原因:卡顿造成的原因是CPU或GPU耗时导致的掉帧引起的:

  • 主线程在进行大量I/O操作:直接主线程写入大量数据
  • 主线程进行大量计算:主线程进行大量复杂的计算
  • 大量UI绘制:界面过于复杂,绘制UI需要大量的时间
  • 主线程在等锁

帧的创建与交互过程:

在CPU中计算显示内容,包括视图的创建、布局计算、图片解码、文本绘制等。然后CPU将计算好的内容提交到GPU,由GPU进行变换、合成、渲染,并将渲染结果提交到帧缓冲区,等待下一次VSync信号到来时显示到屏幕上。

理想情况下每次 V-Sync 发生时,CPU 以及 GPU 都已经完成了对图像的处理以及绘制,显示器可以直接拿到缓冲区中的帧。即 V-Sync 会按这种方式工作:

但是,如果 CPU 或者 GPU 的处理需要的时间较长,就会发生掉帧的问题,即在 V-Sync 信号发出时,CPU 和 GPU 并没有准备好需要渲染的帧,显示器就会继续使用当前帧。

根据 V-Sync 的原理,优化应用性能、提高 App 的 FPS 就可以从两个方面来入手,优化 CPU 以及 GPU 的处理时间。

帧的量化

每段16.67ms,其实是一个VSync信号,该信号用于保证只有在帧缓冲区中的图像被渲染之后,后备缓冲区中的内容才可以被拷贝到帧缓冲区中

3视图-更新机制.md

在VSync信号到来后,系统图形服务通过CADisplayLink等机制通知App,App主线程开始在CPU中计算显示内容,包括视图的创建、布局计算、图片解码、文本绘制等。然后CPU将计算好的内容提交到GPU,由GPU进行变换、合成、渲染,并将渲染结果提交到帧缓冲区,等待下一次VSync信号到来时显示到屏幕上

iOS默认刷新频率是60HZ,所以GPU渲染只要达到60fps就不会产生卡顿。

只要能使CPU的计算和GPU的渲染能在规定时间内完成,就不会出现卡顿。所以目标是减少CPU和GPU的资源消耗。

一、如何监控卡顿

以下内容摘自:iOS-Monitor-Platform 项目中的 如何监控卡顿

那怎么监控应用的卡顿情况?通常有以下两种方案

  • FPS 监控:这是最容易想到的一种方案,如果帧率越高意味着界面越流畅,上文也给出了计算 FPS 的实现方式,通过一段连续的 FPS 计算丢帧率来衡量当前页面绘制的质量。

  • 主线程卡顿监控:这是业内常用的一种检测卡顿的方法,通过开辟一个子线程来监控主线程的 RunLoop,当两个状态区域之间的耗时大于阈值时,就记为发生一次卡顿。美团的移动端性能监控方案 Hertz 采用的就是这种方式

    主线程卡顿监控的实现思路:开辟一个子线程,然后实时计算 kCFRunLoopAfterWaitingkCFRunLoopBeforeSources 两个状态区域之间的耗时是否超过某个阀值,来断定主线程的卡顿情况。计算的是kCFRunLoopAfterWaiting(被唤醒)到kCFRunLoopBeforeSources(开始处理UI)的时间,而不是kCFRunLoopBeforeSources到kCFRunLoopAfterWaiting的时间

    这个时间代表了 RunLoop 被唤醒后,到真正开始执行 UI 事件的延迟

    计算的是 kCFRunLoopAfterWaiting 到 kCFRunLoopBeforeSources 的时间!


    📌 为什么卡顿计算的是 kCFRunLoopAfterWaiting → kCFRunLoopBeforeSources 的时间?

    🔹 这个时间代表了 RunLoop 被唤醒后,到真正开始执行 UI 事件的延迟。

    如果这个时间超过 0.1s(即 100ms),说明主线程可能被卡住了,导致 UI 响应变慢掉帧


    📌 为什么不是 kCFRunLoopBeforeSources → kCFRunLoopAfterWaiting?

    ❌ 这个时间不是衡量卡顿的关键。

    kCFRunLoopBeforeSources 之后,RunLoop 进入 UI 事件 & 业务逻辑执行阶段,这个时间长短取决于你的代码复杂度。例如:

    ​ • UI 事件的回调

    ​ • 复杂的计算任务

    ​ • 耗时操作(如同步网络请求、数据库操作等)

    但是,这部分时间长不一定意味着“卡顿”,因为:

    ​ 1. 执行业务逻辑本来就可能需要时间

    ​ 2. 如果业务逻辑复杂,可以通过异步优化,但它本身并不是卡顿的判断标准

    ​ 3. 真正的问题是 RunLoop 被唤醒后,为什么 UI 事件迟迟没有开始执行?答:📌 核心原因:主线程在 kCFRunLoopAfterWaiting 之后被其他任务阻塞,导致无法立即执行 UI 事件(Source0)。*

    其他参考文章:iOS 性能监控(二)—— 主线程卡顿监控

更详细内容推荐查看项目中md文档。

二、FPS

FPS”通常指的是”Frames Per Second”,即每秒传输帧数,用于衡量动画或视频渲染的性能。

一般情况下,我们的屏幕刷新率是 1/60s 一次。CADisplayLink 实际上跟平常用的 NSTimer 的用法基本相似,NSTimer 的时间间隔是以秒为单位,而 CADisplayLink 则是使用帧率来作为时间间隔的单位。

计算核心:CADisplayLink是一个能让我们以和屏幕刷新率相同的频率将内容画到屏幕上的定时器。

实践代码:CQDemoKit 的 CQTSFPSView.m

附:其他定时器

iOS:三种常见计时器(NSTimer、CADisplayLink、dispatch_source_t)的使用

1、NSTimer:进入后台时停止

iOS开发:解决App进入后台,倒计时(定时器)不能正常计时的问题

方法一(主流方法):根据记录开始的时间和获取当前时间进行时间差操作进行处理。监听进入前台、进入后台的消息,在进入后台的时候存一下时间戳,停掉定时器(系统会强制停止定时器);在再进入前台时,计算时间差。若剩余的时间大于时间差,就减去时间差,否则赋值剩余时间为0。

方法二:苹果只允许三种情况下的App在后台可以一直执行:音视频、定位更新、下载,若是直播、视频播放、地图类、有下载的应用可以这样使用,但是有些小需求就不需这样做。

方法三:通过向苹果的系统申请,在后台完成一个Task任务。

2、NSTimer:滑动时停止

2内存-②循环引用Timer.md》 中的 【3、NSTimer和NSRunLoop的关系?】

iOS卡顿优化

https://github.com/dvlproad/CJOptimizeProject/blob/main/CJOptimizeProject/TSDemo_Optimize/FPS/TSOptimizeTableViewController.m

我们写个列表,计算高度时候模拟耗时,且加载图片时候不使用缓存。代码示例如下:

image-20241027010604427

则经在Instrument中跑起来可查出耗时的位置即在上述①②。

image-20241027010515562

点击进入也能看到详细的时间分布

image-20241027015737011

二、Leak

https://github.com/dvlproad/CJOptimizeProject/blob/main/CJOptimizeProject/TSDemo_Optimize/Leak/TSLeakViewController.m

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 内存泄漏示例--循环引用导致的
- (void)leakMemoryCircularRreference:(UIButton *)button {
//__weak typeof(self) weakSelf = self;
//__strong __typeof(self) strongSelf = weakSelf;

TSLeakViewController *viewController = [[TSLeakViewController alloc] init];
if ([button.titleLabel.text containsString:@"会泄露"]) {
viewController.leakBlock = ^(NSString * _Nonnull title) {
viewController.title = title; // 会泄露
};
} else {
__weak typeof(viewController) weakViewController = viewController;
viewController.leakBlock = ^(NSString * _Nonnull title) {
//self.title = title; // 不会泄露
weakViewController.title = title; // 不会泄露,使得在 Block 中不会出现对 自身 的强引用

//__strong __typeof(viewController) strongViewController = weakViewController;
//strongViewController.title = title; // 不会泄露
};
}
[self.navigationController pushViewController:viewController animated:YES];
}

ios Instruments之Allocations

ios_alloction_01

Flutter FPS

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
Widget buildList(BuildContext context) {
return ListView.builder(
itemCount: 1000,
itemBuilder: (context, index) {
preBuildItem(index); // 模拟耗时操作,例如使用延时

int imageIndex = index % 10;
String imageName = "assets/images/cqts_${imageIndex + 1}.jpg";
return Container(
color: index % 2 == 0 ? Colors.red : Colors.blue,
child: Row(
children: [
JankText("Item $index"),
Image(
image: AssetImage(imageName),
width: 100,
height: 100,
),
],
),
);
},
);

通过进出测试FPS页面,看到有红色区域,点击看到BUILD时长很长,理论上应该是一群绘制,结果在绘制前有一堆控的时间。所以点击BUILD段分析,其中有标记⑦的方法耗时97.28%。即为我们设置进去模拟卡顿的地方。

image-20241029012336176

BUILD分析完已找出第一个造成卡顿的方法。我们继续往BUILD里的JankText分析,发现其也是在绘制Text前有很长一段被其他占用的方法。至此两处耗时操作都找出来了。

image-20241029012559483

内存

image-20241029015507146

flutter_leak_memory_01 image-20241029015611284 image-20241029015635482

通过比较进入后的列表页面的内存情况 main-3 与 未进入前的首页的内存情况 main-2,得到进入到列表后,增加的内存为如下:

image-20241029015741757

通过比较从列表页返回的首页内存情况 main-4 与 未进入前的首页的内存情况 main-2,得到返回到首页后,内存无增加,即正常全部释放:

image-20241029015830539

启动优化

App Launch

image-20241030221807138

寻找卡顿的切入点

监控卡顿,最直接就是找到主线程都在干些啥玩意儿。

我们知道一个线程的消息事件处理都是依赖于NSRunLoop来驱动,所以要知道线程正在调用什么方法,就需要从NSRunLoop来入手

参考文章

iOS 性能监控(一)—— CPU功耗监控

iOS 性能监控(三)—— 方法耗时监控

iOS-卡顿监测-FPS监测

iOS实时卡顿检测-RunLoop(附实例)