项目相关-提升稳定性

日常开发中我们最关注的一个指标就是 App 的崩溃率,所以「如何提升应用的稳定性」这个话题就显得比较重要。

这篇文章尝试从项目层面以及 Crash 防护层面来聊聊我理解的提升稳定性的方法。

# 项目层面

# 功能开关

当项目要增加一个技术相关的模块儿,比如我们开发了一个基于 Runloop 监听卡顿的技术需求,假设测试同事在测试的时候并没有覆盖到所有的测试场景,同时这个技术需求开发的有问题,可能会导致崩溃。那上线之后可能我们项目的崩溃率就会提升。

这种场景我们其实可以做一个服务器开关,客户端通过在运行时发送网络请求来确定是否运行技术需求的相关代码,如果出现了线上的崩溃问题,我们可以及时的关闭对应的功能,保证项目的稳定性。

# 使用断言

项目中尽可能在自己任务不可能的情况下使用断言,将可能崩溃的场景在开发阶段就暴露出来。在我实际开发的经验里,确实通过断言提前预判到一些可能的崩溃问题。最终避免了线上的崩溃。

# 通过编译检查规范代码

Swift 项目可以通过 SwiftLint 去进行一些配置,比如可以约束团队成员不要进行强制转换,如果出现强制转换就会编译报错,比如下面这种,如果不想全局生效的化,可以配置指定的文件(正则匹配)不生效

btn.layer.cornerRadius = CGFloat(10 as! Int)
//CompileError: Force Cast Violation: Force casts should be avoided (force_cast)

对应规则

#excluded:
#  - SwiftDemo/ViewController.swift //指定文件不生效
force_cast:
  severity: error # explicitly

SwiftLint 可以通过 Pod 集成之后,在项目根目录下配置 .swiftlint.yml 规则文件去使用。具体的一些编译规则可以看官方的规则文档 (opens new window)

# 捕获异常

# 抛出异常并捕获

不管是 OC 还是 Swift 都从语言层面提供了异常捕获的机制。

OC 异常捕获的方式如下:

@try {
    NSArray *arr = @[];   //
    NSLog(arr[0]);        //越界访问
} @catch (NSException *exception) {
    NSLog(@"exception %@", exception);
} @finally {
    NSLog(@"finally");
}

Swift 异常捕获,对于可能抛出异常的方法需要通过 do-catch 的方式去进行捕获,否则也会编译报错 Errors thrown from here are not handled.

do {
    var data = try NSData.init(contentsOfFile: "xx", options: .alwaysMapped)
} catch {
    NSLog("content file \(error)异常处理")
}

# 配置异常处理逻辑

通过 NSUncaughtExceptionHandler 来捕获并处理异常

具体使用方式如下

void UncaughtExceptionHandler(NSException *exception) {
    NSArray *arr = [exception callStackSymbols];
    NSString *reason = [exception reason];
    NSString *name = [exception name];
    NSLog(@"\n%@\n%@\n%@",arr,reason,name);
}
//在程序指定位置注册异常捕获配置方法
NSSetUncaughtExceptionHandler(&UncaughtExceptionHandler);

当出现程序越界之类的错误会抛出异常,被异常捕获函数捕获,这让我们有机会去拿到崩溃信息(backtrace_symbols (opens new window)) 去做进一步的处理。

需要注意的一点是,如果重复调用 NSSetUncaughtExceptionHandler 方法的话,后面的调用会覆盖前面调用配置异常处理方法,所以可以通过 NSGetUncaughtExceptionHandler 拿到并保存上一个异常处理方法(也许没有不过不影响),然后在我们自己的异常处理方法中,再调用上一个异常处理方法。

另外就是 Swift 语言中也可以通过如下方式使用这个 API,不过这种方法不能捕获 Swift 运行时错误,像 Swift 中的数组越界就是通过 Swift 运行时报错的,所以这种 API 是不能捕获 Swift 中数组越界的错误,事实上大部分的 Swift 中的错误都是 Swift 运行时报错的,所以这个 API 在 Swift 中感觉很鸡肋。

NSSetUncaughtExceptionHandler { exception in
    print(exception)
    print(exception.callStackSymbols)
}

至于为什么 Swift 这么设计?可以看看这里 (opens new window)

注:macOS 上同样的异常并不会引起崩溃,这是 AppKit 的一个默认操作。具体可以参考这里 (opens new window)

通过 BSD 的 signal 来捕获并处理异常

我们也可以通过 signal API 注册对应信号的函数,然后在函数中做进一步的处理

void customSignalExceptionHandler(int signal) {
	//获取崩溃信息将崩溃信息写入指定文件
}
//**signal API:**Sets the error handler for signal sig. 
+ (void)setSignalExceptionHandler {
    signal(SIGHUP, customSignalExceptionHandler);
    signal(SIGINT, customSignalExceptionHandler);
    signal(SIGQUIT, customSignalExceptionHandler);
    signal(SIGABRT, customSignalExceptionHandler);
    signal(SIGILL, customSignalExceptionHandler);
    signal(SIGSEGV, customSignalExceptionHandler);
    signal(SIGFPE, customSignalExceptionHandler);
    signal(SIGBUS, customSignalExceptionHandler);
    signal(SIGPIPE, customSignalExceptionHandler);
}

注意 Debug 模式下 Xcode 捕获异常的优先级更高 Xcode屏蔽了Signal的回调,所以即使崩溃也不会走到我们注册的信号处理函数中去,两种解决方案

  1. 在 LLDB 中通过命令 pro hand -p true -s false SIGABRT 解除屏蔽,当然要在崩溃之前打断点输入命令,嫌麻烦的话可以 Edit BreakPoint 添加 Action。

    (lldb) pro hand -p true -s false -n true SIGABRT
    NAME         PASS   STOP   NOTIFY
    ===========  =====  =====  ======
    SIGABRT      true   false  true 
    
  2. 直接点击真机/模拟器对应 App 去启动,将崩溃信息写到沙盒里去看。

之前文章 (opens new window)的最后一段大致说了一下崩溃的原理,不论是什么崩溃底层都会产生对应的 Mach 异常,然后经过 BSD 层包装成信号的方式发出。

理论上 signal 信号处理的范围应该是更广的,能够包含 throw Exception 这种错误。但像是上面 NSSetUncaughtExceptionHandler 这种异常捕获机制并不会捕获到 EXC_BAD_ACCESS 这种错误,因为 EXC_BAD_ACCESS 不会产生异常,我自己理解像是 EXC_BAD_ACCESS 这种内存错误应该是操作系统层面直接捕获生成了底层的 Mach Exception。

不管是 NSUncaughtExceptionHandler 还是注册 signal,如果在异常处理程序中不进行额外处理的话,程序还是会照样崩溃的,所以怎么让程序不崩溃呢?

可以在异常处理函数中加入如下代码来让程序异常之后继续运行

CFRunLoopRef runLoop = CFRunLoopGetCurrent();
CFArrayRef allModes = CFRunLoopCopyAllModes(runLoop);
while (!dismissed) {
    for (NSString *mode in (NSArray *)allModes) {
        CFRunLoopRunInMode((CFStringRef)mode, 0.001, false);
    }
}
CFRelease(allModes);

理论上这种场景只适合于应用 CRASH 之后,产品希望用户提交反馈,提交完闪退之后再进行崩溃。

# Crash 防护

异常捕获本质上当异常发生后进行的一些处理,现在有很多技术可以尽量避免异常的发生。比如大白健康系统--iOS APP运行时Crash自动修复系统 (opens new window)这篇文章里介绍的各种防护手段,这种防护手段本质上是通过 AOP Hook 的方式进行防护。

这里简单举几个例子来分析下防护原理

# 方法不存在(Unrecognized Selector Sent to Instance)

粗糙的说当我们向一个实例发送它所属类不包含的实例方法的时候会报这个异常,当然实际的消息发送流程要比这个复杂,不仅要查找所属类的实例方法列表,还要沿着继承链不断向上找,最终还要开启消息转发机制。

防护的具体做法就是在消息转发的机制上进行 HOOK,回顾一下消息转发的三个节点

  1. resolveInstanceMethod/resolveClassMethod 这两个方法是能够让类有机会动态添加实例/类方法。
  2. forwardingTargetForSelector 这个方法是原始类没有办法处理该方法的时候,我们可以提供一个其他的类来处理对应的消息,即将消息转发给其他的类(实例)。
  3. forwardInvocation 这个方法是运行时最后一次尝试找备援接收者,

接下来思考的是应该在哪个节点去做 HOOK 来防范崩溃。

  1. 在第一个时间节点去进行 HOOK 的话,需要我们动态给类添加方法。

    具体思路是 swizzle NSObjectresolveInstanceMethod API,然后在实现中将 SEL 对应到一个 NSObject 的空实例方法,替换方法如下,不过这种方式下在调用 methodSignatureForSelector API 的时候会反复触发 replaceResolveInstanceMethod 的 API 的执行,有点奇怪。

    Method originMethod = class_getClassMethod([NSObject self], @selector(resolveInstanceMethod:));
    Method replaceMethod = class_getClassMethod([NSObject self], @selector(replaceResolveInstanceMethod:));
    method_exchangeImplementations(originMethod, replaceMethod);
    //
    + (BOOL)replaceResolveInstanceMethod:(SEL)sel {
        NSMethodSignature *sig = [self methodSignatureForSelector:sel];
        if (!sig) {
            class_addMethod([self class], sel, class_getMethodImplementation([NSObject class], @selector(emptyMethod)), @"v@:@");
            return true;
        } 
    		return [self replaceResolveInstanceMethod:sel];
    }
    - (void)emptyMethod { NSLog(@"empty method"); }
    

    如果不使用 Swizzle 直接在 NSObject 的 Category 里用同名的方法 resolveInstanceMethod: 覆盖掉原始方法,感觉也不太合适。

    剩下的方法就是在子类中复写 resolveInstanceMethod: ,方法实现参考上面的代码中的实现,但缺点是只有指定的类能处理方法找不到的场景。

  2. 第二个时间节点去进行 HOOK 的话,需要在我们要替换的方法 replaceForwardingTargetForSelector: 里直接返回 nil,或者像下面一样生成一个专门用于接受各种消息的空方法。

    Method originMethod = class_getInstanceMethod([NSObject self], @selector(forwardingTargetForSelector:));
    Method replaceMethod = class_getInstanceMethod([NSObject self], @selector(replaceForwardingTargetForSelector:));
    method_exchangeImplementations(originMethod, replaceMethod);
    //具体实现
    static MsgProxy *msgProxy;
    @implementation NSObject (HOOK)
    + (void)load {
        msgProxy = [MsgProxy new];
    }
    - (id)replaceForwardingTargetForSelector:(SEL)aSelector {
        NSMethodSignature* sign = [self methodSignatureForSelector:aSelector];
        if (!sign) {
            Method method = class_getInstanceMethod([MsgProxy class], @selector(emptyMethod));
            class_addMethod([MsgProxy class], aSelector, method_getImplementation(method), method_getTypeEncoding(method));
            return msgProxy;
        }
        return [self replaceForwardingTargetForSelector:aSelector];
    }
    @end
    
  3. 第三个时间节点去进行 HOOK 的话,需要同时 Swizzle 如下两个方法,参考 JJException (opens new window) 的做法

    - (NSMethodSignature *)methodSignatureForSelector:(SEL)aSelector
    - (void)forwardInvocation:(NSInvocation *)anInvocation
    

    methodSignatureForSelector: 的方法里,先看原类是否提供了方法签名,如果提供了的话,则用原类的方法签名,否则的话提供一个自定义的方法签名,JJException 里提供的方法签名是 [NSMethodSignature signatureWithObjCTypes:"v@:@"] 这样的,但不用这个方法签名也行,也没找到作者为什么使用这个方法签名的原因。

    forwardInvocation: 方法里并不调用原类的 forwardInvocation: 方法了,而是直接调用异常处理方法,打印错误堆栈信息以及后续处理,

不同的防护系统的 HOOK 时间节点也不同,像大白健康系统--iOS APP运行时Crash自动修复系统 (opens new window)这篇文章是在第二个时间节点去进行 HOOK 的,而 JJExceptioin (opens new window) 是在第三个时间节点去 HOOK 的。我感觉还是第三个时间点去做更好,越晚做这种 HOOK 越能减少对正常消息转发的干扰。

# 集合类型防护(NSArray,NSDictionary)

两种异常类型,数组越界; key-value为nil。

  1. 数组越界的解决方式。通过 HOOK 具体的集合的方法去解决,然后再新的替换方法里进行越界判断如果越界就直接处理异常并返回nil

    swizzleInstanceMethod(NSClassFromString(@"__NSArray0"), @selector(objectAtIndex:), @selector(hookObjectAtIndex:));
    - (id) hookObjectAtIndex:(NSUInteger)index {
        if (index < self.count) {
            return [self hookObjectAtIndex:index];
        }
        handleCrashException(JJExceptionGuardArrayContainer,[NSString stringWithFormat:@"NSArray objectAtIndex invalid index:%tu total:%tu",index,self.count]);
        return nil;
    }
    

    Swift 进行数组越界防护的时候,是通过添加下标(subscript)方法来方式越界的

  2. key-value 为 nil 的场景。也是通过HOOK 对应字典方法,判断 object 和 key,如果其中一个为 nil 就走异常处理逻辑。

    swizzleInstanceMethod(NSClassFromString(@"__NSDictionaryM"), @selector(setObject:forKey:), @selector(hookSetObject:forKey:));
    - (void) hookSetObject:(id)object forKey:(id)key {
        if (object && key) {
            [self hookSetObject:object forKey:key];
        } else {
            handleCrashException(JJExceptionGuardDictionaryContainer,[NSString stringWithFormat:@"NSMutableDictionary setObject invalid object:%@ and key:%@",object,key],self);
        }
    }
    

整体上 Crash 防护还是通过运行时机制进行的防护,这种方案对 Objective-C 生效,但是对于 Swift 的话作用不是很大。

但是 Crash 防护也会引起一些其他副作用,引起正常的功能运行异常,参考 JJException 的 issue 区。我自己感觉做 Crash 防护的时候可以配合一个服务器下发的开关,当有异常的时候直接通过用服务器开关来控制它的注入,但基本上都得下次启动才能生效了。

以上是我能想到的一些提升应用稳定性的方案,欢迎讨论。

参考地址:

  1. Github-WOCrashProtector (opens new window)
  2. 大白健康系统--iOS APP运行时Crash自动修复系统 (opens new window)
  3. 再谈 iOS App Crash 防护 (opens new window)
  4. AppleGuide-Exception Programming Topics (opens new window)
  5. 浅谈 iOS 中的 Crash 捕获与防护 (opens new window) #强烈推荐阅读这篇文章… 浅谈真是太谦虚了..
  6. iOS Crash防护你看这个就够了-下篇 (opens new window)
  7. Github-JJException (opens new window)