mirror of
https://github.com/NohamR/knowledge-kit.git
synced 2026-05-25 04:17:17 +00:00
feature: Weex APM
This commit is contained in:
585
Chapter1 - iOS/1.148.md
Normal file
585
Chapter1 - iOS/1.148.md
Normal file
@@ -0,0 +1,585 @@
|
||||
# 安全气垫
|
||||
|
||||
> 安全气垫是端上一个老生常谈的话题,技术方案已经是好多年前的产物。唯一的问题是不同公司对于安全气垫在拦截到异常的时候,“做与不做”策略的判断不同。比如数组索引越界的时候要返回一个错误位置的值吗?会不会产生业务异常?要一刀切吗?那么本文就尝试回答下这个问题
|
||||
|
||||
## 一、一个经典的场景
|
||||
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(); // 确保Crash(NSAssert在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 为 nil:setObject:forKey:时忽略 nil key 并上报,objectForKey:时返回NSNull;
|
||||
- 分母为 0:返回INFINITY(全局工具类判断isinf(),统一返回 “--”);
|
||||
- 字符串转数字失败:返回NSNull而非 0
|
||||
|
||||
### 方案2:声明式全局兜底 + 业务按需关注(阿里/字节)
|
||||
避免业务层 “零散处理异常”,而是**全局统一兜底 + 业务选择性注册关注的异常类型**。
|
||||
- 全局层面:所有基础类异常返回 NSNull,UI 层统一处理 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的NSNumber,value: 回调数组)
|
||||
@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="./../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 平台不光是告诉你又问题,还会告诉你哪里有问题。
|
||||
|
||||
Reference in New Issue
Block a user