以下所有内容均为个人观点,转载请注明出处<简书--小蜗牛吱呀之悠悠 >,谢谢!
线上的崩溃一直以来都是比较头疼的问题,由于crash对于APP来说是严重的质量事故,所以如果能够降低crash并且将可能出错的原因收集起来并通过版本迭代修复掉,那么将会有很大的实际意义,网易的“大白健康系统--APP运行时Crash自动修复系统”从多个维度讲述了防护的方案,本文也将从中借鉴并结合实际情况展开讨论。
一、背景
近期涉及项目稳定性的提升以及线上客户的反馈,为了提升客户的满意度,我们有必要实现一个技术方案来完成“crash发现及防护 — 错误收集 — 通知客户修复问题”的闭环。如果我们能够在客户反馈crash问题之前发现并解决问题,然后第一时间通知客户迭代版本去修复问题,不仅可以提升品牌形象,同时也可以减少后续维护过程crash反馈问题数。
1、crash发现及防护
借助iOS运行时的特性,将一些常见,不影响业务逻辑,不侵染客户项目代码的崩溃,在崩溃之前做出一些防护措施,从而绕开崩溃,并且将产生崩溃的原因收集起来,借助第二环节的“错误收集”提交到开发人员,并修复掉。
2、错误收集
项目已经采用云音乐的sentry框架收集崩溃信息,此处不展开sentry的讨论。sentry框架GitHub链接
3、通知客户修复问题
当我们从第一、第二环节收集到崩溃信息,修复并发布版本后,需要及时通知客户更新版本。此时我们需要知道哪些客户正在使用有问题的版本,基于这个需求,我们联合服务端收集SDK版本号,并将版本号与AppKey关联,从而实现这个需求
二、防护纲要
1、容器越界、非空防护
2、unrecognized selector 崩溃
3、NSTimer
4、KVO crash
5、Bad Access crash (野指针)
结合七鱼SDK项目的实际情况,已经按优先级将上述防护进行排序。
三、实现方案及原理
1、容器越界、非空防护
七鱼中使用的常用容器为:NSArray、NSMutableArray、NSDictionary、NSMutableDictionary、NSString、NSMutableString。
容器的crash一般出现在越界或者插入非空对象,我们采用了方法交换的原理对容器进行crash预处理。
实际操作过程中,使用了如下图中的做法:
本意是为了增加稳定防护,但是却遗漏了考虑不继承于NSObject的类NSProxy和继承于swift根类的_TtCs12_SwiftObject的情况,导致在继承于NSProxy和_TtCs12_SwiftObject类的正常业务逻辑受到了影响
注:为了侵染用户的代码,可以在防护的方法中增加判断,异常是否来源于七鱼,仅对来源于七鱼的有效。
2、unrecognized selector 崩溃
引起这类崩溃通常是因为一个对象调用了一个不属于它方法的方法导致的。这种崩溃出现的频率比较高,防护的意义较大。
我们先了解一下unrecognized selector 崩溃是怎么产生的。当我们使用对象A调用方法B的时候,系统默认会帮我们做以下这些事情:
a、去对象A的方法列表中查找是否已经实现方法B,如果实现了,直接执行,否则执行b
b、去对象A的isa指针指向的对象中查找方法B,如果找到了,直接执行,如果一直到根类都没有找到,则系统会走消息转发机制,即c
c、系统会在崩溃前通过查找是否有重写拦截的方法确定是否产生崩溃,如果没有重写拦截方法,则抛出unrecognized selector 异常。
到这来,unrecognized selector 产生的原因也就显而易见了。由于类的种类不确定,通过a、b两个环节动态增加方法不仅实现起来困难,而且代码侵染性很高;同时,既然系统提供了c这个环节,我们就考虑从c这个步骤入手进行防护,只要在crash前,重写拦截方法,就可以避免crash。
拦截的方法一共有3个步骤,关系如下:
//给对象添加这个方法的实现
+ (BOOL)resolveInstanceMethod:(SEL)sel;
//让别的对象去执行这个函数
- (id)forwardingTargetForSelector:(SEL)aSelector;
//将目标函数以其他形式执行
- (void)forwardInvocation:(NSInvocation *)anInvocation;
通过上图可以知道,方法一和a、b环节类似,不予考虑,方法三经常被重写,而且可以将消息以NSInvocation形式转发个多个对象,增加了不必要的开销,所以我们选择方法二比较合适。也就是说如果我们能建立一个专门用于消息拦截的类,每次发生unrecognized selector的时候,都将消息转发给这个类,那么我们就可以统一处理了。
//将崩溃信息转发到一个指定的类中执行FastForwarding
- (id)BMP_forwardingTargetForSelector:(SEL)selector{
/*判断当前类有没有重写消息转发的相关方法*/
if ([self isEqual:[NSNull null]] || ![self overideForwardingMethods]) {//没有重写消息转发方法
NSArray *callStackSymbolsArr = [NSThread callStackSymbols];
//错误发生在viewdidload中的时候获取发生错误的视图控制器的类名
NSString *vcClassName = GetClassNameOfViewControllerIfErrorHappensInViewDidloadProcessWithCallStackSymbols(callStackSymbolsArr);
errors = ErrorInfosMake([NSStringFromClass(self.class) cStringUsingEncoding:NSASCIIStringEncoding], [NSStringFromSelector(selector) cStringUsingEncoding:NSASCIIStringEncoding]);
//为BayMaxCrashHandler类增加selector对应的方法
class_addMethod([BayMaxCrashHandler class], selector, (IMP)DynamicAddMethodIMP, "v@:");
//收集错误信息
[[BayMaxCrashHandler sharedBayMaxCrashHandler]forwardingCrashMethodInfos:@{ErrorClassName:NSStringFromClass(self.class),
ErrorFunctionName:NSStringFromSelector(selector),
ErrorViewController:[[BayMaxDegradeAssist Assist]topViewController]
}];
BayMaxCatchError *bmpError = [BayMaxCatchError BMPErrorWithType:BayMaxErrorTypeUnrecognizedSelector infos:@{
BMPErrorUnrecognizedSel_Reason:[NSString stringWithFormat:@"UNRecognized Selector:'%@' sent to instance %@",NSStringFromSelector(selector),self],
BMPErrorUnrecognizedSel_VC:vcClassName == nil?([[BayMaxDegradeAssist Assist]topViewController] == nil?@"":[[BayMaxDegradeAssist Assist]topViewController]):vcClassName,
BMPErrorCallStackSymbols:callStackSymbolsArr
}];
if (_showDebugView) {
[[BayMaxDebugView sharedDebugView]addErrorInfo:bmpError.errorInfos];
}
[[BayMaxDegradeAssist Assist]handleError:bmpError];
if (_errorHandler) {
_errorHandler(bmpError);
}
//告诉系统,去BayMaxCrashHandler方法列表查找具体的实现
return [BayMaxCrashHandler sharedBayMaxCrashHandler];
}
return [self BMP_forwardingTargetForSelector:selector];
}
如果某个类本身进行了消息拦截方法的重写,我们再将消息转移到BayMaxCrashHandler类上,则会使原来重写的流程失效,所以在overideForwardingMethods方法中进行了判断。
3、NSTimer防护
NSTimer使用频率很高,但每次使用后,都要在dealloc之前将定时器释放掉,否则会引起循环引用,甚至崩溃。因为timer被target强引用的同时自身也被target所持有,如果不在dealloc之前释放定时器,target对象也将无法释放。
针对这个情况,我们想到的是解除target和timer之间互相引用的环,但又不影响业务逻辑的开展。那么如何才能解除这个环呢?
1、timer被target引用
@property (nonatomic, weak) NSTimer *timer;
target依然被timer强引用,导致dealloc方法不执行,循环引用仍然存在
2、target被timer引用
__weak typeof(self) weakSelf = self;
self.timer = [NSTimer scheduledTimerWithTimeInterval:1 target:weakSelf selector:@selector(test) userInfo:nil repeats:YES];
对target进行弱引用以后,并不能改变target指向的内存地址,例如上述的self指向地址S,弱引用后,weakSelf仍然指向地址S,当我们使用weakSelf传给定时器的时候,相当于是将地址S传过去,scheduledTimerWithTimeInterval方法内部仍然强引用地址S,dealloc方法仍然无法被执行,循环引用仍然存在。
3、timer与target直接加入一个桥阶层
通过方法交换,在scheduledTimerWithTimeInterval方法中,新建一个subTarget对象,并将target,selector,timer,targetClass等参数传递给subTarget,subTarget内部对这些参数进行弱引用持有,并实现一个中转方法给原始scheduledTimerWithTimeInterval调用。这样,每当定时器触发时,target对象强引用了timer,timer强引用subTarget,并向subTarget调用中转方法,由subTarget的中转方法再将方法分发给原始target,而原始target则被subTarget弱引用。
此时,当target被需要被释放时,由于没有被timer强引用,dealloc会被调用,同时会释放subTarget和timer。由于timer及时被释放了,回调函数在target被释放后不会再执行,就不会再引起野指针等异常内存地址访问的问题。
4、KVO crash防护
我们在需要监测某个变量值的变化时常使用到KVO,但KVO需要注册和释放成对配套使用,任意一个环节过多或过少,都会导致崩溃。我曾经对UITableviewCell的某个属性使用过KVO,由于Cell存在复用机制,导致KVO的注册和释放不配套,造成crash。如果能够实现一种机制,使得KVO不依赖于注册和释放成对出现,那么此类crash将大幅减少。
从上述内容我们可知,KVO的注册与释放彼此依赖,与NSTimer的防护非常相似,如果能够打破这个环结构,那么就可以避免KVO的崩溃了。我们参考NSTimer的思路,在注册与释放环节增加中间桥接层,让注册与依赖不强相关,而分别于桥接层强相关,在对象释放的时候,只需要操作桥接层即可,即便注册和释放不成对,也不会引起KVO的crash。
如上图我们可以知道,在观察者与被观察者之间的这层桥接层(KVO delegate)负责记录两者之间的关系,对观察者而言,KVO delegate是被观察者,对被观察者而言,KVO delegate是观察者。
KVO delegate有一个属性用于记录观察路径等信息,当对象被释放时,会将KVO delegate的所有路径释放完,这样就形成了注册和释放的闭环。
5、Bad Access crash (野指针)
野指针或者异常内存地址访问是经常遇到,并且是很难处理的问题,如果能够对这一块的崩溃进行提前规避掉,将大大降低崩溃率。
野指针问题之所以难处理往往是因为崩溃日志能提供的信息很有限,场景又难以复现,所以如果有一个机制,能够将野指针问题所需要的信息收集起来,这样对解决问题将很有帮助。
“大白健康系统--APP运行时Crash自动修复系统”一文中关于这一点的思路是参考僵尸对象原理,模拟构建一个僵尸对象,在访问到异常内存地址时,主动将其释放掉,并将isa指针指向僵尸对象,以此来绕开崩溃。但这种做法风险很大,虽然避免了暂时的崩溃,但程序后续会怎么运行将存在不确定性;绕开了野指针引起的崩溃,可能也绕开了我们正常的业务逻辑,容易引起业务逻辑的错误;
基于上述考虑,并结合前文提到的“crash发现及防护 — 错误收集 — 通知客户修复问题”闭环,我们可以在即将崩溃时,充分收集有效的数据,并借助crash上报的功能,将crash防护作为解决野指针问题的收集工具。
注意:由于大白健康系统并未对外发布实际框架,本文借助BayMaxProtector展开论述,这个框架基本与本文思想一致,但结合实际业务仍有待改进之处:
1、框架中未完善白名单黑名单制度,对于一些需要特殊处理的类需要绕开。
2、框架中的部分判断逻辑未兼容NSProxy类和_TtCs12_SwiftObject类,防护无效。
3、可以增加针对特定范围的防护,以避免收集不属于SDK的错误信息。
4、需要结合实际日志收集功能,实际需要收集的信息加以优化。