前言
正在運(yùn)行的 APP 突然 Crash,是一件令人不爽的事,會(huì)流失用戶,影響公司發(fā)展,所以 APP 運(yùn)行時(shí)擁有防 Crash 功能能有效降低 Crash 率,提升 APP 穩(wěn)定性。但是有時(shí)候 APP Crash 是應(yīng)有的表現(xiàn),我們不讓 APPCrash 可能會(huì)導(dǎo)致別的邏輯錯(cuò)誤,不過我們可以抓取到應(yīng)用當(dāng)前的堆棧信息并上傳至相關(guān)的服務(wù)器,分析并修復(fù)這些 BUG。
所以本文介紹的 XXShield 庫有兩個(gè)重要的功能:
- 防止Crash
- 捕獲異常狀態(tài)下的崩潰信息
類似的相關(guān)技術(shù)分析也有 網(wǎng)易iOS App運(yùn)行時(shí)Crash自動(dòng)防護(hù)實(shí)踐
目前已經(jīng)實(shí)現(xiàn)的功能
- Unrecoginzed Selector Crash
- KVO Crash
- Container Crash
- NSNotification Crash
- NSNull Crash
- NSTimer Crash
- 野指針 Crash
1 Unrecoginzed Selector Crash
出現(xiàn)原因
由于 Objective-C 是動(dòng)態(tài)語言,所有的消息發(fā)送都會(huì)放在運(yùn)行時(shí)去解析,有時(shí)候我們把一個(gè)信息傳遞給了錯(cuò)誤的類型,就會(huì)導(dǎo)致這個(gè)錯(cuò)誤。
解決辦法
Objective-C 在出現(xiàn)無法解析的方法時(shí)有三部曲來進(jìn)行消息轉(zhuǎn)發(fā)。
詳見Objective-C Runtime 運(yùn)行時(shí)之三:方法與消息
- 動(dòng)態(tài)方法解析
- 備用接收者
- 完整轉(zhuǎn)發(fā)
1 一般適用與 Dynamic 修飾的 Property
2 一般適用與將方法轉(zhuǎn)發(fā)至其他對象
3 一般適用與消息可以轉(zhuǎn)發(fā)多個(gè)對象,可以實(shí)現(xiàn)類似多繼承或者轉(zhuǎn)發(fā)中心的概念。
這里選擇的是方案二,因?yàn)槿锩嬗玫搅?NSInvocation 對象,此對象性能開銷較大,而且這種異常如果出現(xiàn)必然頻次較高。最適合將消息轉(zhuǎn)發(fā)到一個(gè)備用者對象上。
這里新建一個(gè)智能轉(zhuǎn)發(fā)類。此對象將在其他對象無法解析數(shù)據(jù)時(shí),返回一個(gè) 0 來防止 Crash。返回 0 是因?yàn)檫@個(gè)通用的智能轉(zhuǎn)發(fā)類做的操作接近向 nil 發(fā)送一個(gè)消息。
代碼如下
#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
方法啟動(dòng)消息轉(zhuǎn)發(fā)。
很多人不知道的是如果想要轉(zhuǎn)發(fā)類方法,只需要實(shí)現(xiàn)一個(gè)同名的類方法即可,雖然在頭文件中此方法并未聲明。
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 userinfo:nil errorType:EXXShieldTypeUnrecognizedSelector];
return stub;
}
}
XXStaticHookEnd
這里匯報(bào)了 Crash 信息,出現(xiàn)消息轉(zhuǎn)發(fā)一般是一個(gè) logic 錯(cuò)誤,為必須修復(fù)的Bug,上報(bào)尤為重要。
2 KVO Crash
出現(xiàn)原因
KVOCrash總結(jié)下來有以下2大類。
- 不匹配的移除和添加關(guān)系。
- 觀察者和被觀察者釋放的時(shí)候沒有及時(shí)斷開觀察者關(guān)系。
解決辦法
尼古拉斯趙四說過 :趙四
對比到程序世界就是,程序世界沒有什么難以解決的問題都是不可以通過抽象層次來解決的,如果有,那就兩層。
縱觀程序的架構(gòu)設(shè)計(jì),計(jì)算機(jī)網(wǎng)絡(luò)協(xié)議分層設(shè)計(jì),操作系統(tǒng)內(nèi)核設(shè)計(jì)等等都是如此。
問題1 : 不成對的添加觀察者和移除觀察者會(huì)導(dǎo)致 Crash,以往我們使用 KVO,觀察者和被觀察者都是直接交互的。這里的設(shè)計(jì)方案是我們找一個(gè) Proxy 用來做轉(zhuǎn)發(fā), 真正的觀察者是 Proxy,被觀察者出現(xiàn)了通知信息,由 Proxy 做分發(fā)。所以 Proxy 里面要保存一個(gè)數(shù)據(jù)結(jié)構(gòu) {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 相關(guān)方法。
- (void)addObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath options:(NSKeyValueObservingOptions)options context:(void *)context;
- (void)removeObserver:(NSObject *)observer forKeyPath:(NSString *)keyPath;
-
在添加觀察者時(shí)
addObserver
- 在移除觀察者時(shí)
問題2: 觀察者和被觀察者釋放的時(shí)候沒有斷開觀察者關(guān)系。
對于觀察者, 既然我們是自己用 Proxy 做的分發(fā),我們自己就需要保存觀察者,這里我們簡單的使用 NSHashTable
指定指針持有策略為 weak
即可。
對于被觀察者,我們使用 iOS 界的毒瘤-MethodSwizzling
一文中到的方法。我們在被觀察者上綁定一個(gè)關(guān)聯(lián)對象,在關(guān)聯(lián)對象的 dealloc 方法中做相關(guān)操作即可。
- (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 Container Crash
出現(xiàn)原因
容器在任何編程語言中都尤為重要,容器是數(shù)據(jù)的載體,很多容器對容器放空值都做了容錯(cuò)處理。不幸的是 Objective-C 并沒有,容器插入了 nil
就會(huì)導(dǎo)致 Crash,容器還有另外一個(gè)最容易 Crash 的原因就是下標(biāo)越界。
解決辦法
常見的容器有 NS(Mutable)Array , NS(Mutable)Dictionary, NSCache 等。我們需要 hook 常見的方法加入檢測功能并且捕獲堆棧信息上報(bào)。
例如
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 userinfo:nil 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 userinfo:nil errorType:EXXShieldTypeContainer];
return nil;
}
return XXHookOrgin(index);
}
XXStaticHookEnd
但是需要注意的是 NSArray 是一個(gè) Class Cluster 的抽象父類,所以我們需要 Hook 到我們真正的子類。
這里給出一個(gè)輔助方法,獲取一個(gè)類的所有直接子類:
+ (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
//只有一個(gè)元素的數(shù)組類型 __NSSingleObjectArrayI,
// 其他的大部分是//__NSArrayI,
// 對于NSMutableArray :
//[NSMutableDictionary dictionary] 和 @[].mutableCopy__NSArrayM
// 對于NSDictionary: :
//[NSDictionary dictionary];。 @{}; __NSDictionary0
// 其他一般是 __NSDictionaryI
// 對于NSMutableDictionary: :
// 一般用到的是 __NSDictionaryM
4 NSNotification Crash
出現(xiàn)原因
在 iOS8 及以下的操作系統(tǒng)中添加的觀察者一般需要在 dealloc 的時(shí)候做移除,如果開發(fā)者忘記移除,則在發(fā)送通知的時(shí)候會(huì)導(dǎo)致 Crash,而在 iOS9 上即使移忘記除也無所謂,猜想可能是 iOS9 之后系統(tǒng)將通知中心持有對象由 assign
變?yōu)榱?code>weak。
解決辦法
所以這里兩種解決辦法
- 類似 KVO 中間加上 Proxy 層,使用 weak 指針來持有對象
- 在 dealloc 的時(shí)候?qū)⑽幢灰瞥挠^察者移除
這里我們使用 iOS 界的毒瘤-MethodSwizzling
一文中到的方法。
5 NSNull Crash
出現(xiàn)原因
雖然 Objecttive-C 不允許開發(fā)者將 nil 放進(jìn)容器內(nèi),但是另外一個(gè)代表用戶態(tài) 空
的類 NSNull 卻可以放進(jìn)容器,但令人不爽的是這個(gè)類的實(shí)例,并不能響應(yīng)任何方法。
容器中出現(xiàn) NSNull 一般是 API 接口返回了含有 null 的 JSON ?數(shù)據(jù),
調(diào)用方通常將其理解為 NSNumber,NSString,NSDictionary 和 NSArray。 這時(shí)開發(fā)者如果沒有做好防御 一旦對 NSNull 這個(gè)類型調(diào)用任何方法都會(huì)出現(xiàn) unrecongized selector 錯(cuò)誤。
解決辦法
我們在 NSNull 的轉(zhuǎn)發(fā)方法中可以判斷?上面的四種類型是否可以解析。如果可以解析直接將其轉(zhuǎn)發(fā)給?這幾種對象,如果不能則調(diào)用父類的默認(rèn)實(shí)現(xiàn)。
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 Crash
出現(xiàn)原因
在使用 + (NSTimer *)scheduledTimerWithTimeInterval:(NSTimeInterval)ti target:(id)aTarget selector:(SEL)aSelector userInfo:(nullable id)userInfo repeats:(BOOL)yesOrNo
創(chuàng)建定時(shí)任務(wù)的時(shí)候,target? 一般都會(huì)持有 timer,timer又會(huì)持有 target 對象,在我們沒有正確關(guān)閉定時(shí)器的時(shí)候,timer 會(huì)一直持有target 導(dǎo)致內(nèi)存泄漏。
解決辦法
同 KVO 一樣,既然 timer 和 target 直接交互容易出現(xiàn)問題,我們就再找個(gè)代理將 target 和 selctor 等信息保存到 Proxy 里,并且是弱引用 target。
這樣避免因?yàn)檠h(huán)引用造成的內(nèi)存泄漏。然后在觸發(fā)真正 target 事件的時(shí)候如果 target 置為 nil 了這時(shí)候手動(dòng)去關(guān)閉定時(shí)器。
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 userinfo:nil errorType:(EXXShieldTypeTimer)];
}
}
@end
7. 野指針 Crash
出現(xiàn)原因
一般在單線程條件下使用 ARC 正確的處理引用關(guān)系野指針出現(xiàn)的并不頻繁, 但是多線程下則不盡然,通常在一個(gè)線程中釋放了對象,?另外一個(gè)線程還沒有更新指針狀態(tài) 后續(xù)訪問就可能會(huì)造成隨機(jī)性 bug。
之所以是隨機(jī) bug 是因?yàn)楸换厥盏膬?nèi)存不一定立馬被使用。而且崩潰的位置可能也與原來的邏輯相聚很遠(yuǎn),因此收集的堆棧信息也可能是雜亂無章沒有什么價(jià)值。
具體的分類請看Bugly整理的腦圖。
更多關(guān)于野指針的文章請參考:
解決辦法
這里我們可以借用系統(tǒng)的NSZombies對象的設(shè)計(jì)。
參考buildNSZombie
解決過程
建立白名單機(jī)制,由于系統(tǒng)的類基本不會(huì)出現(xiàn)野指針,而且 hook 所有的類開銷較大。所以我們只過濾開發(fā)者自定義的類。
hook dealloc 方法 這些需要保護(hù)的類我們并不讓其釋放,而是調(diào)用objc_desctructInstance 方法釋放實(shí)例內(nèi)部所持有屬性的引用和關(guān)聯(lián)對象。
利用 object_setClass(id,Class) 修改 isa 指針將其指向一個(gè)Proxy 對象(類比?系統(tǒng)的 KVO 實(shí)現(xiàn)),此 Proxy 實(shí)現(xiàn)了一個(gè)和前面所說的智能轉(zhuǎn)發(fā)類一樣的
return 0
的函數(shù)。在 Proxy 對象內(nèi)的
- (void)forwardInvocation:(NSInvocation *)anInvocation
中收集 Crash 信息。緩存的對象是有成本的,我們在緩存對象到達(dá)一定數(shù)量時(shí)候?qū)⑵溽尫?object_dispose)。
存在問題
延遲釋放內(nèi)存會(huì)造成性能浪費(fèi),所以默認(rèn)緩存會(huì)造成野指針的Class實(shí)例的對象限制是50,超出之后會(huì)釋放,如果這時(shí)候再此觸發(fā)了剛好釋放掉的野指針,還是會(huì)造成Crash的,
建議使用的時(shí)候如果近期沒有野指針的Crash可以不必開啟,如果野指針類型的Crash突然增多,可以考慮在 hot Patch 中開啟野指針防護(hù),待收取異常信息之后,再關(guān)閉此開關(guān)。
收集信息
由于希望此庫沒有任何外部依賴,所以并未實(shí)現(xiàn)響應(yīng)的上報(bào)邏輯。使用者如果需要上報(bào)信息 只需要自行實(shí)現(xiàn) XXRecordProtocol
即可,然后在開啟 SDK 之前將其注冊進(jìn)入 SDK。
在實(shí)現(xiàn)方法里面會(huì)接收到 XXShield 內(nèi)部定義的錯(cuò)誤信息。
開發(fā)者無論可以使用諸如 CrashLytics,友盟, bugly等第三庫,或者自行 dump堆棧信息都可。
@protocol XXRecordProtocol <NSObject>
- (void)recordWithReason:(NSError * )reason userInfo:(NSDictionary *)userInfo;
@end
使用方法
示例工程
git clone git@github.com:ValiantCat/XXShield.git
cd Example
pod install
open XXShield.xcworkspace
Install
pod "XXShield"
Usage
/**
注冊匯報(bào)中心
@param record 匯報(bào)中心
*/
+ (void)registerRecordHandler:(id<XXRecordProtocol>)record;
/**
注冊SDK,默認(rèn)只要開啟就打開防Crash,如果需要DEBUG關(guān)閉,請?jiān)谡{(diào)用處使用條件編譯
本注冊方式不包含EXXShieldTypeDangLingPointer類型
*/
+ (void)registerStabilitySDK;
/**
本注冊方式不包含EXXShieldTypeDangLingPointer類型
@param ability ability
*/
+ (void)registerStabilityWithAbility:(EXXShieldType)ability;
/**
///注冊EXXShieldTypeDangLingPointer需要傳入存儲類名的array,暫時(shí)請不要傳入系統(tǒng)框架類
@param ability ability description
@param classNames 野指針類列表
*/
+ (void)registerStabilityWithAbility:(EXXShieldType)ability withClassNames:(nonnull NSArray<NSString *> *)classNames;
ChangeLog
單元測試
相關(guān)的單元測試在示例工程的Test Target下,有興趣的開發(fā)者可以自行查看。并且已經(jīng)接入 TrivisCI保證了?代碼質(zhì)量。
?Bug&Feature
如果有相關(guān)的 Bug 請?zhí)?Issue。
如果覺得可以擴(kuò)充新的防護(hù)類型,請?zhí)?PR 給我。
作者
?ValiantCat, 519224747@qq.com
個(gè)人博客
南梔傾寒的簡書
License
XXShield 使用 Apache-2.0 開源協(xié)議.