iOS常见闪退问题总结

2019-02-25 19:23:30

​ 在所有线上出现的问题中,闪退是最严重的。因此,在开发过程中我们要尽量避免闪退的发生,尽可能的提高程序的稳定性。本文针对项目线上出现过的闪退问题进行分类和总结,在后续开发过程中可以吸取经验教训,避免犯同样的错误。或者再次遇到类似的闪退问题时,可以比较快速的进行定位并修复。

一、常见闪退问题

1、数组访问越界

set_beyond_bounds.png

array_beyond_bounds.png

​ 解决方法也非常简单,只需要判断下索引和数组大小就可以避免。这种闪退是在开发过程中应该尽可能避免的。

2、多线程访问
@interface ApiSessionManager ()
@property (copy, nonatomic) NSURL *baseSecurityURL;
@property (strong) NSMutableDictionary *taskIds;
@end

ApiSessionManager中的taskIds用于保存网络请求任务taskId以及NSURLSessionDataTask的taskIdentifier键值对,用于取消或恢复网络请求任务。该属性在多线程访问下可能导致闪退,因此对其进行操作时需要加锁或同步操作。例如:

// 修改前
NSArray *taskIds = [self.taskIds allKeysForObject:@(taskId)];
[self.taskIds removeObjectsForKeys:taskIds];
// 修改后
@synchronized (self) {
    NSArray *taskIds = [self.taskIds allKeysForObject:@(taskId)];
    [self.taskIds removeObjectsForKeys:taskIds];
 }

​ 在addEntriesFromDictionary以及objectForKey调用处也需要通过@synchronized进行同步,避免多线程访问导致的闪退。通过这个例子我们也可以看出,属性通过atomic修饰并不能保证线程安全。

​ 在多线程使用过程中要保证线程安全,可以通过加锁的方式,避免闪退。

3、系统升级导致闪退

​ iOS 11.3系统正式发布后,闪退平台上陆续出现一些闪退,分析发现是下载课件后暂停再恢复时闪退。通过分析发展是系统API有调整导致,立即发布新版本进行适配。

​ 要避免系统升级导致的闪退,可以在系统Beta版本对App进行兼容性测试,提前适配新系统,防止线上出现此类问题。

4、unrecognized selector sent to instance XX

​ 常见场景为某个对象调用某个方法,而该对象并没有实现这个方法,在运行时就会闪退。

  • delegate未实现协议就直接调用
// 错误示例
- (void)someMethod {
    // delegate 并未实现doSomething,则会导致闪退
    [self.delegate doSomething];
}
// 正确示例
- (void)someMethod {
    // 判断delegate 是否实现doSomething,避免闪退
    if ([self.delegate respondsToSelector:@selector(doSomething)]) {
        [self.delegate doSomething];
    }
}
  • 后端返回数值类型字段为null且客户端没有做判空处理

    对后端返回的字段,如果客户端没有进行类型判断就直接使用的话,可能导致闪退。例如:

// 如果后端返回的termId为null,则会导致闪退
int64_t termId = [[result objectForKey@"termId"] longLongValue];

上述例子会导致闪退 Terminating app due to uncaught exception 'NSInvalidArgumentException', reason: '-[NSNull longLongValue]: unrecognized selector sent to instance 0x1107a0f28'

// 正确示例
int64_t termId = 0;
NSNumber *termNumber = [result objectForKey@"termId"];
// 判空处理
if (![termNumber isKindOfClass:[NSNull class]] &&[termNumber isKindOfClass:[NSNumber class]]) {
      termId = [termNumber longLongValue];
}

针对后端返回的所有数值型字段,都需要进行判空处理,避免由于后端返回的数据错误导致客户端闪退。

5、KVC/KVO
  • KVC

    当调用NSObject的setValue:forKey方法时,如果对象中不存在对应的key,或者非对象属性value为nil会导致NSInvalidArgumentException闪退,可以通过实现NSObject的分类覆盖setValue:forUndefinedKey:方法来避免闪退。

@implementation NSObject (SafeKVC)

/**
 * 调用 setValue:forKey: 、 setValuesForKeysWithDictionary: 、setValue:forKeyPath
 * 当 key 不存在时,会调用下列方法,默认的实现会引起 NSUndefinedKeyException
 * 通过覆写,来规避这类这类 crash
 */
- (void)setValue:(id)value forUndefinedKey:(NSString *)key {

}

/**
 * 调用 valueForKey: 或者 valueForKeyPaht:
 * 当 key 不存在时,会调用下列方法,默认的实现会引起 NSUndefinedKeyException
 * 通过覆写,来规避这类这类 crash
 */
- (id)valueForUndefinedKey:(NSString *)key {
    return nil;
}

@end
  • KVO

    使用系统API addObserver:forKeyPath:options:context: removeObserver:forKeyPath: 实现KVO时需要格外小心,一不留神就很容易闪退。

    • 添加监听后没有清除会导致闪退

    • 清除不存在的key也会闪退

    • 添加重复的key导致闪退

      可以通过hook系统API进行解决,或者直接使用KVOController或者RAC实现KVO的功能,避免对系统API误操作导致闪退。

6、应用启动时闪退

​ 在某个版本发布后,出现过用户启动后就闪退,而Fabric闪退平台上并没有对应的闪退堆栈,说明在Fabric启动之前应用就闪退了。

​ 这种问题就比较难定位,需要和策划、运营配合,收集反馈遇到问题的用户手机信息,并通过本地Xcode构建上传发布Test Flight测试包给出问题的用户安装,用户再次启动时就可以通过Xcode收集到闪退堆栈,并进行问题定位。通过Xcode收集到的堆栈信息发现,问题出在云信SDK,云信SDK的info.plist支持的架构类型设置错误,不支持armv7架构,导致armv7架构的手机启动app时闪退。通过更新云信SDK后问题解决。

Xcode查看Crash log类似下图:

xcode_crash_log.png

7、EXC_BAD_ACCESS / KERN_INVALID_ADDRESS

bad_access_crash.png

Details:

The stack trace indicates that heap corruption may have caused your app to crash. Memory corruption can occur pretty easily from freeing a dangling pointer, a thread race, or bad pointer arithmetic. The important thing to keep in mind is that the resulting crash may happen long after the initial corruption. As a result, the stack trace for this crash might not provide any clues to the location of the bug in your code. However, you can still fix memory issues with tools from Apple. For speedy resolution of memory corruption issues, we recommend regularly auditing your app with Xcode’s memory debugging facilities: Visual Memory Debugger, Zombies Instrument, Address Sanitizer, Thread Sanitizer and malloc diagnostics.

External resources:

  1. iOS Debugging Magic
  2. macOS Debugging Magic
  3. Apple Memory Usage Performance Guidelines
  4. Zombies Instrument Guide
  5. WWDC 2015: Advanced Debugging and the Address Sanitizer
  6. WWDC 2016: Thread Sanitizer and Static Analysis
  7. Apple Secure Coding Guide: Avoiding Buffer Overflows and Underflows

这种闪退通常是随机出现,并且缺乏必要的堆栈信息,所以很难定位。导致该闪退的场景原因包括:

  • 访问野指针

    解决野指针问题可以通过Zombie Objects,将释放的对象标记为Zombie对象,再次给Zombie对象发送消息时,发生crash并且输出相关的调用信息。这套机制同时定位了发生crash的类对象以及有相对清晰的调用栈,可以比较方便的定位问题,缺点是只能在调试阶段使用。

  • 线程竞争

    需要保证线程安全。

  • 内存泄漏

    可以通过Instrument工具检测项目是否存在内存泄漏,也可以通过接入MLeaksFinder第三方工具,检测是否存在内存泄漏。一旦发现内存泄漏要及时解决。

8、项目中存在两个相同文件名的xib文件,但是两个文件内容不完全相同,访问到某个不存在的属性时会导致闪退

​ 项目某个版本上线后通过闪退分析平台发现,某个页面出现大量闪退。通过闪退堆栈分析,问题出现在SomeHeaderView,提示<SomeHeaderView 0x123def230> setValue:forUndefinedKey:]: this class is not key value coding-compliant for the key headerView,分析代码发现工程里存在两个SomeHeaderView.xib文件,这两个文件中有一个outlet property命名不一样,一个是headerView,一个是myHeaderView,而SomeHeaderView.h对应property的命名是myHeaderView,Xcode打包时将包含属性headerView的xib文件打包进去了,程序运行时找不到headerView对应的property,导致闪退。原来项目工程中只包括一个SomeHeaderView.xib文件的引用,不过文件夹中包括一个多余的SomeHeaderView.xib文件,通过git log分析发现是在项目工程结构调整时,把文件夹中的SomeHeaderView.xib文件也引入到了工程中,导致项目打包时把错误的文件打包进去,引发闪退。删除包含属性headerView的SomeHeaderView.xib文件,重新打包,问题解决。

9、Fatal Exception: CALayerInvalidGeometry

reader_crash.png

​ 项目中出现CALayer position contains NaN: [nan nan] 闪退,该类型闪退表示CALayer的坐标出现了非数字,通常是除零操作导致的。闪退堆栈很清晰的指向EMReader.m中的第209行,

ReaderContentView *contentView = [[ReaderContentView alloc] initWithFrame:viewRect fileURL:fileURL page:page password:phrase];

但是查看代码并没有发现除零操作,因此对代码中的除法操作做了保护,防止出现除零操作。上线后发现该闪退仍然存在,通过分析发现问题出在vfrReader中。

static CGFloat g_BugFixWidthInset = 0.0f;

#pragma mark - ReaderContentView functions

static inline CGFloat zoomScaleThatFits(CGSize target, CGSize source)
{
    CGFloat w_scale = (target.width / (source.width + g_BugFixWidthInset));
    CGFloat h_scale = (target.height / source.height);
    return ((w_scale < h_scale) ? w_scale : h_scale);
}

#pragma mark - ReaderContentView class methods

+ (void)initialize
{
    if (self == [ReaderContentView self]) // Do once - iOS 8.0 UIScrollView bug workaround
    {
        if ([UIDevice currentDevice].userInterfaceIdiom == UIUserInterfaceIdiomPhone) // Not iPads
        //{
            NSString *iosVersion = [UIDevice currentDevice].systemVersion; // iOS version as a string
            if ([@"8.0" compare:iosVersion options:NSNumericSearch] != NSOrderedDescending) // 8.0 and up
            {
                if ([@"8.2" compare:iosVersion options:NSNumericSearch] == NSOrderedDescending) // Below 8.2
                {
                    g_BugFixWidthInset = 2.0f * [[UIScreen mainScreen] scale]; // Reduce width of content view
                }
            }
        //}
    }
}

上述代码中在iOS 8.2以上系统时,g_BugFixWidthInset没有被初始化,导致zoomScaleThatFits静态方法中可能存在除零操作,从而导致闪退。只需要把if ([@"8.2" compare:iosVersion options:NSNumericSearch] == NSOrderedDescending)这个条件注释掉即可解决问题,同时要注意if ([UIDevice currentDevice].userInterfaceIdiom == UIUserInterfaceIdiomPhone)针对iPhone的条件判断,这个条件也必须去掉,否则iPad上仍然会出现问题(惨痛的教训)。由于vfrReader已经停止更新,因此我们把vfrReader加入到内部的第三方组件进行管理,并进行修改,问题得以解决。

10、后台线程刷新UI

​ 由于UIKit不是线程安全的,因此所有涉及到UI的操作都需要在主线程进行,如界面刷新、控件位置调整等。如果在后台线程操作UI,可能导致闪退,常常出现在网络接口的Block回调中,在开发过程中要尽量避免。

二、闪退问题定位解决方法

遇到闪退问题时如何定位并解决?

1、首先可以跟进Fabric上的闪退堆栈定位到项目中的具体代码,然后进行分析,判断代码是否有明显的错误。

2、如果通过代码无法看出闪退的原因,则需要尝试复现闪退,可以通过操作app或者修改代码人为制造闪退,并分析原因或进行规避解决。

3、如果Fabric上没有有效的闪退堆栈,则可以本地调试尝试复现闪退,如果还是无法复现,则可以通过打Test Flight包的方式邀请用户进行使用,发生闪退时可以通过Xcode查看闪退堆栈,进一步分析问题并解决。