Files
knowledge-kit/Chapter1 - iOS/1.148.md
2026-01-11 00:09:24 +08:00

586 lines
28 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 移动端的“安全气垫”
> 安全气垫是端上一个老生常谈的话题,技术方案已经是好多年前的产物。唯一的问题是不同公司对于安全气垫在拦截到异常的时候,“做与不做”策略的判断不同。比如数组索引越界的时候要返回一个错误位置的值吗?会不会产生业务异常?要一刀切吗?那么本文就尝试回答下这个问题
## 一、一个经典的场景
Weex 源码中,设计了一个 `WXThreadSafeMutableDictionary`。在字典操作的地方使用锁:
```Objective-C
- (void)setObject:(id)anObject forKey:(id<NSCopying>)aKey
{
id originalObject = nil; // make sure that object is not released in lock
@try {
pthread_mutex_lock(&_safeThreadDictionaryMutex);
originalObject = [_dict objectForKey:aKey];
[_dict setObject:anObject forKey:aKey];
}
@finally {
pthread_mutex_unlock(&_safeThreadDictionaryMutex);
}
originalObject = nil;
}
```
这么写的价值:**解锁逻辑「绝对执行」,彻底避免死锁**
这是 `@try-finally` 最核心的价值 ——无论 try 块内发生什么(正常执行、提前 return、抛异常finally 块的解锁逻辑一定会执行
对比无 try-finally 的写法
```Objective-C
// Bad: 若setObject抛异常unlock不会执行→死锁
pthread_mutex_lock(&_mutex);
[_dict setObject:anObject forKey:aKey];
pthread_mutex_unlock(&_mutex);
```
问题:`[_dict setObject:anObject forKey:aKey]` 可能抛异常(比如 aKey = nil 时会触发 NSInvalidArgumentException若没有 finally锁会被永久持有→其他线程调用 lock 时死锁,整个字典无法再操作。
设计优点:
- `@try-finallly`:即使 try 内逻辑出错finally 也会执行 pthread_mutex_unlock保证锁最终释放这是**线程安全的「兜底保障」**
- 注意,不是 `try...catch...finally`: 如果加了 catch 逻辑,则字典的 key 为 nil 产生的崩溃也会被捕获掉,这属于不符合预期的行为。因为 key 为 nil 产生的原因太多了,可能是业务代码异常,也可能是数据异常,也可能是逻辑错误,如果一刀切直接用 `try...catch...finally` 捕获了异常,但是没有配置异常的收集、上报、处理逻辑,属于边界不清晰,本质是为了解决加解锁不匹配而可能带来的线程安全问题,却"多管闲事",把字典 key 为 nil 本该向上跑的异常而卡住了。
这是一个经典的策略问题,端上的异常发生时,安全气垫的“做与不做”问题
聊聊类似网易的大白解决方案或者业界其他公司中,安全气垫虽然保证了代码不 crash影响用户体验但是比如数组本该越界现在却不越界
1. 唯一能做的就是返回一个错误的值比如数组长度为3访问4现在不 crash返回了 0 的值,那是不是产生了业务异常?比如商品价格
2. 不 crash也不返回错误位置的值类似给一个回调告诉业务方出现了异常可以做一些业务层面的提醒或者配置(比如开发阶段商品卡片的价格 Label 显示:商品价格获取错误,数组越界),同时产生的异常案发现场信息和其他的一些数据会上报,用于 APM 平台去分析和定位。
但这也产生一个问题类似数组越界的场景可能10000次里面9999次都正常只有1次异常业务开发为了这万分之一出现的异常还需要写一些异常处理的逻辑比如商品卡片展示价格获取错误数组越界。那字典的 key 为 nil 呢除法的分母为0呢诸如此类类似乐观锁和悲观锁的场景
## 二、核心原则
要解决「安全气垫防崩溃但引发隐性业务异常」「低概率异常导致业务开发冗余逻辑」的核心矛盾,业界的优雅方案核心思路是:「环境差异化策略」+「分层兜底 + 语义化默认值」+「可观测驱动的轻量处理」,既避免线上 Crash又最小化业务侵入同时保证问题可被发现和修复。
**开发阶段让问题 “炸出来”,生产阶段让问题 “软落地”**。从源头减少线上低概率异常的发生,业务开发无需为 “万分之一” 的异常写冗余逻辑
| 环境 | 核心目标 | 策略 |
| ----------- | ---------------------------------- | ----------------------------------- |
| 开发 / 测试 | 提前暴露问题,杜绝上线 | 「零容忍」:直接 Crash + 详细上下文 |
| 生产 | 避免 Crash + 可观测 + 最小业务影响 | 「软兜底」:语义化默认值 + 全量上报 |
## 三、多个方案
### 方案1:环境差异化 + 开发强感知(网易大白/腾讯 Bugly 核心思路)
对 NSArray、NSDictionary、NSNumber 等基础类做运行时 Hook区分环境处理异常
NSArray+DWSafeHook.m
```Objective-C
// 核心逻辑:
// 1. Method Swizzling Hook数组核心读写方法
// 2. Debug环境越界直接Crash+详细上下文,强制研发修复
// 3. Release环境拦截崩溃+上报异常+返回语义化空值,避免用户感知
//
#import "NSArray+DWSafeHook.h"
#import <objc/runtime.h>
#import "DWEnvironmentUtils.h" // 环境判断工具类(自研)
#import "DWCrashReporter.h" // 崩溃上报工具类(自研)
@implementation NSArray (DWSafeHook)
#pragma mark - 对外入口初始化Hook
+ (void)dw_setupSafeHook {
// 防止重复Hook
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
// Hook 只读数组核心方法
[self dw_swizzleInstanceMethod:@selector(objectAtIndex:)
withNewMethod:@selector(dw_safe_objectAtIndex:)];
[self dw_swizzleInstanceMethod:@selector(objectAtIndexedSubscript:)
withNewMethod:@selector(dw_safe_objectAtIndexedSubscript:)];
// Hook 可变数组核心方法(写操作)
[NSMutableArray dw_swizzleInstanceMethod:@selector(addObject:)
withNewMethod:@selector(dw_safe_addObject:)];
[NSMutableArray dw_swizzleInstanceMethod:@selector(insertObject:atIndex:)
withNewMethod:@selector(dw_safe_insertObject:atIndex:)];
[NSMutableArray dw_swizzleInstanceMethod:@selector(removeObjectAtIndex:)
withNewMethod:@selector(dw_safe_removeObjectAtIndex:)];
});
}
#pragma mark - 私有工具Method Swizzling封装
/// 通用Swizzling方法避免重复代码
/// @param originalSEL 原方法SEL
/// @param newSEL 替换后的方法SEL
+ (void)dw_swizzleInstanceMethod:(SEL)originalSEL withNewMethod:(SEL)newSEL {
Class cls = [self class];
// 获取原方法和新方法
Method originalMethod = class_getInstanceMethod(cls, originalSEL);
Method newMethod = class_getInstanceMethod(cls, newSEL);
if (!originalMethod || !newMethod) {
NSLog(@"[DWCrashGuard] Swizzling失败方法不存在 originalSEL: %@, class: %@",
NSStringFromSelector(originalSEL), NSStringFromClass(cls));
return;
}
// 尝试添加新方法(防止原方法未实现)
BOOL isAdded = class_addMethod(cls,
originalSEL,
method_getImplementation(newMethod),
method_getTypeEncoding(newMethod));
if (isAdded) {
// 替换原方法实现
class_replaceMethod(cls,
newSEL,
method_getImplementation(originalMethod),
method_getTypeEncoding(originalMethod));
} else {
// 交换方法实现
method_exchangeImplementations(originalMethod, newMethod);
}
}
#pragma mark - Hook实现只读数组读操作核心防越界
/// 拦截 [array objectAtIndex:] 越界
- (id)dw_safe_objectAtIndex:(NSUInteger)index {
// 安全校验:索引越界判断
if (index >= self.count) {
[self dw_handleArrayOutOfBoundsWithIndex:index method:@"objectAtIndex:"];
return [NSNull null]; // Release环境返回语义化空值而非0
}
// 正常逻辑调用原方法Swizzle后此处实际是原objectAtIndex:
return [self dw_safe_objectAtIndex:index];
}
/// 拦截 array[index] 下标访问越界
- (id)dw_safe_objectAtIndexedSubscript:(NSUInteger)index {
if (index >= self.count) {
[self dw_handleArrayOutOfBoundsWithIndex:index method:@"objectAtIndexedSubscript:"];
return [NSNull null];
}
return [self dw_safe_objectAtIndexedSubscript:index];
}
#pragma mark - 私有工具:数组越界统一处理
/// 数组越界异常处理(区分环境)
/// @param index 访问的索引
/// @param method 触发异常的方法名
- (void)dw_handleArrayOutOfBoundsWithIndex:(NSUInteger)index method:(NSString *)method {
// 1. 构建异常上下文(用于上报/调试)
NSDictionary *context = @{
@"crashType": @"NSArrayOutOfBounds",
@"method": method,
@"arrayCount": @(self.count),
@"accessIndex": @(index),
@"arrayDescription": [self description], // 数组内容(便于定位)
@"callStack": [NSThread callStackSymbols], // 完整调用栈
@"timestamp": @([[NSDate date] timeIntervalSince1970]),
@"deviceInfo": [DWEnvironmentUtils deviceInfo] // 设备型号/系统版本等
};
// 2. 区分环境处理
if ([DWEnvironmentUtils isDebugEnvironment]) {
// Debug环境直接Crash+详细日志,强制研发修复
NSString *errorMsg = [NSString stringWithFormat:
@"【网易大白】NSArray越界崩溃\n"
@"方法:%@\n"
@"数组长度:%lu访问索引%lu\n"
@"数组内容:%@\n"
@"调用栈:%@",
method, self.count, index, self, [NSThread callStackSymbols]];
NSAssert(NO, @"%@", errorMsg);
abort(); // 确保CrashNSAssert在Release下失效
} else {
// Release环境拦截崩溃+上报APM平台
[DWCrashReporter reportCrashWithType:@"NSArrayOutOfBounds"
context:context
severity:DWCrashSeverityLow]; // 低优先级(万分之一概率)
NSLog(@"[DWCrashGuard] 拦截NSArray越界%@, count:%lu, index:%lu", method, self.count, index);
}
}
@end
#pragma mark - 可变数组Hook实现写操作防护
@implementation NSMutableArray (DWSafeHook)
/// 拦截 addObject:nil 崩溃
- (void)dw_safe_addObject:(id)anObject {
if (anObject == nil) {
// 构建异常上下文
NSDictionary *context = @{
@"crashType": @"NSMutableArrayAddNil",
@"callStack": [NSThread callStackSymbols],
@"timestamp": @([[NSDate date] timeIntervalSince1970])
};
if ([DWEnvironmentUtils isDebugEnvironment]) {
// Debug环境Crash+提示
NSAssert(NO, @"【网易大白】NSMutableArray添加nil对象调用栈%@", [NSThread callStackSymbols]);
abort();
} else {
// Release环境拦截+上报忽略nil添加
[DWCrashReporter reportCrashWithType:@"NSMutableArrayAddNil"
context:context
severity:DWCrashSeverityLow];
NSLog(@"[DWCrashGuard] 拦截NSMutableArray添加nil对象");
return;
}
}
// 正常逻辑:调用原方法
[self dw_safe_addObject:anObject];
}
/// 拦截 insertObject:atIndex: 越界/nil
- (void)dw_safe_insertObject:(id)anObject atIndex:(NSUInteger)index {
// 1. 校验nil
if (anObject == nil) {
[self dw_handleMutableArrayNilObjectWithMethod:@"insertObject:atIndex:"];
return;
}
// 2. 校验越界
if (index > self.count) { // insert允许index == count追加
[self dw_handleArrayOutOfBoundsWithIndex:index method:@"insertObject:atIndex:"];
return;
}
// 正常逻辑
[self dw_safe_insertObject:anObject atIndex:index];
}
/// 拦截 removeObjectAtIndex: 越界
- (void)dw_safe_removeObjectAtIndex:(NSUInteger)index {
if (index >= self.count) {
[self dw_handleArrayOutOfBoundsWithIndex:index method:@"removeObjectAtIndex:"];
return;
}
// 正常逻辑
[self dw_safe_removeObjectAtIndex:index];
}
#pragma mark - 私有工具可变数组nil处理
- (void)dw_handleMutableArrayNilObjectWithMethod:(NSString *)method {
NSDictionary *context = @{
@"crashType": @"NSMutableArrayInsertNil",
@"method": method,
@"callStack": [NSThread callStackSymbols]
};
if ([DWEnvironmentUtils isDebugEnvironment]) {
NSAssert(NO, @"【网易大白】NSMutableArray插入nil对象方法%@,调用栈:%@", method, [NSThread callStackSymbols]);
abort();
} else {
[DWCrashReporter reportCrashWithType:@"NSMutableArrayInsertNil"
context:context
severity:DWCrashSeverityLow];
NSLog(@"[DWCrashGuard] 拦截NSMutableArray插入nil对象%@", method);
}
}
@end
```
简化下,逻辑基本为:
```Objective-C
// 伪代码Hook NSArray的objectAtIndex:
- (id)safe_objectAtIndex:(NSUInteger)index {
if (index >= self.count) {
// 开发环境Crash + 打印完整的案发现场信息 + 调用栈 + 上下文(数组内容、访问索引、业务模块)
#ifdef DEBUG
NSAssert(NO, @"数组越界:数组长度%lu访问索引%lu调用栈%@", self.count, index, [NSThread callStackSymbols]);
abort();
#else
// 生产环境返回语义化空值而非0+ 上报APM
[APMManager reportExceptionWithType:@"数组越界"
context:@{@"arrayCount": @(self.count), @"index": @(index), @"callStack": [NSThread callStackSymbols]}];
return [NSNull null]; // 而非0业务层可统一识别比如价格展示为: '--'
#endif
}
return [self original_objectAtIndex:index];
}
```
核心优势:
- 开发环境:不仅 Crash还打印「业务数据上下文」比如当前是商品详情页、数组是价格数组开发一眼定位问题
- 生产环境返回NSNull而非无意义的 0业务层只需做一次全局 UI 处理(比如所有 Label 展示时判断值为NSNull则显示 “--”),无需为每个场景写逻辑
覆盖场景:
- 数组越界返回NSNull
- 字典 key 为 nilsetObject:forKey:时忽略 nil key 并上报objectForKey:时返回NSNull
- 分母为 0返回INFINITY全局工具类判断isinf(),统一返回 “--”);
- 字符串转数字失败返回NSNull而非 0
### 方案2:声明式全局兜底 + 业务按需关注(阿里/字节)
避免业务层 “零散处理异常”,而是**全局统一兜底 + 业务选择性注册关注的异常类型**。
- 全局层面:所有基础类异常返回 NSNullUI 层统一处理 NSNull 为 “--”/“获取失败”;
- 业务层面:仅对 “核心场景”(如商品价格、支付金额)注册异常回调,非核心场景无需处理:
WXExceptionManager.h
```Objective-C
#import <Foundation/Foundation.h>
NS_ASSUME_NONNULL_BEGIN
/// 异常类型枚举(替代字符串,避免硬编码)
typedef NS_ENUM(NSUInteger, WXExceptionType) {
WXExceptionTypeArrayOutOfBounds, // 数组越界
WXExceptionTypeDictionaryNilKey, // 字典nil key
WXExceptionTypeDivideByZero, // 分母为0
};
/// 异常上下文Key定义统一常量避免拼写错误
FOUNDATION_EXTERN NSString *const WXExceptionContextBizModuleKey; // 业务模块
FOUNDATION_EXTERN NSString *const WXExceptionContextArrayCountKey;// 数组长度
FOUNDATION_EXTERN NSString *const WXExceptionContextAccessIndexKey;// 访问索引
FOUNDATION_EXTERN NSString *const WXExceptionContextCallStackKey; // 调用栈
FOUNDATION_EXTERN NSString *const WXExceptionContextExtraKey; // 扩展信息
@interface WXExceptionManager : NSObject
/// 单例(全局唯一)
+ (instancetype)sharedManager;
/// 注册异常回调
/// @param type 异常类型
/// @param handler 回调主线程执行避免UI操作崩溃
- (void)registerCallbackForType:(WXExceptionType)type handler:(void(^)(NSDictionary *context))handler;
/// 上报异常内部Hook调用业务层无需调用
/// @param type 异常类型
/// @param context 异常上下文
- (void)reportExceptionWithType:(WXExceptionType)type context:(NSDictionary *)context;
@end
NS_ASSUME_NONNULL_END
```
WXExceptionManager.m
```Objective-C
#import "WXExceptionManager.h"
#import <pthread/pthread.h>
// 上下文Key常量定义
NSString *const WXExceptionContextBizModuleKey = @"bizModule";
NSString *const WXExceptionContextArrayCountKey = @"arrayCount";
NSString *const WXExceptionContextAccessIndexKey = @"accessIndex";
NSString *const WXExceptionContextCallStackKey = @"callStack";
NSString *const WXExceptionContextExtraKey = @"extra";
@interface WXExceptionManager ()
/// 存储不同异常类型的回调key: WXExceptionType的NSNumbervalue: 回调数组)
@property (nonatomic, strong) NSMutableDictionary<NSNumber *, NSMutableArray<void(^)(NSDictionary *)> *> *callbackDict;
/// 线程安全锁
@property (nonatomic, assign) pthread_mutex_t mutex;
@end
@implementation WXExceptionManager
+ (instancetype)sharedManager {
static WXExceptionManager *instance = nil;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
instance = [[self alloc] init];
});
return instance;
}
- (instancetype)init {
self = [super init];
if (self) {
_callbackDict = [NSMutableDictionary dictionary];
// 初始化线程锁
pthread_mutex_init(&_mutex, NULL);
}
return self;
}
/// 注册异常回调
- (void)registerCallbackForType:(WXExceptionType)type handler:(void (^)(NSDictionary *))handler {
if (!handler) return;
pthread_mutex_lock(&_mutex);
// 按异常类型分组存储回调
NSNumber *typeKey = @(type);
if (!_callbackDict[typeKey]) {
_callbackDict[typeKey] = [NSMutableArray array];
}
[_callbackDict[typeKey] addObject:handler];
pthread_mutex_unlock(&_mutex);
}
/// 上报异常(触发回调+APM上报
- (void)reportExceptionWithType:(WXExceptionType)type context:(NSDictionary *)context {
// 1. APM平台上报模拟实际对接公司APM如Bugly/云监控)
NSLog(@"【APM上报】异常类型%lu上下文%@", type, context);
// 2. 触发注册的回调主线程执行避免UI操作崩溃
dispatch_async(dispatch_get_main_queue(), ^{
pthread_mutex_lock(&self->_mutex);
NSNumber *typeKey = @(type);
NSArray *handlers = self->_callbackDict[typeKey];
pthread_mutex_unlock(&self->_mutex);
for (void(^handler)(NSDictionary *) in handlers) {
if (handler) {
handler(context);
}
}
});
}
- (void)dealloc {
pthread_mutex_destroy(&_mutex);
}
#pragma mark - 类方法封装(简化调用)
+ (void)registerCallbackForType:(WXExceptionType)type handler:(void (^)(NSDictionary *))handler {
[[self sharedManager] registerCallbackForType:type handler:handler];
}
+ (void)reportExceptionWithType:(WXExceptionType)type context:(NSDictionary *)context {
[[self sharedManager] reportExceptionWithType:type context:context];
}
@end
```
使用的地方
```Objective-C
- (void)viewDidLoad {
[super viewDidLoad];
self.view.backgroundColor = [UIColor whiteColor];
// 1. 初始化价格标签
self.priceLabel = [[UILabel alloc] initWithFrame:CGRectMake(100, 200, 200, 40)];
self.priceLabel.font = [UIFont systemFontOfSize:18];
[self.view addSubview:self.priceLabel];
// 2. 模拟业务数据价格数组长度3索引0-2
self.priceArray = @[@"99.9", @"199.9", @"299.9"];
// 标记数组所属业务模块(关键:用于回调筛选)
self.priceArray.bizModule = @"goodsPrice";
// 3. 注册数组越界异常回调(仅关注价格模块)
[WXExceptionManager registerCallbackForType:WXExceptionTypeArrayOutOfBounds handler:^(NSDictionary *context) {
// 筛选:仅处理“价格数组”的越界异常
if ([context[WXExceptionContextBizModuleKey] isEqualToString:@"goodsPrice"]) {
NSLog(@"【业务降级】商品价格数组越界触发UI兜底");
// 生产环境:展示友好提示(而非错误值)
self.priceLabel.text = @"价格获取失败";
// 可选:触发其他降级逻辑(如隐藏价格、显示默认价)
[self triggerPriceFallback];
}
}];
// 4. 模拟异常场景访问索引3越界
[self testPriceArrayOutOfBounds];
}
/// 模拟价格数组越界访问
- (void)testPriceArrayOutOfBounds {
// 访问索引3数组长度3正常索引0-2
id price = [self.priceArray objectAtIndex:3];
// 正常场景显示价格异常场景price是NSNull显示兜底
if ([price isKindOfClass:[NSNull class]]) {
self.priceLabel.text = @"价格获取失败";
} else {
self.priceLabel.text = [NSString stringWithFormat:@"¥%@", price];
}
}
/// 价格降级逻辑(可选)
- (void)triggerPriceFallback {
// 比如:隐藏优惠券、显示默认包邮等
NSLog(@"【降级】隐藏优惠券模块,显示默认包邮文案");
}
@end
```
核心优势:
- 99% 的低概率异常由全局兜底处理(返回 NSNull+UI 显示 --
- 仅核心业务场景(价格、支付)需写少量回调逻辑,避免冗余
效果,类似针对核心的业务场景做专项化监控和优化。
### 方案 3静态分析 + CI/CD 前置拦截(从源头消灭异常)
比运行时 Hook 更优雅的是 **“提前拦截”**。通过静态分析工具Clang Static Analyzer、OCLint、自定义 LLVM 插件),在代码提交/编译阶段检测出:
- 数组越界风险如array[index]中 index 未做长度校验)
- 字典 key 为 nil如dict[nil]
- 分母为 0如a / b中 b 未判 0
- 强制类型转换风险(如(NSNumber *)nil
落地方式:
- 集成到 CI/CD Pipeline代码提交时触发静态分析有风险则阻断合入
- 通过编写 LLVM 插件,就可以在 Xcode 有问题的代码上实时提示 “数组越界风险”“字典 key 可能为 nil”。比如
<img src="https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets/LLVMClangPluginUseInXcode.png" style="zoom:25%">
效果:线上低概率异常的根源被提前消灭,问题无法逃逸到现线上,业务层几乎无需处理这类异常(因为代码本身就没有漏洞)
关于如何编写 LLVM 插件,做到在 Xcode 中实时展示代码中存在的问题,可以查看:[LLVM 插件](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.102.md) 这篇文章。
### 方案 4轻量级熔断 / 降级(适合核心业务场景)
对于 “万分之一” 但影响大的异常(如支付金额计算、商品价格),采用「熔断策略」:
第一次出现异常:上报 + 降级为默认值(如价格显示 “--”);
短时间内多次出现(如 1 分钟内 > 3 次):触发熔断,建议:展示兜底 UI提示安抚用户稍等片刻后尝试起码不会发生稳定的 crash 或者业务异常)
熔断状态上报 APM研发收到告警后优先修复。
### 方案 5语义化默认值 + 业务无感知适配(美团 / 饿了么实践)
返回**业务可识别的语义化空值**,并在 UI 层做统一适配:
| 异常场景 | 兜底值 | UI 层统一处理 | 业务层感知 |
| ---------------- | -------- | --------------- | ---------- |
| 数组越界 | NSNull | 显示 “--” | 无 |
| 字典 key 为 nil | NSNull | 显示 “暂无数据” | 无 |
| 分母为 0 | INFINITY | 显示 “计算异常” | 无 |
| 字符串转数字失败 | NSNull | 显示 “--” | 无 |
**实现方式**
- 封装全局 UI 工具类(如`WXUILabel+Safe.h`),重写`setText:`方法:
```objective-c
- (void) safe_setText:(id)text {
if (text == [NSNull null] || text == nil) {
self.text = @"--";
} else if ([text isKindOfClass:[NSNumber class]] && isinf([text doubleValue])) {
self.text = @"计算异常";
} else {
self.text = [text description];
}
}
```
业务层只需使用`safe_setText:`,无需为每个异常场景写判断逻辑。
## 四、最佳实践
1. **开发环境零容忍**:通过静态分析、自定义 LLVM 插件 + 运行时 Hook让问题在测试阶段暴露比如异常发生时通过日志打印案发现场数据或者在 Xcode 面板上可视化的在有问题的代码地方显示 error及时 crash 掉,阻塞正常流程,以防止开发偷懒不去及时修复,产生技术债,同时也防止问题逃逸到线上。
2. **生产环境软兜底**:不要一刀切,做到可配置。业务方在配置平台可以针对不同业务域的配置,决定是否开启兜底展示(某些业务域的架构师可能更严格,需要开发在 debug 阶段解决这些问题,线上如果出现就统计为 crash。如果开启那么安全气垫就返回语义化默认值而非无意义的 0全局 UI 统一处理,业务层零侵入;
3. **可观测驱动修复**APM 平台统计异常频率 / 影响度,优先处理高频高影响的异常;
4. **核心场景按需关注**:仅对价格、支付等核心场景注册回调,非核心场景无需处理。
虽然机制和策略已经制定了但是执行还是靠人难以保证100%解决问题。所以开发解决通过 lint 和 crash 可以拦截到大部分问题,但是逃逸到线上的部分问题,还是需要安全气垫去 cover线下无限毕竟去清空问题 + 少数逃逸到线上的问题通过安全气垫去 cover 就可以保证相对全面的安全守护。
## 五、平台做些什么
获取到 问题数据是第一步,获取之后问题需要及时上报,需要有一个数据上报系统,数据及时准确、高效的上传到服务端(这就是另一个命题,可以查看这篇文章:[打造一个通用、可配置、多句柄的数据上报 SDK](https://github.com/FantasticLBP/knowledge-kit/blob/master/Chapter1%20-%20iOS/1.80.md)),到 APM 后台。后台产生工单,根据业务模块分配到对应的责任人、群组。根据问题的紧急程度,报警策略和提醒频次也不一样,比如**波动报警、自定义报警策略等(这也是另一个命题:智能报警策略、波动报警策略、业务线自定义可配置责任人等)**。责任人及时解决问题、修改工单状态、热修复或者发布新版本,问题闭环。
线上异常上报后APM 平台需提供:
- 异常频率(如 “数组越界” 仅 1/10000
- 影响用户数(如仅影响 10 个用户);
- 业务上下文(如 “商品详情页 - 价格数组”);
- 调用栈 + 设备信息。
**核心逻辑**
- 低频率、低影响的异常(如 1/10000影响 10 用户):暂时无需业务层处理,研发排期修复即可;
- 高频率、高影响的异常(如 1/100影响 1000 用户):触发告警,研发紧急修复,业务层临时加兜底。
这样业务开发无需为 “万分之一” 的异常写逻辑,仅需处理 “高频高影响” 的异常。
## 总结
“最佳实践”部分选择的方案既避免了 “安全气垫返回错误值导致业务异常”,又解决了 “业务开发为低概率异常写冗余逻辑” 的问题,是网易、腾讯、阿里等大厂的通用实践 ——**优雅的方案不是 “兜底所有异常”,而是 “让异常可被提前发现、可被观测、最小化影响”**,即使少数逃逸到线上的,再由安全气垫去兜底解决(解决了展示问题、异常上报和案发现场还原问题),一个好的 APM 平台不光是告诉你又问题,还会告诉你哪里有问题。