在移动应用开发中,卡顿和卡死是最影响用户体验的问题之一。本文将深入探讨如何从零开始构建一个高性能的 iOS 卡死检测工具,包括主线程监控、堆栈收集、FPS 监控等核心技术的实现原理。
⭐️⭐️⭐️ 代码地址:https://github.com/zwcshy/LagDetector/tree/main
前言:为什么需要卡死检测?
在日常开发中,我们经常会遇到这样的场景:
- 用户反馈:"应用突然卡住了,点什么都没反应"
- 测试报告:"某个页面滑动时明显卡顿"
- 性能分析:"主线程执行时间过长,导致界面无响应"
传统的调试方法(如 Instruments)虽然强大,但无法在线上环境中实时监控。我们需要一个轻量级、低侵入性、高性能的监控工具,能够在应用运行时实时检测卡死问题,并收集详细的诊断信息。
一、整体架构设计
1.1 核心思路
卡死检测的核心思路很简单:定期检查主线程是否响应,如果超时未响应,则判定为卡死。
但实现起来并不简单,需要考虑:
- 如何在不影响主线程性能的情况下进行检测?
- 如何收集被阻塞线程的堆栈信息?
- 如何准确计算 CPU 使用率和内存占用?
1.2 架构设计
我们的工具采用模块化设计,核心组件包括:
┌─────────────────────────────────────────┐
│ LagDetector (核心控制器) │
│ ┌──────────┐ ┌──────────┐ ┌────────┐│
│ │主线程监控 │ │FPS监控 │ │堆栈收集││
│ └──────────┘ └──────────┘ └────────┘│
│ ┌──────────┐ ┌──────────┐ │
│ │系统信息 │ │报告生成 │ │
│ └──────────┘ └──────────┘ │
└─────────────────────────────────────────┘
设计原则:
- 低侵入性:单例模式,一行代码集成
- 高性能:后台线程监控,几乎无性能影响
- 可配置:丰富的配置选项,适应不同场景
二、主线程阻塞检测:信号量机制
2.1 检测原理
主线程阻塞检测的核心是信号量机制:
- (void)checkMainThread {
NSDate *checkStartTime = [NSDate date];
// 在主线程发送信号量
__weak typeof(self) weakSelf = self;
dispatch_async(dispatch_get_main_queue(), ^{
weakSelf.lastResponseTime = [NSDate date];
dispatch_semaphore_signal(weakSelf.semaphore);
});
// 等待主线程响应,超时时间为阈值
dispatch_time_t timeout = dispatch_time(DISPATCH_TIME_NOW,
(int64_t)(self.config.mainThreadBlockThreshold * NSEC_PER_SEC));
long result = dispatch_semaphore_wait(self.semaphore, timeout);
if (result != 0) {
// 主线程未响应,检测到卡死
NSTimeInterval blockDuration = [[NSDate date] timeIntervalSinceDate:checkStartTime];
// 处理卡死事件...
}
}
工作原理:
- 后台线程向主线程发送异步任务(
dispatch_async) - 主线程执行任务时发送信号量(
dispatch_semaphore_signal) - 如果主线程被阻塞,无法执行任务,信号量不会被发送
- 后台线程等待超时,判定为阻塞
2.2 为什么使用信号量?
信号量机制的优势:
- 非阻塞:后台线程可以设置超时时间
- 精确:可以精确测量阻塞时长
- 低开销:信号量操作非常轻量
2.3 定时检查机制
使用 dispatch_source_t 创建后台定时器:
dispatch_queue_t queue = dispatch_queue_create(
"com.lagdetection.mainthread.monitor",
DISPATCH_QUEUE_SERIAL
);
self.checkTimer = dispatch_source_create(
DISPATCH_SOURCE_TYPE_TIMER, 0, 0, queue
);
dispatch_source_set_timer(self.checkTimer,
DISPATCH_TIME_NOW,
(int64_t)(self.config.mainThreadCheckInterval * NSEC_PER_SEC),
(int64_t)(self.config.mainThreadCheckInterval * NSEC_PER_SEC));
dispatch_source_set_event_handler(self.checkTimer, ^{
[weakSelf checkMainThread];
});
dispatch_resume(self.checkTimer);
关键点:
- 使用串行队列,避免并发问题
- 定时器在后台线程运行,不影响主线程
- 可配置检查间隔(默认 100ms)
三、堆栈收集:Mach API 的深度应用
3.1 为什么需要 Mach API?
当主线程被阻塞时,常规的堆栈收集方法都无法使用:
NSThread.callStackSymbols:需要在主线程执行dispatch_async:主线程被阻塞,无法执行backtrace():无法获取其他线程的堆栈
解决方案:使用 Mach API 从外部读取线程状态。
3.2 Mach API 基础
Mach 是 macOS/iOS 的底层内核 API,提供了直接访问线程和进程的能力:
// 获取所有线程
thread_act_array_t threads;
mach_msg_type_number_t threadCount = 0;
kern_return_t kr = task_threads(mach_task_self(), &threads, &threadCount);
// 获取线程寄存器状态
_STRUCT_ARM_THREAD_STATE64 state;
mach_msg_type_number_t stateCount = ARM_THREAD_STATE64_COUNT;
kr = thread_get_state(mainThread, ARM_THREAD_STATE64,
(thread_state_t)&state, &stateCount);
3.3 ARM64 堆栈帧结构
理解堆栈帧结构是收集堆栈的关键。ARM64 架构的堆栈帧布局如下:
高地址
┌─────────────┐
│ 局部变量 │
├─────────────┤
│ 返回地址 │ ← FP + 8
├─────────────┤
│ 上一个 FP │ ← FP (当前帧指针)
├─────────────┤
│ ... │
└─────────────┘
低地址
寄存器说明:
__pc:程序计数器(当前执行地址)__sp:栈指针(栈顶)__fp:帧指针(当前栈帧)
3.4 堆栈遍历实现
// 获取程序计数器和帧指针
uintptr_t pc = (uintptr_t)state.__pc;
uintptr_t fp = (uintptr_t)state.__fp;
uintptr_t sp = (uintptr_t)state.__sp;
// 从帧指针开始遍历堆栈帧
uintptr_t currentFP = fp;
void *callstack[128];
int frameCount = 0;
// 添加当前 PC
callstack[frameCount++] = (void *)pc;
// 遍历堆栈帧
for (int i = 0; i < 127 && currentFP != 0; i++) {
// ARM64 堆栈帧布局:FP -> [prev FP, return address]
struct {
uintptr_t prevFP;
uintptr_t returnAddr;
} stackFrame;
// 读取堆栈帧
vm_size_t bytesRead = 0;
kr = vm_read_overwrite(mach_task_self(),
(vm_address_t)currentFP,
sizeof(stackFrame),
(vm_address_t)&stackFrame,
&bytesRead);
if (kr == KERN_SUCCESS && bytesRead == sizeof(stackFrame)) {
// 提取返回地址
if (stackFrame.returnAddr != 0) {
callstack[frameCount++] = (void *)stackFrame.returnAddr;
}
// 更新帧指针
currentFP = stackFrame.prevFP;
} else {
break; // 读取失败,停止遍历
}
}
3.5 边界检查和验证
堆栈遍历需要严格的边界检查,避免读取无效内存:
// 验证帧指针的有效性
if (currentFP == 0 ||
currentFP < stackBottom ||
currentFP > stackTop ||
currentFP == lastFP ||
currentFP <= lastFP) {
break; // 无效,停止遍历
}
检查项:
- 帧指针不能为 0
- 帧指针必须在堆栈范围内
- 帧指针必须递增(向上增长)
- 帧指针不能重复
3.6 符号化处理
收集到地址后,需要将其转换为可读的符号信息:
Dl_info info;
if (dladdr(callstack[i], &info) != 0) {
NSString *imageName = [NSString stringWithCString:info.dli_fname
encoding:NSUTF8StringEncoding];
NSString *symbolName = [NSString stringWithCString:info.dli_sname
encoding:NSUTF8StringEncoding];
uintptr_t offset = (uintptr_t)callstack[i] - (uintptr_t)info.dli_saddr;
// 格式化输出
NSString *frame = [NSString stringWithFormat:@"%d %@ 0x%lx %@ + %lu",
i, imageName, (unsigned long)callstack[i], symbolName, offset];
}
符号信息:
dli_fname:镜像文件路径dli_sname:符号名称dli_saddr:符号地址dli_fbase:镜像基址
四、FPS 监控:CADisplayLink 的妙用
4.1 CADisplayLink 简介
CADisplayLink 是 iOS 提供的帧率监控 API,它与屏幕刷新同步,每帧调用一次回调。
self.displayLink = [CADisplayLink displayLinkWithTarget:self
selector:@selector(displayLinkTick:)];
[self.displayLink addToRunLoop:[NSRunLoop mainRunLoop]
forMode:NSRunLoopCommonModes];
关键点:
- 使用
NSRunLoopCommonModes,确保在滚动时也能监控 timestamp属性提供精确的时间戳
4.2 FPS 计算
- (void)displayLinkTick:(CADisplayLink *)displayLink {
if (self.lastTimestamp == 0) {
self.lastTimestamp = displayLink.timestamp;
return;
}
self.frameCount++;
// 计算 FPS
CFTimeInterval elapsed = displayLink.timestamp - self.lastTimestamp;
if (elapsed >= 1.0) {
double fps = (double)self.frameCount / elapsed;
self.currentFPS = fps;
self.frameCount = 0;
self.lastTimestamp = displayLink.timestamp;
}
}
计算方式:
- 记录帧数
frameCount - 记录时间差
elapsed - FPS = 帧数 / 时间差
4.3 低帧率检测
定时采样 FPS,如果低于阈值则触发回调:
- (void)sampleFPS {
// 计算平均 FPS
double averageFPS = self.currentFPS;
if (self.fpsHistory.count > 0) {
double sum = 0;
for (NSNumber *fps in self.fpsHistory) {
sum += fps.doubleValue;
}
averageFPS = sum / self.fpsHistory.count;
}
// 检测低帧率
if (averageFPS < self.config.lowFPSThreshold) {
if ([self.delegate respondsToSelector:@selector(fpsMonitor:didDetectLowFPS:)]) {
[self.delegate fpsMonitor:self didDetectLowFPS:averageFPS];
}
}
}
五、系统信息收集:CPU 和内存
5.1 内存使用收集
使用 Mach API 的 task_info 获取内存信息:
struct mach_task_basic_info info;
mach_msg_type_number_t count = MACH_TASK_BASIC_INFO_COUNT;
kern_return_t result = task_info(mach_task_self(),
MACH_TASK_BASIC_INFO,
(task_info_t)&info,
&count);
if (result == KERN_SUCCESS) {
int64_t used = (int64_t)info.resident_size;
int64_t total = (int64_t)[NSProcessInfo processInfo].physicalMemory;
int64_t available = total - used;
}
5.2 CPU 使用率计算
CPU 使用率的计算需要两次采样:
// 第一次采样
double totalCPUTime1 = 0.0;
for (每个线程) {
thread_basic_info_data_t threadInfo;
thread_info(thread, THREAD_BASIC_INFO, &threadInfo, &count);
totalCPUTime1 += threadInfo.user_time + threadInfo.system_time;
}
NSTimeInterval time1 = [[NSDate date] timeIntervalSince1970];
// 等待一段时间后第二次采样
// ... (相同逻辑)
double totalCPUTime2 = ...;
NSTimeInterval time2 = ...;
// 计算使用率
double cpuUsage = (totalCPUTime2 - totalCPUTime1) / (time2 - time1);
关键点:
- 需要两次采样才能计算使用率
- 时间间隔太短会导致不准确
- 使用静态变量存储上次采样数据
六、实战应用:集成和使用
6.1 基本集成
#import "LagDetection.h"
@interface AppDelegate () <LagDetectorDelegate>
@end
@implementation AppDelegate
- (BOOL)application:(UIApplication *)application
didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
// 获取单例
LagDetector *detector = [LagDetector shared];
// 设置代理
detector.delegate = self;
// 配置
LagDetectionConfig *config = [LagDetectionConfig defaultConfig];
config.mainThreadBlockThreshold = 0.8; // 800ms
config.lowFPSThreshold = 30.0;
[detector updateConfig:config];
// 启动检测
[detector start];
return YES;
}
#pragma mark - LagDetectorDelegate
- (void)lagDetector:(LagDetector *)detector didDetectLag:(LagInfo *)lagInfo {
NSLog(@"检测到卡死: %.2fs", lagInfo.duration);
NSLog(@" 开始时间: %@", lagInfo.timestamp);
NSLog(@" 当前FPS: %.1f", lagInfo.currentFPS);
NSLog(@" 设备: %@", lagInfo.systemInfo.deviceModel);
NSLog(@" 系统版本: %@", lagInfo.systemInfo.systemVersion);
NSLog(@" 内存使用: %.2f MB / %.2f MB (可用: %.2f MB)",
lagInfo.systemInfo.memoryUsage.used / (1024.0 * 1024.0),
lagInfo.systemInfo.memoryUsage.total / (1024.0 * 1024.0),
lagInfo.systemInfo.memoryUsage.available / (1024.0 * 1024.0));
NSLog(@" CPU使用: %.2f%%", lagInfo.systemInfo.cpuUsage * 100.0);
NSLog(@" 线程堆栈:\n%@", lagInfo.stackTraces);
// 转换为字典用于上报
NSDictionary *dict = [lagInfo toDictionary];
// 上报到服务器
YCLOG_WARNING(@"lag_detection", @"performance", [dict mj_JSONString]);
}
@end
6.2 配置选项
工具提供了丰富的配置选项:
LagDetectionConfig *config = [LagDetectionConfig defaultConfig];
// 主线程阻塞阈值(秒)
config.mainThreadBlockThreshold = 0.5;
// FPS 阈值
config.lowFPSThreshold = 50.0;
// 主线程检查间隔(秒)
config.mainThreadCheckInterval = 0.1;
// FPS 采样间隔(秒)
config.fpsSampleInterval = 1.0;
// 是否收集堆栈信息
config.enableStackTrace = YES;
// 堆栈深度
config.stackTraceDepth = 20;
// 是否启用日志
config.enableLogging = YES;
6.3 预设配置
工具提供了三种预设配置:
默认配置(平衡性能和精度):
LagDetectionConfig *config = [LagDetectionConfig defaultConfig];
高性能配置(降低采样频率):
LagDetectionConfig *config = [LagDetectionConfig performanceConfig];
高精度配置(提高采样频率):
LagDetectionConfig *config = [LagDetectionConfig accuracyConfig];
七、性能优化和注意事项
7.1 性能开销
经过测试,工具的性能开销非常小:
- CPU 开销:<1%
- 内存开销:<5MB(包括报告缓存)
- 主线程影响:几乎无影响(使用异步机制)
7.2 优化策略
- 后台线程监控:主线程监控在后台线程执行
- 可配置采样频率:根据需求调整检查间隔
- 堆栈深度限制:限制堆栈收集深度,避免过度开销
- 报告缓存限制:最多保存 100 条报告,避免内存泄漏
7.3 注意事项
- 不要过于频繁检查:建议检查间隔 >= 50ms
- 堆栈深度适中:建议 10-30 帧
- 生产环境建议:使用高性能配置,降低采样频率
八、实际案例:解决卡死问题
8.1 问题场景
用户反馈:某个页面滑动时明显卡顿,有时甚至完全卡死。
8.2 问题排查
集成卡死检测工具后,我们收集到了以下信息:
检测到卡死: 0.85s
FPS: 12.5
设备: iPhone 12
系统版本: iOS 15.0
内存使用: 450.2 MB / 4096.0 MB
CPU使用: 85.3%
堆栈信息:
0 YCMath-iOS 0x100123456 -[ViewController tableView:cellForRowAtIndexPath:] + 123
1 UIKit 0x180234567 -[UITableView _createPreparedCellForGlobalRow:] + 456
2 UIKit 0x180345678 -[UITableView _updateVisibleCellsNow:] + 789
...
8.3 问题定位
从堆栈信息可以看出,问题出在 cellForRowAtIndexPath: 方法中。进一步分析发现,该方法中进行了大量的图片解码操作,导致主线程阻塞。
8.4 解决方案
优化方案:
- 使用异步图片加载
- 图片解码移到后台线程
- 使用图片缓存
优化后的效果:
- 卡死次数:从每天 100+ 次降低到 0 次
- 平均 FPS:从 35 提升到 58
- 用户体验:明显改善
九、总结和思考
9.1 技术要点
通过这个项目,我们深入了解了:
- Mach API:底层系统 API 的强大能力
- 信号量机制:如何检测线程阻塞
- 堆栈帧结构:理解程序执行流程
- 性能监控:如何在不影响性能的情况下监控性能
9.2 设计思考
- 低侵入性:单例模式,一行代码集成
- 高性能:后台线程监控,几乎无性能影响
- 可配置:丰富的配置选项,适应不同场景
- 可追溯:完整的堆栈信息,便于问题定位
9.3 未来改进
- GPU 监控:添加 GPU 使用率监控
- 网络监控:集成网络请求监控
- 自动化分析:使用机器学习分析卡死模式
- 可视化界面:提供可视化的性能分析界面
十、参考资料
作者:思考快慢
GitHub:[github.com/zwcshy/LagD…]
如果这篇文章对你有帮助,欢迎点赞、分享和关注!如果你有任何问题或建议,欢迎在评论区留言讨论。