目录
- [Crash 分类与原理](#Crash 分类与原理 "#1-crash-%E5%88%86%E7%B1%BB%E4%B8%8E%E5%8E%9F%E7%90%86")
- [Mach 异常与 Unix Signal](#Mach 异常与 Unix Signal "#2-mach-%E5%BC%82%E5%B8%B8%E4%B8%8E-unix-signal")
- [OC 层异常捕获(NSException)](#OC 层异常捕获(NSException) "#3-oc-%E5%B1%82%E5%BC%82%E5%B8%B8%E6%8D%95%E8%8E%B7nsexception")
- [Unrecognized Selector 防护](#Unrecognized Selector 防护 "#4-unrecognized-selector-%E9%98%B2%E6%8A%A4")
- [KVO Crash 防护](#KVO Crash 防护 "#5-kvo-crash-%E9%98%B2%E6%8A%A4")
- [容器类越界/插 nil 防护](#容器类越界/插 nil 防护 "#6-%E5%AE%B9%E5%99%A8%E7%B1%BB%E8%B6%8A%E7%95%8C%E6%8F%92-nil-%E9%98%B2%E6%8A%A4")
- [野指针 Crash](#野指针 Crash "#7-%E9%87%8E%E6%8C%87%E9%92%88-crash")
- [内存管理与 OOM](#内存管理与 OOM "#8-%E5%86%85%E5%AD%98%E7%AE%A1%E7%90%86%E4%B8%8E-oom")
- [Watchdog 超时(0x8badf00d)](#Watchdog 超时(0x8badf00d) "#9-watchdog-%E8%B6%85%E6%97%B60x8badf00d")
- 后台任务被系统杀死
- [Method Swizzling 在稳定性防护中的应用](#Method Swizzling 在稳定性防护中的应用 "#11-method-swizzling-%E5%9C%A8%E7%A8%B3%E5%AE%9A%E6%80%A7%E9%98%B2%E6%8A%A4%E4%B8%AD%E7%9A%84%E5%BA%94%E7%94%A8")
- [符号化与 Crash 日志分析](#符号化与 Crash 日志分析 "#12-%E7%AC%A6%E5%8F%B7%E5%8C%96%E4%B8%8E-crash-%E6%97%A5%E5%BF%97%E5%88%86%E6%9E%90")
- [APM 与 Crash 监控体系搭建](#APM 与 Crash 监控体系搭建 "#13-apm-%E4%B8%8E-crash-%E7%9B%91%E6%8E%A7%E4%BD%93%E7%B3%BB%E6%90%AD%E5%BB%BA")
- [多线程 Crash](#多线程 Crash "#14-%E5%A4%9A%E7%BA%BF%E7%A8%8B-crash")
- [Fishhook 与系统函数 Hook](#Fishhook 与系统函数 Hook "#15-fishhook-%E4%B8%8E%E7%B3%BB%E7%BB%9F%E5%87%BD%E6%95%B0-hook")
- [线上 Crash 率治理实践](#线上 Crash 率治理实践 "#16-%E7%BA%BF%E4%B8%8A-crash-%E7%8E%87%E6%B2%BB%E7%90%86%E5%AE%9E%E8%B7%B5")
- [启动阶段 Crash 的特殊处理](#启动阶段 Crash 的特殊处理 "#17-%E5%90%AF%E5%8A%A8%E9%98%B6%E6%AE%B5-crash-%E7%9A%84%E7%89%B9%E6%AE%8A%E5%A4%84%E7%90%86")
- 堆栈回溯原理
- 卡顿监控与治理
- 磁盘与数据库稳定性
1. Crash 分类与原理
Q: iOS Crash 可以分为哪几大类?各自的产生原因是什么?
iOS Crash 主要分为三大类:
(1) OC 异常(NSException)
由 Objective-C 运行时或 Foundation 框架抛出,常见场景:
unrecognized selector sent to instance:对象收到了未实现的消息- 数组越界
objectAtIndex:超出 bounds - 字典插入 nil value 或 nil key
- KVO 移除了未注册的观察者
NSInvalidArgumentException:参数不合法
(2) Mach 异常 / Unix Signal
操作系统层面的异常,由内核产生:
| Signal | 含义 | 常见场景 |
|---|---|---|
| SIGSEGV | 访问无效内存 | 野指针、访问已释放对象 |
| SIGBUS | 总线错误,内存对齐问题 | 访问未映射地址 |
| SIGABRT | 程序主动调用 abort() | NSException 未捕获会触发 |
| SIGTRAP | 断点/陷阱指令 | __builtin_trap()、Swift fatalError |
| SIGILL | 非法指令 | 代码段损坏 |
| SIGFPE | 算术异常 | 除零 |
| SIGPIPE | 向已关闭的 socket 写数据 | 网络编程 |
(3) 被系统杀死
不属于传统意义的 Crash,但表现一致:
- Watchdog(0x8badf00d):主线程卡死超时
- Jetsam/OOM(0xd00d2bad):内存超限被系统终止
- 后台超时:后台任务未在限定时间内完成
2. Mach 异常与 Unix Signal
Q: 请描述 Mach 异常和 Unix Signal 的关系和传递流程。
传递流程
arduino
硬件异常 / 软件异常
↓
Mach 异常(内核态)
↓
Mach 异常处理 port(task/thread/host 级别)
↓
如果未处理,内核将其转换为对应的 Unix Signal
↓
Signal Handler(用户态)
↓
如果未处理,进程被终止
关键要点
- Mach 异常先于 Signal:内核先尝试通过 Mach 异常端口投递,如果没有 handler 消化,才转换成 Signal。
- 注册 Mach 异常 handler :通过
task_set_exception_ports()在 task 级别注册,或通过thread_set_exception_ports()在线程级别注册。 - 注册 Signal handler :通过
signal()或更推荐的sigaction()注册。 - 两者都注册时:先触发 Mach 异常 handler,再触发 Signal handler。
- NSException 最终也会走到 Signal :未被
@try-@catch或NSSetUncaughtExceptionHandler捕获的 OC 异常,最终调用abort()产生SIGABRT。
Mach 异常与 Signal 的映射
| Mach 异常 | Unix Signal |
|---|---|
| EXC_BAD_ACCESS | SIGSEGV / SIGBUS |
| EXC_BAD_INSTRUCTION | SIGILL |
| EXC_ARITHMETIC | SIGFPE |
| EXC_BREAKPOINT | SIGTRAP |
| EXC_SOFTWARE | SIGABRT / SIGPIPE 等 |
为什么 Crash 收集框架同时注册两者?
- Mach 异常可以获得更底层的信息(如 fault address),但不是所有异常都先经过 Mach(如
abort()直接发 signal)。 - Signal handler 可以兜底,但在某些场景下信息不完整。
- 同时注册可以覆盖更全面的 Crash 场景。
3. OC 层异常捕获(NSException)
Q: NSSetUncaughtExceptionHandler 的原理和注意事项是什么?
原理
objectivec
void MyUncaughtExceptionHandler(NSException *exception) {
NSString *name = exception.name;
NSString *reason = exception.reason;
NSArray *callStack = exception.callStackSymbols;
// 持久化保存 crash 信息
}
// 注册
NSSetUncaughtExceptionHandler(&MyUncaughtExceptionHandler);
- OC 运行时在异常未被
@try-@catch捕获时,检查是否设置了全局的 UncaughtExceptionHandler。 - 如果有,调用该 handler,传入 NSException 对象。
- handler 返回后,运行时调用
abort()终止进程。
注意事项
- Handler 会被覆盖 :多个 SDK 都可能调用
NSSetUncaughtExceptionHandler,后者覆盖前者。正确做法是保存前一个 handler 并在自己的 handler 中转发:
objectivec
static NSUncaughtExceptionHandler *previousHandler = nil;
void MyHandler(NSException *exception) {
// 自己的处理逻辑
saveException(exception);
// 转发给前一个 handler
if (previousHandler) {
previousHandler(exception);
}
}
previousHandler = NSGetUncaughtExceptionHandler();
NSSetUncaughtExceptionHandler(&MyHandler);
- handler 中不能做太多事:此时进程即将终止,信号可能不安全,应避免申请大块内存、使用锁等操作。
- 只能捕获 OC 异常:C++ 异常、Mach 异常等无法通过此方式捕获。
- callStackSymbols 不一定完整:Release 环境下符号可能被 strip,需要配合 dSYM 符号化。
4. Unrecognized Selector 防护
Q: 如何在线上防护 unrecognized selector 导致的 Crash?请描述 OC 消息转发机制。
OC 消息转发三步流程
objectivec
1. 动态方法决议(Dynamic Method Resolution)
+resolveInstanceMethod: / +resolveClassMethod:
→ 可以动态添加方法实现
↓ 返回 NO
2. 快速转发(Fast Forwarding)
-forwardingTargetForSelector:
→ 返回另一个可以处理该消息的对象
↓ 返回 nil
3. 完整转发(Normal Forwarding)
-methodSignatureForSelector:
-forwardInvocation:
→ 构造 NSInvocation 做完整转发
↓ 未处理
调用 -doesNotRecognizeSelector: → 抛出 NSException → Crash
防护方案(以 HSSafeKit 为例)
Hook forwardingTargetForSelector: 方法,在消息转发的第二步进行拦截:
objectivec
- (id)HSSafeKit_forwardingTargetForSelector:(SEL)selector {
BOOL aBool = [self respondsToSelector:@selector(selector)];
NSMethodSignature *signature = [self methodSignatureForSelector:selector];
// 如果已经有消息转发的实现(如 JSPatch),不拦截,继续走原始流程
if (aBool || signature) {
return [self HSSafeKit_forwardingTargetForSelector:selector];
} else {
// 报告异常但不 crash,返回一个动态添加了该方法的"桩对象"
reportBug(self.class, selector);
HSSafeKitObj *safeKitObj = [[HSSafeKitObj alloc] init];
[safeKitObj addAnyFunc:selector];
return safeKitObj;
}
}
关键设计细节
- 桩对象动态添加方法 :通过
class_addMethod给桩对象添加空实现,避免继续走消息转发导致死循环。 - 兼容性考量 :必须判断
methodSignatureForSelector:是否返回非 nil,因为有些框架(如 JSPatch、Aspects)依赖完整消息转发,如果提前拦截会导致这些框架失效。 - 上报但不 crash:线上记录异常信息用于排查,但不中断用户体验。
5. KVO Crash 防护
Q: KVO 常见的 Crash 场景有哪些?如何防护?
常见 Crash 场景
- 移除未注册的观察者 :
-removeObserver:forKeyPath:找不到匹配的注册信息 - 重复添加同一观察者(某些场景会导致回调多次触发或移除时 crash)
- 被观察对象 dealloc 时仍有观察者注册
- 观察者 dealloc 时未移除注册(iOS 11 以前会 crash)
防护方案:KVO 代理中间层
核心思想:维护一个 KVO 关系映射表,拦截 add/remove 操作。
objectivec
@interface KVOProxy : NSObject
@property (nonatomic, strong) NSMapTable<NSString *, NSHashTable *> *observerMap;
@end
// Hook addObserver:forKeyPath:options:context:
- (void)safe_addObserver:(NSObject *)observer
forKeyPath:(NSString *)keyPath
options:(NSKeyValueObservingOptions)options
context:(void *)context {
// 检查是否已注册,防止重复
if ([self.kvoProxy containsObserver:observer forKeyPath:keyPath]) {
return;
}
[self.kvoProxy recordObserver:observer forKeyPath:keyPath];
[self safe_addObserver:observer forKeyPath:keyPath options:options context:context];
}
// Hook removeObserver:forKeyPath:
- (void)safe_removeObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath {
if (![self.kvoProxy containsObserver:observer forKeyPath:keyPath]) {
return; // 未注册则跳过,避免 crash
}
[self.kvoProxy removeObserver:observer forKeyPath:keyPath];
[self safe_removeObserver:observer forKeyPath:keyPath];
}
注意事项
- Hook 方式有两种:(a) Swizzle NSObject 的 KVO 方法;(b) 使用 KVO 代理对象集中管理。
- 方案 (b) 更安全,但实现复杂度更高。
- 需要在被观察对象
dealloc时自动清理未移除的观察者。
6. 容器类越界/插 nil 防护
Q: 如何对 NSArray/NSMutableArray/NSDictionary 等容器类做安全防护?为什么需要 Hook 类簇的真实子类?
为什么需要 Hook 真实子类?
OC 中的 NSArray、NSDictionary 等是**类簇(Class Cluster)**设计模式,对外暴露抽象接口,内部使用不同的私有子类:
| 抽象类 | 真实子类 |
|---|---|
| NSArray | __NSArrayI(不可变)、__NSArray0(空数组)、__NSSingleObjectArrayI(单元素) |
| NSMutableArray | __NSArrayM |
| NSDictionary | __NSDictionaryI |
| NSMutableDictionary | __NSDictionaryM |
| NSString | __NSCFString、NSTaggedPointerString |
直接 Swizzle NSArray 的方法不会生效,因为实际运行时对象的类是 __NSArrayI。必须通过 NSClassFromString() 获取真实子类再进行 Swizzle。
防护示例
objectivec
+ (void)load {
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
Class class = NSClassFromString(@"__NSArrayI");
safeKit_swizzleSelector(class,
@selector(objectAtIndex:), self,
@selector(HSSafeKit_objectAtIndex:));
});
}
- (id)HSSafeKit_objectAtIndex:(NSUInteger)index {
if (index >= self.count) {
// 记录并上报,但不 crash
reportException(@"NSArray index out of bounds");
return nil;
}
return [self HSSafeKit_objectAtIndex:index];
}
常见防护点
NSArray:objectAtIndex:,objectAtIndexedSubscript:,initWithObjects:count:NSMutableArray:addObject:,insertObject:atIndex:,removeObjectAtIndex:,replaceObjectAtIndex:withObject:NSDictionary:initWithObjects:forKeys:count:NSMutableDictionary:setObject:forKey:,removeObjectForKey:NSString:substringWithRange:,characterAtIndex:,stringWithUTF8String:(NULL 检查)
7. 野指针 Crash
Q: 什么是野指针?为什么野指针 Crash 难以复现?如何检测和防护?
什么是野指针
对象被释放后,指向该内存地址的指针没有被置 nil,再次访问就是野指针访问。
为什么难以复现
对象释放后内存进入空闲池,如果该内存被立即复用(分配给新对象),访问旧指针可能:
- 刚好访问到新对象 → 行为不可预期但不一定 crash
- 内存未被复用 → SIGSEGV crash
- 内存被复用为相同类型对象 → 可能表现正常
这种随机性导致问题难以复现。
检测手段
1. Xcode Zombie Objects(开发阶段)
开启 NSZombieEnabled,对象释放后不真正回收内存,而是变成"僵尸对象"。再次访问时会打印明确的日志并 crash。
原理:
- Swizzle
dealloc,对象释放时将 isa 指针指向_NSZombie_OriginalClass - 向僵尸对象发消息时,
objc_msgSend识别到 Zombie 类,打印日志
2. Address Sanitizer (ASan)
编译时插桩,运行时检测内存越界、use-after-free 等。性能开销约 2-5x,适合开发和测试阶段。
3. Malloc Scribble
释放内存时用 0x55 填充,访问已释放内存大概率 crash,提高复现率。
4. 线上野指针防护方案
思路类似 Zombie,但更轻量:
markdown
对象 dealloc 时:
1. 不立即释放内存,放入一个延迟释放队列
2. 将 isa 替换为代理类,代理类的所有方法都上报异常
3. 延迟释放队列满(如超过 10MB)时,真正释放最早入队的对象
这样在对象被真正回收前,任何野指针访问都会被代理类拦截。
8. 内存管理与 OOM
Q: 什么是 OOM?Jetsam 机制是什么?如何监控和治理 OOM?
OOM(Out Of Memory)
iOS 没有内存交换机制(Swap),物理内存耗尽时系统直接终止进程。App 被 Jetsam 杀死时不会产生标准 Crash 日志。
Jetsam 机制
Jetsam 是 iOS/macOS 的内存压力管理系统:
- 系统维护一个进程优先级队列
- 内存压力升高时按优先级从低到高终止进程
- 优先级:后台App < 前台App < 系统进程
- 终止原因码
0xd00d2bad(FC_RES_TYPE_MEMORY)
内存警告级别
objectivec
UIApplicationDidReceiveMemoryWarningNotification
↓
didReceiveMemoryWarning
↓
如果内存未降低 → Jetsam 终止进程
如何监控 OOM
1. 排除法判断 OOM
Facebook 提出的方案:如果上次退出不是以下原因,则认为是 OOM:
diff
非 OOM 退出原因:
- 用户主动杀进程
- App 更新
- crash(有 crash 日志)
- watchdog 杀死
- 正常退出(applicationWillTerminate)
- 调试模式下被 Xcode 杀死
排除以上所有原因后 → 判定为 OOM
2. 内存水位监控
定期采样 App 内存占用:
objectivec
#import <mach/mach.h>
- (int64_t)memoryUsageInBytes {
task_vm_info_data_t vmInfo;
mach_msg_type_number_t count = TASK_VM_INFO_COUNT;
kern_return_t result = task_info(mach_task_self(),
TASK_VM_INFO,
(task_info_t)&vmInfo,
&count);
if (result == KERN_SUCCESS) {
return vmInfo.phys_footprint; // 物理内存占用
}
return 0;
}
OOM 治理方向
| 方向 | 措施 |
|---|---|
| 图片 | 使用 ImageIO 降采样加载,避免全尺寸解码;及时释放不可见图片 |
| 缓存 | 使用 NSCache(自动响应内存警告),设置合理上限 |
| 大数据 | 分页加载,避免一次加载全量数据 |
| WebView | WKWebView 独立进程,OOM 不影响宿主 |
| 内存泄漏 | 定期检测循环引用(MLeaksFinder / Instruments Leaks) |
| 监控 | 建立内存水位报警机制,超过阈值主动释放缓存 |
9. Watchdog 超时(0x8badf00d)
Q: 什么是 Watchdog 机制?如何避免和排查 Watchdog Crash?
Watchdog 机制
系统有一个看门狗线程监控主线程响应能力。如果主线程在规定时间内未完成特定回调,系统直接终止进程。
超时时限
| 场景 | 超时时间 |
|---|---|
启动(application:didFinishLaunchingWithOptions:) |
约 20 秒 |
| 前台无响应 | 约 10 秒(可能因系统版本和设备而异) |
进入后台(applicationDidEnterBackground:) |
约 5-10 秒 |
| 挂起到前台恢复 | 约 10 秒 |
终止码
Crash 日志中的 Exception Code: 0x8badf00d(读作 "ate bad food")。
排查方法
- 查看 Crash 日志中主线程堆栈:卡在哪一步
- 常见原因 :
- 启动阶段同步读取大文件
- 主线程发起同步网络请求
- 主线程等待锁(被子线程持有且长时间未释放)
- 主线程执行大量计算(如 JSON 解析超大文件)
- 数据库操作在主线程同步执行
防治方案
objectivec
// 错误:主线程同步网络请求
NSData *data = [NSData dataWithContentsOfURL:url];
// 正确:异步
dispatch_async(dispatch_get_global_queue(0, 0), ^{
NSData *data = [NSData dataWithContentsOfURL:url];
dispatch_async(dispatch_get_main_queue(), ^{
[self updateUI:data];
});
});
- 启动阶段精简同步操作,能延迟的延迟,能异步的异步
- 主线程只做 UI 操作
- 大量 I/O、计算放到子线程
- 监控主线程卡顿(见第 19 题)
10. 后台任务被系统杀死
Q: App 进入后台后可能被系统杀死的原因有哪些?如何申请额外的后台执行时间?
被杀原因
- 内存压力:后台 App 优先被 Jetsam 终止
- 后台任务超时 :
beginBackgroundTaskWithExpirationHandler:的任务未在限定时间内调用endBackgroundTask: - CPU 使用过高:后台持续占用大量 CPU 资源
- 违规后台行为:未声明后台模式却尝试执行后台操作
后台任务正确用法
objectivec
- (void)applicationDidEnterBackground:(UIApplication *)application {
__block UIBackgroundTaskIdentifier taskID = UIBackgroundTaskInvalid;
taskID = [application beginBackgroundTaskWithName:@"SaveData"
expirationHandler:^{
// 必须在 handler 中结束任务,否则系统直接杀进程
[application endBackgroundTask:taskID];
taskID = UIBackgroundTaskInvalid;
}];
dispatch_async(dispatch_get_global_queue(0, 0), ^{
[self saveDataToDisk];
// 任务完成后立即结束
[application endBackgroundTask:taskID];
taskID = UIBackgroundTaskInvalid;
});
}
常见错误
- 忘记调用
endBackgroundTask:→ 超时后系统杀进程并标记为 Watchdog 异常 expirationHandler中没有结束任务- 申请了后台任务但实际已经完成了操作,未及时结束
11. Method Swizzling 在稳定性防护中的应用
Q: Method Swizzling 的原理是什么?在稳定性防护中使用有哪些坑和最佳实践?
原理
OC 方法调用通过 objc_msgSend 查找 IMP(方法实现),Swizzling 就是交换两个方法的 IMP 指针。
交换前:
SEL_A → IMP_A
SEL_B → IMP_B
交换后:
SEL_A → IMP_B
SEL_B → IMP_A
标准实现
objectivec
void swizzle(Class class, SEL originalSEL, SEL swizzledSEL) {
Method originalMethod = class_getInstanceMethod(class, originalSEL);
Method swizzledMethod = class_getInstanceMethod(class, swizzledSEL);
// 先尝试添加(处理原方法来自父类的情况)
BOOL didAdd = class_addMethod(class,
originalSEL,
method_getImplementation(swizzledMethod),
method_getTypeEncoding(swizzledMethod));
if (didAdd) {
class_replaceMethod(class,
swizzledSEL,
method_getImplementation(originalMethod),
method_getTypeEncoding(originalMethod));
} else {
method_exchangeImplementations(originalMethod, swizzledMethod);
}
}
为什么要先 class_addMethod?
如果子类没有 override 父类方法,直接 method_exchangeImplementations 会修改父类的方法列表,影响所有子类。先 class_addMethod 确保只在当前类上操作。
最佳实践
- 在
+load中执行,配合dispatch_once:+load在类加载时调用,时机最早最安全;dispatch_once确保只执行一次。 - Swizzled 方法中调用"自己":看起来像递归但实际是调用原始实现(因为 IMP 已交换)。
- 不要在
+initialize中 Swizzle :+initialize可能被子类继承调用多次。 - 注意 Swizzle 顺序 :多个 Category 都 Swizzle 同一个方法时,
+load调用顺序取决于编译顺序。 - 线上慎用 :提供开关机制,可以远程关闭防护(例如 HSSafeKit 中的
hs_openSafeKit)。
12. 符号化与 Crash 日志分析
Q: 拿到一份 Crash 日志后,如何分析和符号化?
Crash 日志结构
yaml
Incident Identifier: xxxxxx
Hardware Model: iPhone12,1
Process: MyApp [1234]
Exception Type: EXC_BAD_ACCESS (SIGSEGV)
Exception Subtype: KERN_INVALID_ADDRESS at 0x0000000000000010
Thread 0 Crashed:
0 libobjc.A.dylib 0x1a2b3c4d5 objc_msgSend + 32
1 MyApp 0x1000abcde 0x100000000 + 703710
2 UIKitCore 0x1b2c3d4e5 -[UIViewController viewDidLoad] + 100
符号化步骤
-
获取 dSYM 文件:Xcode Archive 时自动生成,UUID 必须和二进制匹配。
-
验证 UUID 匹配:
bash
# 查看 dSYM UUID
dwarfdump --uuid MyApp.app.dSYM
# 查看二进制 UUID
dwarfdump --uuid MyApp.app/MyApp
- 使用 atos 符号化:
bash
atos -arch arm64 -o MyApp.app.dSYM/Contents/Resources/DWARF/MyApp -l 0x100000000 0x1000abcde
# 输出: -[MyViewController handleTap:] (MyViewController.m:42)
- 使用 symbolicatecrash:
bash
export DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer
symbolicatecrash MyApp.crash MyApp.app.dSYM > symbolicated.crash
关键信息分析
- Exception Type:确定 Crash 类型
- Exception Code :
0x8badf00d= Watchdog,0xd00d2bad= OOM - Crashed Thread 的堆栈:定位 Crash 发生的代码位置
- Last Exception Backtrace:OC 异常的堆栈(NSException)
- Binary Images:用于计算符号偏移
13. APM 与 Crash 监控体系搭建
Q: 如何设计一个完整的 Crash 监控体系?
三层捕获架构
yaml
┌──────────────────────────────────────┐
│ Layer 1: OC 异常捕获 │
│ NSSetUncaughtExceptionHandler │
├──────────────────────────────────────┤
│ Layer 2: Mach 异常捕获 │
│ task_set_exception_ports │
├──────────────────────────────────────┤
│ Layer 3: Unix Signal 捕获 │
│ sigaction(SIGSEGV/SIGABRT/...) │
└──────────────────────────────────────┘
捕获后的处理流程
scss
Crash 发生
↓
1. 收集上下文信息(线程堆栈、寄存器、设备信息)
↓
2. 持久化到沙盒(写磁盘,不能用 OC/高级 API,用 C 的 write())
↓
3. 下次启动读取并上报到服务端
↓
4. 服务端符号化 + 聚合 + 报警
Crash 回调中的安全限制
Crash handler 运行在**异步信号不安全(Async-Signal-Unsafe)**环境中:
- 不能使用
malloc(可能死锁,因为 malloc 内部有锁) - 不能使用 Objective-C 消息发送(
objc_msgSend可能死锁) - 不能使用
NSLog、NSString等高级 API - 应使用
write()直接写文件 - 预分配好写入 buffer
开源框架对比
| 框架 | Mach 异常 | Signal | OC 异常 | C++ 异常 | 符号化 |
|---|---|---|---|---|---|
| PLCrashReporter | ✅ | ✅ | ✅ | ❌ | 本地 |
| KSCrash | ✅ | ✅ | ✅ | ✅ | 本地+服务端 |
| Bugly | ✅ | ✅ | ✅ | ✅ | 服务端 |
| Firebase Crashlytics | ✅ | ✅ | ✅ | ✅ | 服务端 |
14. 多线程 Crash
Q: 多线程场景下有哪些常见 Crash?如何防护?
常见场景
1. 容器的非线程安全读写
objectivec
// 线程 A:写
[self.mutableArray addObject:obj];
// 线程 B:读(或同时写)
NSLog(@"%@", self.mutableArray[0]);
// 可能触发 EXC_BAD_ACCESS
2. 属性的非原子访问
nonatomic 属性在多线程读写时可能读到"半成品"指针(写入时只完成了部分字节的赋值),导致野指针。
3. CoreData 多线程访问
NSManagedObjectContext 不是线程安全的,跨线程访问会导致不可预期的行为。
防护方案
方案 1:加锁
objectivec
// 读写锁 pthread_rwlock(读多写少场景最优)
pthread_rwlock_t lock;
pthread_rwlock_init(&lock, NULL);
// 读
pthread_rwlock_rdlock(&lock);
id obj = self.array[index];
pthread_rwlock_unlock(&lock);
// 写
pthread_rwlock_wrlock(&lock);
[self.mutableArray addObject:obj];
pthread_rwlock_unlock(&lock);
方案 2:GCD 并发队列 + barrier
objectivec
dispatch_queue_t queue = dispatch_queue_create("com.app.safe",
DISPATCH_QUEUE_CONCURRENT);
// 读
dispatch_sync(queue, ^{
id obj = self.array[index];
});
// 写(barrier 确保写操作独占)
dispatch_barrier_async(queue, ^{
[self.mutableArray addObject:obj];
});
方案 3:使用线程安全容器
@synchronized(简单但性能差)- 使用
NSCache(线程安全)替代NSMutableDictionary做缓存 - CoreData 使用
performBlock:确保在正确线程操作
Thread Sanitizer (TSan)
Xcode 内置的多线程问题检测工具,编译时插桩,可以检测:
- 数据竞争(Data Race)
- 线程间通过非同步方式共享数据
开启方式:Edit Scheme → Run → Diagnostics → Thread Sanitizer
15. Fishhook 与系统函数 Hook
Q: fishhook 的原理是什么?与 Method Swizzling 有何区别?在稳定性领域有什么应用?
Method Swizzling vs fishhook
| 特性 | Method Swizzling | fishhook |
|---|---|---|
| 目标 | OC 方法(SEL → IMP) | C 函数(系统库) |
| 原理 | 交换方法列表中的 IMP | 修改 __DATA 段中的符号指针 |
| 范围 | OC 对象方法/类方法 | 动态链接的 C 函数(如 malloc、objc_msgSend) |
| 限制 | 只能 Hook OC 方法 | 只能 Hook 通过 dyld 动态绑定的函数 |
fishhook 原理
Mach-O 二进制使用 PIC(位置无关代码)调用外部函数:
markdown
代码段(__TEXT,只读)
call → 桩函数(stub)
↓
间接符号指针表(__DATA.__la_symbol_ptr,可写)
存储实际函数地址
↓
dyld 在运行时绑定真实地址
fishhook 的做法:遍历间接符号表,找到目标符号,将其指针替换为自定义函数。
稳定性应用
1. Hook objc_msgSend 做方法耗时监控
c
// 记录每个 OC 方法的调用耗时,发现卡顿方法
rebind_symbols((struct rebinding[1]){
{"objc_msgSend", my_objc_msgSend, (void *)&orig_objc_msgSend}
}, 1);
2. Hook malloc/free 做内存分配监控
追踪大内存分配,帮助定位 OOM 问题。
3. Hook NSLog 减少线上日志开销
c
static void (*orig_NSLog)(NSString *format, ...);
void my_NSLog(NSString *format, ...) {
// 线上环境直接跳过 NSLog,减少性能开销
#ifdef DEBUG
va_list args;
va_start(args, format);
orig_NSLog(format, args);
va_end(args);
#endif
}
16. 线上 Crash 率治理实践
Q: 如何系统性地降低线上 Crash 率?
Crash 率指标
erlang
Crash Rate = Crash UV / DAU × 100%
行业参考:
- 优秀:< 0.1%(千分之一)
- 合格:< 0.3%
- 需治理:> 0.5%
治理策略分层
第一层:防护兜底(短期见效)
| 防护类型 | 手段 |
|---|---|
| Unrecognized Selector | Hook forwardingTargetForSelector: |
| 容器越界/nil | Hook 容器类方法 |
| KVO | KVO 代理层 |
| NSTimer | 弱引用 target 防循环引用 |
| NSNotification | dealloc 自动 removeObserver |
第二层:根因修复(中期)
- Top Crash 排序:按影响用户数排序,优先修复 Top 10
- 分版本分析:某版本新增的 Crash 优先处理
- 分设备/系统版本:特定设备或系统的 Crash 单独适配
- 堆栈聚合:相同 Crash 合并,避免重复分析
第三层:预防机制(长期)
- CI/CD 集成静态分析(Clang Static Analyzer / Infer)
- Code Review 重点关注多线程、内存管理
- 灰度发布 + Crash 率实时监控
- A/B 实验关联 Crash 率指标
- 单元测试覆盖边界条件
17. 启动阶段 Crash 的特殊处理
Q: 如果 App 在启动阶段反复 Crash,如何处理?
难点
- 启动阶段的 Crash 用户无法操作,导致 App 完全不可用
- Crash 日志可能来不及上报
- 如果是配置/数据导致的 Crash,每次启动都会重复
连续启动 Crash 保护方案
objectivec
// 启动时记录
NSInteger crashCount = [[NSUserDefaults standardUserDefaults]
integerForKey:@"launch_crash_count"];
// 标记启动中
[[NSUserDefaults standardUserDefaults] setInteger:crashCount + 1
forKey:@"launch_crash_count"];
[[NSUserDefaults standardUserDefaults] synchronize];
if (crashCount >= 3) {
// 连续 3 次启动 crash,执行修复策略
[self performRecovery];
}
// 启动成功后(如 didBecomeActive 或首页展示后)重置计数
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, 5 * NSEC_PER_SEC),
dispatch_get_main_queue(), ^{
[[NSUserDefaults standardUserDefaults] setInteger:0
forKey:@"launch_crash_count"];
});
修复策略
- 清除缓存数据:可能是脏数据导致
- 清除 UserDefaults:可能是错误配置导致
- 重置数据库:可能是数据库损坏
- 回退到安全模式:使用最小化功能启动
- 关闭防护框架 (如 HSSafeKit):防护框架本身可能导致启动 Crash(HSSafeKit 中的
hs_openSafeKit开关设计即为此目的)
18. 堆栈回溯原理
Q: iOS Crash 时如何获取线程堆栈?有哪些回溯方式?
三种堆栈回溯方式
1. Frame Pointer 回溯(FP-Based)
ARM64 架构下,x29(FP)指向当前栈帧,栈帧中保存了上一帧的 FP 和返回地址(LR):
scss
┌─────────────────┐ 高地址
│ 上层 FP(x29) │ ← 当前 FP 指向这里
├─────────────────┤
│ 返回地址 (LR) │
├─────────────────┤
│ 局部变量 │
└─────────────────┘ 低地址
遍历链表即可回溯整个调用栈。性能高,但需要编译时不优化掉 Frame Pointer(-fno-omit-frame-pointer)。
2. DWARF Unwind
使用 DWARF 调试信息中的 .eh_frame / .debug_frame 段进行回溯。信息更准确,但性能开销大。
3. Compact Unwind
Apple 平台特有的压缩格式,__TEXT.__unwind_info 段存储,比 DWARF 更紧凑高效。
实际获取堆栈
objectivec
// OC 层(简单但信息有限)
NSArray *symbols = [NSThread callStackSymbols];
NSArray *addresses = [NSThread callStackReturnAddresses];
// C 层(更底层)
#include <execinfo.h>
void *callstack[128];
int frames = backtrace(callstack, 128);
char **symbols = backtrace_symbols(callstack, frames);
// 获取所有线程堆栈(Mach API)
thread_act_array_t threads;
mach_msg_type_number_t threadCount;
task_threads(mach_task_self(), &threads, &threadCount);
for (int i = 0; i < threadCount; i++) {
_STRUCT_MCONTEXT machineContext;
mach_msg_type_number_t stateCount = ARM_THREAD_STATE64_COUNT;
thread_get_state(threads[i], ARM_THREAD_STATE64,
(thread_state_t)&machineContext.__ss, &stateCount);
// 从 machineContext 中获取 PC、LR、FP 进行回溯
}
19. 卡顿监控与治理
Q: 如何监控主线程卡顿?常用方案有哪些?
方案 1:RunLoop Observer 监控
原理:监听 RunLoop 的状态切换,如果长时间停留在某个状态(如 kCFRunLoopBeforeSources 到 kCFRunLoopAfterWaiting),说明主线程被阻塞。
objectivec
static void runLoopObserverCallback(CFRunLoopObserverRef observer,
CFRunLoopActivity activity,
void *info) {
// 记录 RunLoop 状态变化时间
switch (activity) {
case kCFRunLoopBeforeSources:
// 开始处理事件
break;
case kCFRunLoopAfterWaiting:
// 唤醒后开始执行
break;
}
}
// 子线程定时检查状态是否长时间未变化
// 超过阈值(如 200ms)则记录堆栈
方案 2:子线程 Ping 主线程
objectivec
dispatch_async(dispatch_get_global_queue(0, 0), ^{
while (YES) {
__block BOOL responded = NO;
dispatch_async(dispatch_get_main_queue(), ^{
responded = YES;
});
[NSThread sleepForTimeInterval:0.2]; // 等待 200ms
if (!responded) {
// 主线程卡顿,抓取主线程堆栈
[self captureMainThreadStack];
}
}
});
方案 3:Hook objc_msgSend(方法级耗时统计)
通过 fishhook 替换 objc_msgSend,在前后记录时间戳,精确到每个方法的耗时。适合线下 profiling,线上开销过大。
卡顿治理方向
| 问题 | 优化方案 |
|---|---|
| 主线程 I/O | 移到子线程(网络、文件读写、数据库) |
| 主线程大量计算 | 拆分任务或移到子线程 |
| 过度布局计算 | 缓存 cell 高度,使用 estimatedRowHeight |
| 离屏渲染 | 减少 cornerRadius + masksToBounds,使用预渲染 |
| 图片解码 | 子线程预解码(SDWebImage 默认处理) |
| 锁竞争 | 减小锁粒度,避免主线程等待子线程锁 |
20. 磁盘与数据库稳定性
Q: 磁盘写入和数据库操作在稳定性方面需要注意什么?
磁盘写入
1. 沙盒空间不足
iOS 设备存储满时写入失败,需要:
- 写入前检查可用空间
- 捕获写入错误并上报
- 清理过期缓存释放空间
objectivec
NSError *error;
NSDictionary *attrs = [[NSFileManager defaultManager]
attributesOfFileSystemForPath:NSHomeDirectory()
error:&error];
uint64_t freeSpace = [attrs[NSFileSystemFreeSize] unsignedLongLongValue];
2. 原子写入
NSData writeToFile:atomically:YES 先写临时文件再 rename,确保写入的原子性。如果写入过程中 Crash,原文件不会损坏。
SQLite / CoreData 稳定性
1. 数据库损坏
原因:
- 写入过程中异常中断(crash / 断电)
- 多进程/多线程并发写入冲突
- 磁盘空间不足
防护:
sql
-- 开启 WAL 模式,提高并发性能和安全性
PRAGMA journal_mode=WAL;
-- 定期完整性检查
PRAGMA integrity_check;
2. 损坏后恢复策略
objectivec
if (![self openDatabase]) {
// 数据库损坏
// 方案 1:删除重建
[[NSFileManager defaultManager] removeItemAtPath:dbPath error:nil];
[self openDatabase];
// 方案 2:从备份恢复
[self restoreFromBackup];
// 方案 3:尝试 dump 并重建
// 使用 ".dump" 命令导出可恢复数据
}
3. CoreData 的轻量级迁移
模型升级时使用轻量级迁移,失败时降级处理:
objectivec
NSDictionary *options = @{
NSMigratePersistentStoresAutomaticallyOption: @YES,
NSInferMappingModelAutomaticallyOption: @YES
};
NSError *error;
if (![coordinator addPersistentStoreWithType:NSSQLiteStoreType
configuration:nil
URL:storeURL
options:options
error:&error]) {
// 迁移失败,删除旧数据库重建
[[NSFileManager defaultManager] removeItemAtURL:storeURL error:nil];
[coordinator addPersistentStoreWithType:NSSQLiteStoreType
configuration:nil
URL:storeURL
options:options
error:nil];
}
附录:高频追问
Q: @try-@catch 能捕获所有 Crash 吗?
不能。@try-@catch 只能捕获 OC 异常(NSException),无法捕获:
- EXC_BAD_ACCESS(野指针、内存越界)
- SIGABRT(非 NSException 触发的)
- SIGSEGV
- 其他系统级异常
而且 @try-@catch 在 ARC 下有额外开销(需要维护异常处理的 cleanup 表)。
Q: App 的 Crash 日志存储在哪里?
- 设备上:
设置 → 隐私 → 分析与改进 → 分析数据 - Xcode:
Window → Devices and Simulators → View Device Logs - 通过
MetricKit框架:iOS 13+ 可以在 App 内接收系统级 Crash 和性能数据
Q: +load 和 +initialize 的区别是什么?为什么 Swizzle 要放在 +load?
| 特性 | +load | +initialize |
|---|---|---|
| 调用时机 | dyld 加载类时(main 之前) | 类第一次收到消息时 |
| 调用次数 | 每个类/分类各调用一次 | 可能多次(子类未实现会调用父类的) |
| 线程安全 | 系统加锁保证线程安全 | 系统加锁保证线程安全 |
| 是否继承 | 不继承 | 继承 |
| 影响启动 | 过多 +load 会拖慢启动 | 惰性调用不影响启动 |
Swizzle 放在 +load 的原因:
- 时机最早,确保方法被调用前已经完成交换
- 不会被子类继承导致多次调用
+initialize有被多次调用的风险,可能导致重复 Swizzle(偶数次交换等于没交换)
Q: Swift 中的 Crash 有什么不同?
- 强制解包 nil :
let value: String = optional!,Optional 为 nil 时触发SIGTRAP - 数组越界 :Swift 数组越界直接触发
SIGTRAP(不像 OC 可以 Hook) - fatalError / preconditionFailure :直接
SIGTRAP - as! 类型转换失败
- Swift Crash 更难做运行时防护(缺少 OC 的消息转发机制),更依赖编译期检查和代码质量
最后更新: 2026-04-16