最近线上出了一次事故, 在退出登录时, 正常的用户在退出登录时会清除成功userDefault中的数据,一般只会crash一次,但是有个用户比较特殊, 出现了连续闪退, 主要原因就是userDefault中的数据没有清除成功, 下次再启动app, 从userDefault中获取数据还认为是登录中的状态, 然后再次crash, 这样就陷入了死循环,导致只要打开app就会crash, 只有卸载重装才能解决问题.
先来说说为什么这个用户比较特殊, 这是一个公司内部的用户, app一直都是覆盖安装, 随着版本的迭代,迭代了3,4年, userDefault中数据越来越多, 查看此用户的沙盒文件, 系统默认的userDefault大小竟然有20M,文件太大导致退出登录时,清除某个key太慢, 此时发生了crash,userDefault中的数据抹除失败, 陷入死循环.
自己写了一个demo试了一下, 当数据量很大时,确实会出现这样的情况, 前2个key写入成功, 第三个key还是原来的旧值.
除了正常解决crash之外, 还想到了2种方式来补救.
NSUserDefault 用法优化
userdefault中不适合存较大数据, 但是业务上也确实需要这么多key来标记用户的某些状态, 比如当天是否签到, 比如是否展示过引导图, ...
发现系统在NSUserDefault中提供其他的初始化方法, 这样就可以按照不同的业务线或者按照功能来区分, 不要一股脑的都往默认的standardUserDefaults写入数据.
- (instancetype)initWithSuiteName:(NSString *)suitename NS_DESIGNATED_INITIALIZER;
比如
// 设置值
NSUserDefaults *userDefault = [[NSUserDefaults alloc] initWithSuiteName:@"abc"];
[userDefault setObject:@"1" forKey:@"1"];
// 另一个方法中获取值
NSUserDefaults *userDefault = [[NSUserDefaults alloc] initWithSuiteName:@"abc"];
NSString *value = [userDefault objectForKey:@"1"];
NSLog(@"SuiteName:abc -- %@",value);
这个方法会在和默认的standardUserDefaults平级目录下记录一个文件, 其他用法和standardUserDefaults一模一样. 这样就达到了减少standardUserDefaults的目的了.
连续闪退的优化
这个的做法就是检测到发生连续闪退时, 手动抹掉沙盒路径下的所有文件, 把所有的文件抹除后, 这个app其实就和刚从商店下载的一样了.
首先我们需要一种比较可靠的方式,可以在 app 启动时判断上次是否发生了启动 crash。介绍一个可行的思路。
如何检测连续闪退
连续闪退包含两个元素,闪退和连续。只有这两个元素同时具备时,才会影响我们的日志上传。闪退的定义可以简单为
app crash 时间 - app 启动时间 <= 5s (或者其他 threshold)
连续的定义为,至少接连出现两次或者以上。一般 2 次就够了,很多时候用户连续经历两次闪退,就会放弃尝试。
我们可以通过记录若干个特殊的时间点 timestamp 来试图还原 App crash 场景下的生命周期。
-
App 启动 timestamp,定义为 launchTs
App 每次启动时,记录当前时间,写入时间数组。
-
App crash timestamp,定义为 crashTs
App 每次启动时,通过 crash 采集库,获取上次 crash report 的时间戳,写入时间数组。
-
App 正常退出 timestamp,定义为 terminateTs
App 在接收到 UIApplicationWillTerminateNotification 通知时,记录当前时间戳,写入时间数组。注意,还有很多种 App 退出行为的时间戳是无法被准确记录的。
之所以要记录 terminateTs,是为了排除一种特殊情况,即用户启动 App 之后立即手动 kill app。如果我们正确记录了上面三个时间戳,那么我们可以得到一个与 App crash 行为相关的时间线。比如:
launchTs => crashTs => launchTs => terminateTs
或者
launchTs => launchTs => launchTs
或者
launchTs => crashTs => launchTs => crashTs => launchTs
请自行脑洞上面三种时间线的行为特征。很明显,第三种时间线看上去是连续 crash 了两次。我们只需要加上时间间隔判断,就能得知是否为连续两次闪退了。注意,如果两个 crashTs 之间如果存在 terminateTs,则不能被认为是连续闪退。检测代码比较简单,我就不贴了。
这个时间线只是记录与 crash 相关的 App 启动和退出行为,还有很多特殊的时间点没有记录,比如 App 在 前台发生 out of memory(FOOM),App 在前台 main thread 卡住被系统 Watch Dog 杀掉,iOS 系统升级时 App 被强杀,App 从 AppStore 升级时被强杀等等,这些特殊的时间点都没有记录,不过这些并不影响我们的 App 连续闪退检测,所以可以忽略。
这里指的注意的是,因为启动时要从 disk 读取时间线记录,涉及磁盘读写,会对 App 的启动时间产生影响,一个优化点是,在每次写入时间点移除掉较老的 timestamp,比如只记录最近 5 个时间戳。或者在没有读取到 crash 日志时,甚至不用启动连续闪退检测的整个流程。
接下来,我们看假设检测到连续闪退,我们如何继续上传日志。
同步等待 Crash 日志上传
最直白的方式,在 App 的代码继续执行之前,先等待日志上传成功。
把网络请求改成同步的?这会卡住 UI 线程,网络差的场景下会被系统 watch dog 强杀,显然不可取。
我们可以依旧保持异步网络请求,但是,暂时中断 UI 线程的流程,让整个 App 处于 UI 线程的 runloop 等待中,一旦网络请求成功,则跳回到 UI 线程的原有代码流程。
看着简单的实现,有几个细节需要注意。首先我们需要增加一个 App 交互,一旦进入 runloop 等待,展示一个 loading 界面,告知用户耐心等待。其次,这个等待时间不能过长,我个人建议不超过 5s,一旦超过 5s,无论 crash 日志上传的 request 是否成功,都恢复 App 原有代码流程。5s 内日志都无法上传成功的情况应该比较小,除非日志文件过大。
这种做法缺陷也很明显,一是改动比较大(修改了原有代码流程),二是需要增加新的 UI 交互,三是延长了用户的等待时间。
我们来看另一种取巧的做法。
启用后台进程上传 Crash 日志
其实最理想的日志上传,是将上传的 request 放到另一个不同的进程,那么即使 App 又发生闪退,也不会影响到另一个进程代码的执行。
问题是,iOS app 都处于 sandbox 环境下,系统不允许代码 fork 一个新进程。
幸运的是,从 iOS 8 开始,系统对 NSURLSession 新增了一个 background session 特性。这个特性允许 NSURLSession 将网络请求放入到一个单独的进程中执行。我个人感觉,这个特性设计,原本是为了增强某些 App 后台下载音视频等资源的体验。我实际测试下来,发现不管下载或者是上传,我们都可以将网络请求放入另一个进程。代码也很简单,比如我写一段如下的测试代码:
NSURLSessionConfiguration *config = [NSURLSessionConfiguration backgroundSessionConfigurationWithIdentifier:@"com.mrpeak.background.crashupload"];
NSURLSession *session = [NSURLSession sessionWithConfiguration:config delegate:self delegateQueue:[NSOperationQueue new]];
NSURL *url = [NSURL URLWithString:@"https://images.unsplash.com/photo-1515816949419-7caf0a210607?ixlib=rb-0.3.5&ixid=eyJhcHBfaWQiOjEyMDd9&s=f46b60857b4826e733da34993ec26a2f&auto=format&fit=crop&w=1534&q=80"];
NSURLSessionDownloadTask *task = [session downloadTaskWithURL:url];
[task resume];
exit(0);
执行之后,我们可以在 console 中看到如下日志:
ioscrashupload00.png
可以清楚的看到 nsurlsessiond 进程如何替我们完成网络请求,并试图唤醒已经异常退出的 App。
当然这种最理想的方式,也有一些细节需要处理。比如如何告知 App 某个 crash 日志上传成功,并从本地移除。由于连续闪退的 App 处于极度不稳定的状态,所以任何代码逻辑都无法确保顺利完成。
我个人感觉一种比较理想的方式是,给后台进程上报的日志加上某个特殊的 flag,然后在后台通过 client request ID 和这个 flag 来做去重和整理。
线上 App 连续闪退是一种极其恶劣和可怕的故障,可怕之处在于,发生大面积连续闪退且无法被监控时,你正哼着小曲敲着代码,老板突然发现自己手机上 App 启动不了了,一打开 AppStore,发现一星差评潮水般涌来,如果是主流 App 甚至还会上科技新闻,不难预料一口黑漆漆的大锅正在成形。下次 App 的升级介绍里一定会出现 "fire peter" 了。
全文完。
作者:MrPeak
链接:https://www.jianshu.com/p/dd28c17e044c
来源:简书
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。