# iOS 稳定性方向常见面试题与详解
iOS 稳定性方向常见面试题与详解
目录
- Crash 分类与原理
- Mach 异常与 Unix Signal
- OC 层异常捕获(NSException)
- Unrecognized Selector 防护
- KVO Crash 防护
- 容器类越界/插 nil 防护
- 野指针 Crash
- 内存管理与 OOM
- Watchdog 超时(0x8badf00d)
- 后台任务被系统杀死
- Method Swizzling 在稳定性防护中的应用
- 符号化与 Crash 日志分析
- APM 与 Crash 监控体系搭建
- 多线程 Crash
- Fishhook 与系统函数 Hook
- 线上 Crash 率治理实践
- 启动阶段 Crash 的特殊处理
- 堆栈回溯原理
- 卡顿监控与治理
- 磁盘与数据库稳定性
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 的关系和传递流程。
传递流程
硬件异常 / 软件异常
↓
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 的原理和注意事项是什么?
原理
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 中转发:
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 消息转发三步流程
1. 动态方法决议(Dynamic Method Resolution)
+resolveInstanceMethod: / +resolveClassMethod:
→ 可以动态添加方法实现
↓ 返回 NO
2. 快速转发(Fast Forwarding)
-forwardingTargetForSelector:
→ 返回另一个可以处理该消息的对象
↓ 返回 nil
3. 完整转发(Normal Forwarding)
-methodSignatureForSelector:
-forwardInvocation:
→ 构造 NSInvocation 做完整转发
↓ 未处理
调用 -doesNotRecognizeSelector: → 抛出 NSException → Crash
防护方案
Hook forwardingTargetForSelector: 方法,在消息转发的第二步进行拦截:
- (id)safe_forwardingTargetForSelector:(SEL)selector {
BOOL aBool = [self respondsToSelector:@selector(selector)];
NSMethodSignature *signature = [self methodSignatureForSelector:selector];
// 如果已经有消息转发的实现(如 JSPatch),不拦截,继续走原始流程
if (aBool || signature) {
return [self safe_forwardingTargetForSelector:selector];
} else {
// 报告异常但不 crash,返回一个动态添加了该方法的"桩对象"
reportBug(self.class, selector);
SafeGuardStub *stub = [[SafeGuardStub alloc] init];
[stub addAnyFunc:selector];
return stub;
}
}
关键设计细节
-
桩对象动态添加方法:通过
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 操作。
@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。
防护示例
+ (void)load {
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
Class class = NSClassFromString(@"__NSArrayI");
safe_swizzleSelector(class,
@selector(objectAtIndex:), self,
@selector(safe_objectAtIndex:));
});
}
- (id)safe_objectAtIndex:(NSUInteger)index {
if (index >= self.count) {
// 记录并上报,但不 crash
reportException(@"NSArray index out of bounds");
return nil;
}
return [self safe_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,但更轻量:
对象 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)
内存警告级别
UIApplicationDidReceiveMemoryWarningNotification
↓
didReceiveMemoryWarning
↓
如果内存未降低 → Jetsam 终止进程
如何监控 OOM
1. 排除法判断 OOM
Facebook 提出的方案:如果上次退出不是以下原因,则认为是 OOM:
非 OOM 退出原因:
- 用户主动杀进程
- App 更新
- crash(有 crash 日志)
- watchdog 杀死
- 正常退出(applicationWillTerminate)
- 调试模式下被 Xcode 杀死
排除以上所有原因后 → 判定为 OOM
2. 内存水位监控
定期采样 App 内存占用:
#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 解析超大文件)
- 数据库操作在主线程同步执行
防治方案
// 错误:主线程同步网络请求
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 资源
- 违规后台行为:未声明后台模式却尝试执行后台操作
后台任务正确用法
- (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
标准实现
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调用顺序取决于编译顺序。 - 线上慎用:提供开关机制,可以通过配置中心远程关闭防护。
12. 符号化与 Crash 日志分析
Q: 拿到一份 Crash 日志后,如何分析和符号化?
Crash 日志结构
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 匹配:
# 查看 dSYM UUID
dwarfdump --uuid MyApp.app.dSYM
# 查看二进制 UUID
dwarfdump --uuid MyApp.app/MyApp
- 使用 atos 符号化:
atos -arch arm64 -o MyApp.app.dSYM/Contents/Resources/DWARF/MyApp -l 0x100000000 0x1000abcde
# 输出: -[MyViewController handleTap:] (MyViewController.m:42)
- 使用 symbolicatecrash:
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 监控体系?
三层捕获架构
┌──────────────────────────────────────┐
│ Layer 1: OC 异常捕获 │
│ NSSetUncaughtExceptionHandler │
├──────────────────────────────────────┤
│ Layer 2: Mach 异常捕获 │
│ task_set_exception_ports │
├──────────────────────────────────────┤
│ Layer 3: Unix Signal 捕获 │
│ sigaction(SIGSEGV/SIGABRT/...) │
└──────────────────────────────────────┘
捕获后的处理流程
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. 容器的非线程安全读写
// 线程 A:写
[self.mutableArray addObject:obj];
// 线程 B:读(或同时写)
NSLog(@"%@", self.mutableArray[0]);
// 可能触发 EXC_BAD_ACCESS
2. 属性的非原子访问
nonatomic 属性在多线程读写时可能读到"半成品"指针(写入时只完成了部分字节的赋值),导致野指针。
3. CoreData 多线程访问
NSManagedObjectContext 不是线程安全的,跨线程访问会导致不可预期的行为。
防护方案
方案 1:加锁
// 读写锁 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
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(位置无关代码)调用外部函数:
代码段(__TEXT,只读)
call → 桩函数(stub)
↓
间接符号指针表(__DATA.__la_symbol_ptr,可写)
存储实际函数地址
↓
dyld 在运行时绑定真实地址
fishhook 的做法:遍历间接符号表,找到目标符号,将其指针替换为自定义函数。
稳定性应用
1. Hook objc_msgSend 做方法耗时监控
// 记录每个 OC 方法的调用耗时,发现卡顿方法
rebind_symbols((struct rebinding[1]){
{"objc_msgSend", my_objc_msgSend, (void *)&orig_objc_msgSend}
}, 1);
2. Hook malloc/free 做内存分配监控
追踪大内存分配,帮助定位 OOM 问题。
3. Hook NSLog 减少线上日志开销
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 率指标
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 保护方案
// 启动时记录
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:可能是错误配置导致
- 重置数据库:可能是数据库损坏
- 回退到安全模式:使用最小化功能启动
- 关闭防护框架:防护框架本身可能导致启动 Crash,因此防护框架应设计远程开关以便紧急关闭
18. 堆栈回溯原理
Q: iOS Crash 时如何获取线程堆栈?有哪些回溯方式?
三种堆栈回溯方式
1. Frame Pointer 回溯(FP-Based)
ARM64 架构下,x29(FP)指向当前栈帧,栈帧中保存了上一帧的 FP 和返回地址(LR):
┌─────────────────┐ 高地址
│ 上层 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 更紧凑高效。
实际获取堆栈
// 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),说明主线程被阻塞。
static void runLoopObserverCallback(CFRunLoopObserverRef observer,
CFRunLoopActivity activity,
void *info) {
// 记录 RunLoop 状态变化时间
switch (activity) {
case kCFRunLoopBeforeSources:
// 开始处理事件
break;
case kCFRunLoopAfterWaiting:
// 唤醒后开始执行
break;
}
}
// 子线程定时检查状态是否长时间未变化
// 超过阈值(如 200ms)则记录堆栈
方案 2:子线程 Ping 主线程
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 设备存储满时写入失败,需要:
- 写入前检查可用空间
- 捕获写入错误并上报
- 清理过期缓存释放空间
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 / 断电)
- 多进程/多线程并发写入冲突
- 磁盘空间不足
防护:
-- 开启 WAL 模式,提高并发性能和安全性
PRAGMA journal_mode=WAL;
-- 定期完整性检查
PRAGMA integrity_check;
2. 损坏后恢复策略
if (![self openDatabase]) {
// 数据库损坏
// 方案 1:删除重建
[[NSFileManager defaultManager] removeItemAtPath:dbPath error:nil];
[self openDatabase];
// 方案 2:从备份恢复
[self restoreFromBackup];
// 方案 3:尝试 dump 并重建
// 使用 ".dump" 命令导出可恢复数据
}
3. CoreData 的轻量级迁移
模型升级时使用轻量级迁移,失败时降级处理:
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