前言
正在运行的 APP 突然崩溃是一件令人不快的事情,它会流失用户,影响公司发展。所以 APP 运行时拥有防崩溃功能能有效降低崩溃率,提升 APP 稳定性。但是有时候 APP 崩溃是应有的表现,不让 APP崩溃可能也会导致其他逻辑错误。不过我们可以抓取应用当前的堆栈信息并上传至相关的服务器,分析并修复这些错误。
本文介绍的 XXShield 库有两个重要的功能
- 阻止崩溃
- 捕获异常状态下的崩溃信息
类似的相关技术分析也有 网易 iOS App 运行时 Crash 自动防护实践
目前已经实现的功能
- 未识别的选择器崩溃
- KVO 崩溃
- 容器崩溃
- NSNotification 崩溃
- NSNull 崩溃
- NSTimer 崩溃
- 野指针崩溃
1 未识别的选择器崩溃
出现原因
由于 Objective-C 是动态语言,所有的消息发送都会放在运行时去解析,有时候我们把信息传递给了错误的类型,就会导致这个错误。
解决办法
Objective-C 在出现无法解析的方法时有三部曲来进行消息转发。 详见Objective-C Runtime 运行时之三:方法与消息
- 动态方法解析
- 备用接收者
- 完整转发
1 通常适用于 Dynamic 修饰的 Property,2 通常适用于将方法转发至其他对象,3 通常适用于消息可以转发多个对象,可以实现类似多继承或者转发中心的概念。
这里选择的是方案二,因为三中用到了 NSInvocation 对象,此对象性能开销较大,而且这种异常如果出现必然频次较高。最适合将消息转发到一个备用者对象上。
这里新建一个智能转发类。此对象将在其他对象无法解析信息时,返回一个 0 来防止崩溃。返回 0 是因为这个通用的智能转发类进行的操作接近向 nil 发送一个消息。
代码如下
#import <objc/runtime.h>
/**
default Implement
@param target trarget
@param cmd cmd
@param ... other param
@return default Implement is zero
*/
int smartFunction(id target, SEL cmd, ...) {
return 0;
}
static BOOL __addMethod(Class clazz, SEL sel) {
NSString *selName = NSStringFromSelector(sel);
NSMutableString *tmpString = [[NSMutableString alloc] initWithFormat:@"%@", selName];
int count = (int)[tmpString replaceOccurrencesOfString:@":"
withString:@"_"
options:NSCaseInsensitiveSearch
range:NSMakeRange(0, selName.length)];
NSMutableString *val = [[NSMutableString alloc] initWithString:@"i@:"];
for (int i = 0; i < count; i++) {
[val appendString:@"@"];
}
const char *funcTypeEncoding = [val UTF8String];
return class_addMethod(clazz, sel, (IMP)smartFunction, funcTypeEncoding);
}
@implementation XXShieldStubObject
+ (XXShieldStubObject *)shareInstance {
static XXShieldStubObject *singleton;
if (!singleton) {
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
singleton = [XXShieldStubObject new];
});
}
return singleton;
}
- (BOOL)addFunc:(SEL)sel {
return __addMethod([XXShieldStubObject class], sel);
}
+ (BOOL)addClassFunc:(SEL)sel {
Class metaClass = objc_getMetaClass(class_getName([XXShieldStubObject class]));
return __addMethod(metaClass, sel);
}
@end
我们这里需要 Hook NSObject的 - (id)forwardingTargetForSelector:(SEL)aSelector
方法来启动消息转发。很多人不知道的是如果想要转发类方法,只需要实现一个同名的类方法即可,虽然在头文件中此方法并未声明。
XXStaticHookClass(NSObject, ProtectFW, id, @selector(forwardingTargetForSelector:), (SEL)aSelector) {
// 1 如果是NSSNumber 和NSString没找到就是类型不对 切换下类型就好了
if ([self isKindOfClass:[NSNumber class]] && [NSString instancesRespondToSelector:aSelector]) {
NSNumber *number = (NSNumber *)self;
NSString *str = [number stringValue];
return str;
} else if ([self isKindOfClass:[NSString class]] && [NSNumber instancesRespondToSelector:aSelector]) {
NSString *str = (NSString *)self;
NSNumberFormatter *formatter = [[NSNumberFormatter alloc] init];
NSNumber *number = [formatter numberFromString:str];
return number;
}
BOOL aBool = [self respondsToSelector:aSelector];
NSMethodSignature *signatrue = [self methodSignatureForSelector:aSelector];
if (aBool || signatrue) {
return XXHookOrgin(aSelector);
} else {
XXShieldStubObject *stub = [XXShieldStubObject shareInstance];
[stub addFunc:aSelector];
NSString *reason = [NSString stringWithFormat:@"*****Warning***** logic error.target is %@ method is %@, reason : method forword to SmartFunction Object default implement like send message to nil.",
[self class], NSStringFromSelector(aSelector)];
[XXRecord recordFatalWithReason:reason errorType:EXXShieldTypeUnrecognizedSelector];
return stub;
}
}
XXStaticHookEnd
这里记录了崩溃信息,出现消息转发一般是一个逻辑错误,为必须修复的 Bug,上报尤为重要。
2 KVO 崩溃
出现原因
KVOCrash 总结起来有以下 2 大类。
- 不匹配的移除和添加关系。
- 观察者和被观察者释放的时候没有及时断开观察者关系。
解决办法
尼古拉斯赵四说过 :
对比到程序世界就是,程序世界没有什么难以解决的问题都是不可以通过抽象层次来解决的,如果有,那就两层。 纵观程序的架构设计,计算机网络协议分层设计,操作系统内核设计等等都是如此。
问题1:不成对的添加观察者和移除观察者会导致崩溃。以往我们使用 KVO,观察者和被观察者都是直接交互的。这里的设计方案是我们找一个 Proxy 用来做转发,真正的观察者是 Proxy,被观察者出现通知信息,由 Proxy 做分发。所以 Proxy 里面要保存一个数据结构 {keypath : [observer1, observer2,...]} 。
@interface XXKVOProxy : NSObject {
__unsafe_unretained NSObject *_observed;
}
/**
{keypath : [ob1,ob2](NSHashTable)}
*/
@property (nonatomic, strong) NSMutableDictionary<NSString *, NSHashTable<NSObject *> *> *kvoInfoMap;
@end
我们需要 Hook NSObject的 KVO 相关方法。
- (void)addObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath options:(NSKeyValueObservingOptions)options context:(void *)context;
- (void)removeObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath;
问题2: 观察者和被观察者释放时没有断开观察者关系。对于观察者,既然我们是自己用 Proxy 做的分发,我们自己就需要保存观察者,这里我们简单地使用 NSHashTable
指定指针持有策略为 weak
即可。
对于被观察者,我们使用 iOS 界的毒瘤-MethodSwizzling 一文中的方法。我们在被观察者上绑定一个关联对象,在关联对象的 dealloc 方法中做相关操作即可。
- (void)dealloc {
@autoreleasepool {
NSDictionary<NSString *, NSHashTable<NSObject *> *> *kvoinfos = self.kvoInfoMap.copy;
for (NSString *keyPath in kvoinfos) {
// call original IMP
__xx_hook_orgin_function_removeObserver(_observed,@selector(removeObserver:forKeyPath:),self, keyPath);
}
}
}
3 容器崩溃
出现原因
容器在任何编程语言中都尤为重要,因为容器是数据的载体,许多容器对容器内部放空值都做了容错处理。不幸的是,Objective-C 并没有这样做,容器插入了 nil
就会导致崩溃,容器还有另一个最容易导致崩溃的原因就是下标越界。
解决办法
常见的容器有 NS(Mutable)Array, NS(Mutable)Dictionary, NSCache 等。我们需要 hook 常见的方法以加入检测功能并捕获堆栈信息上报。
例如
XXStaticHookClass(NSArray, ProtectCont, id, @selector(objectAtIndex:),(NSUInteger)index) {
if (self.count == 0) {
NSString *reason = [NSString stringWithFormat:@"target is %@ method is %@,reason : index %@ out of count %@ of array ",
[self class], XXSEL2Str(@selector(objectAtIndex:)), @(index), @(self.count)];
[XXRecord recordFatalWithReason:reason errorType:EXXShieldTypeContainer];
return nil;
}
if (index >= self.count) {
NSString *reason = [NSString stringWithFormat:@"target is %@ method is %@,reason : index %@ out of count %@ of array ",
[self class], XXSEL2Str(@selector(objectAtIndex:)), @(index), @(self.count)];
[XXRecord recordFatalWithReason:reason errorType:EXXShieldTypeContainer];
return nil;
}
return XXHookOrgin(index);
}
XXStaticHookEnd
但需要注意的是,NSArray 是一个 Class Cluster 的抽象父类,所以我们需要 hook 到我们真正的子类。
这里给出一个辅助方法,获取一个类的所有直接子类:
+ (NSArray *)findAllOf:(Class)defaultClass {
int count = objc_getClassList(NULL, 0);
if (count <= 0) {
@throw@"Couldn't retrieve Obj-C class-list";
return @[defaultClass];
}
NSMutableArray *output = @[].mutableCopy;
Class *classes = (Class *) malloc(sizeof(Class) * count);
objc_getClassList(classes, count);
for (int i = 0; i < count; ++i) {
if (defaultClass == class_getSuperclass(classes[i]))//子类
{
[output addObject:classes[i]];
}
}
free(classes);
return output.copy;
}
// 对于NSarray :
//[NSarray array] 和 @[] 的类型是__NSArray0
//只有一个元素的数组类型 __NSSingleObjectArrayI,
// 其他的大部分是//__NSArrayI,
// 对于NSMutableArray :
//[NSMutableDictionary dictionary] 和 @[].mutableCopy__NSArrayM
// 对于NSDictionary: :
//[NSDictionary dictionary];。 @{}; __NSDictionary0
// 其他一般是 __NSDictionaryI
// 对于NSMutableDictionary: :
// 一般用到的是 __NSDictionaryM
4 NSNotification 崩溃
出现原因
在 iOS8 及以下的操作系统中添加的观察者通常需要在 dealloc 的时候进行移除,如果开发者忘记移除,则在发送通知时会导致崩溃,而在 iOS9 上即使在 dealloc 时也无需担心,猜测可能是 iOS9 之后系统将通知中心持有对象从 assign
改为了 weak
。
解决方案
因此这里有两种解决方案
- 在 KVO 的中间加上代理层,使用弱引用指针来持有对象
- 在析构时移除未被删除的观察者
我们在这里使用 iOS 界的毒瘤-MethodSwizzling 文章中介绍的方法。
5. NSNull Crashes
出现原因
虽然 Objective-C 不允许开发者将 nil 放入容器内,但另一个代表用户态 空
的类 NSNull 可以放入容器,但令人不快的是该类的实例并不能响应任何方法。
容器中出现 NSNull 通常是因为 API 接口返回了含有 null 的 JSON 数据,调用者通常将其理解为一个 NSNumber、NSString、NSDictionary 或 NSArray。这时,如果开发者没有做好准备,一旦对 NSNull 类调用任何方法,都会出现 unrecongized selector 错误。
解决方案
我们可以在 NSNull 的转发方法中判断这四种类型是否可解析。如果可以解析,则将其转发给这些对象,如果不行,则调用父类的默认实现。
XXStaticHookClass(NSNull, ProtectNull, id, @selector(forwardingTargetForSelector:), (SEL) aSelector) {
static NSArray *sTmpOutput = nil;
if (sTmpOutput == nil) {
sTmpOutput = @[@"", @0, @[], @{}];
}
for (id tmpObj in sTmpOutput) {
if ([tmpObj respondsToSelector:aSelector]) {
return tmpObj;
}
}
return XXHookOrgin(aSelector);
}
XXStaticHookEnd
6. NSTimer Crashes
出现原因
在使用 + (NSTimer *)scheduledTimerWithTimeInterval:(NSTimeInterval)ti target:(id)aTarget selector:(SEL)aSelector userInfo:(nullable id)userInfo repeats:(BOOL)yesOrNo
创建定时任务时,target 通常会持有 timer,而 timer又会持有 target 对象。如果我们没有正确关闭定时器,timer 会一直持有 target 导致内存泄漏。
解决办法
与KVO类似,由于timer和target直接交互容易出问题,我们再找一个代理将target和selctor等信息保存到Proxy中,并且使用弱引用指向target。
这样就能避免因为循环引用导致的内存泄漏。然后在触发实际target事件时,如果target被设置为nil,这时我们手动关闭定时器。
XXStaticHookMetaClass(NSTimer, ProtectTimer, NSTimer * ,@selector(scheduledTimerWithTimeInterval:target:selector:userInfo:repeats:),
(NSTimeInterval)ti , (id)aTarget, (SEL)aSelector, (id)userInfo, (BOOL)yesOrNo ) {
if (yesOrNo) {
NSTimer *timer = nil ;
@autoreleasepool {
XXTimerProxy *proxy = [XXTimerProxy new];
proxy.target = aTarget;
proxy.aSelector = aSelector;
timer.timerProxy = proxy;
timer = XXHookOrgin(ti, proxy, @selector(trigger:), userInfo, yesOrNo);
proxy.sourceTimer = timer;
}
return timer;
}
return XXHookOrgin(ti, aTarget, aSelector, userInfo, yesOrNo);
}
XXStaticHookEnd
@implementation XXTimerProxy
- (void)trigger:(id)userinfo {
id strongTarget = self.target;
if (strongTarget && ([strongTarget respondsToSelector:self.aSelector])) {
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
[strongTarget performSelector:self.aSelector withObject:userinfo];
#pragma clang diagnostic pop
} else {
NSTimer *sourceTimer = self.sourceTimer;
if (sourceTimer) {
[sourceTimer invalidate];
}
NSString *reason = [NSString stringWithFormat:@"*****Warning***** logic error target is %@ method is %@, reason : an object dealloc not invalidate Timer.",
[self class], NSStringFromSelector(self.aSelector)];
[XXRecord recordFatalWithReason:reason errorType:(EXXShieldTypeTimer)];
}
}
@end
7. 野指针崩溃
出现原因
通常在单线程条件下使用ARC时,正确处理引用关系,野指针出现的频率并不高,但在多线程下则不然,通常在一个线程中释放了对象,而另一个线程还没有更新指针状态,后续访问可能会造成随机性bug。
之所以是随机bug,是因为被回收的内存不一定立即被使用。而且崩溃位置可能与原逻辑相距甚远,因此收集的堆栈信息也可能杂乱无章,没有什么价值。具体的分类请参考Bugly整理的思维导图。
更多关于野指针的文章请参考
解决办法
这里我们可以借鉴系统NSZombies对象的设计。 参考buildNSZombie
解决过程
-
建立白名单机制,由于系统的类基本上不会出现野指针,而且对所有类进行hook会造成较大的性能开销。因此,我们只过滤开发者自定义的类。
-
hook dealloc方法:这些需要保护的类我们不让其释放,而是调用objc_desctructInstance方法释放实例所持有的属性引用和关联对象。
-
使用object_setClass(id, Class)修改isa指针,将其指向一个Proxy对象(与系统的KVO实现类似),此Proxy实现了一个与前面所述智能转发类相同的return 0的函数。
-
在Proxy对象的-code forwardInvocation:(NSInvocation *)anInvocation中收集Crash信息。
-
缓存的对象是有成本的,当缓存对象数量达到一定量时,我们将其释放(object_dispose)。
存在问题
-
延迟释放内存会造成性能浪费,所以默认缓存会造成野指针的Class实例的对象限制是50,超出之后会释放。如果这时候再次触发刚好释放掉的野指针,还是会造成Crash的。
-
在推荐使用时,如果近期没有出现野指针导致的崩溃,可以不开启此设置。如果突然出现大量野指针崩溃,可以考虑在热补丁中开启野指针防护,待收集到异常信息后,再关闭此开关。
收集信息
由于希望此库不依赖于外部包,因此并未实现响应的上报逻辑。如果需要上报信息,使用者只需自行实现 XXRecordProtocol
,并在启用 SDK 之前将其注册到 SDK 中。在实现方法中,将接收到 XXShield 内部定义的错误信息。开发者可以使用诸如 CrashLytics,友盟,bugly 等第三方库,也可以自行导出堆栈信息。
@protocol XXRecordProtocol <NSObject>
- (void)recordWithReason:(NSError * )reason userInfo:(NSDictionary *)userInfo;
@end
使用方法
示例工程
git clone [email protected]:ValiantCat/XXShield.git
cd Example
pod install
open XXShield.xcworkspace
安装
pod "XXShield"
用法
/**
注册汇报中心
@param record 汇报中心
*/
+ (void)registerRecordHandler:(id<XXRecordProtocol>)record;
/**
注册SDK,默认只要开启就打开防Crash,如果需要DEBUG关闭,请在调用处使用条件编译
本注册方式不包含EXXShieldTypeDangLingPointer类型
*/
+ (void)registerStabilitySDK;
/**
本注册方式不包含EXXShieldTypeDangLingPointer类型
@param ability ability
*/
+ (void)registerStabilityWithAbility:(EXXShieldType)ability;
/**
///注册EXXShieldTypeDangLingPointer需要传入存储类名的array,暂时请不要传入系统框架类
@param ability ability description
@param classNames 野指针类列表
*/
+ (void)registerStabilityWithAbility:(EXXShieldType)ability withClassNames:(nonnull NSArray<NSString *> *)classNames;
变更日志
单元测试
相关的单元测试位于示例工程的Test Target下,有兴趣的开发者可以自行查看。并且已经接入 TrivisCI以保证代码质量。
Bug&Feature
如果有相关的 Bug 请提 Issue。
如果觉得可以扩充新的防护类型,请提 PR 给我。
作者
ValiantCat, [email protected]
许可证
XXShield 采用 Apache-2.0 开源协议。