技 术 文 章 / 超 人
在实际工作中希望在应用上线后,能看见应用运行情况,崩溃日志。有一下几种方式
收集崩溃日志
最直接的就是自己写一个崩溃收集方法,每一个应用只能申请一个崩溃收集监听,当接收到崩溃信息后,将崩溃信息保存在应用中,等待下次用户启动应用的时候把崩溃信息发送到自己的服务器,来查看。
注意:自己收集崩溃日志需要注意,每个应用只能申请一个监听崩溃事件,如果申请多个,那么只有最后一个申请监听崩溃事件的方法能收到信息。异常捕获有2种,一种是EXC_BAD_ACCESS引发崩溃,一种是程序向自身发送了SIGABRT信号而崩溃。
步骤1:首先需要在程序的入口处加入异常捕获的注册。一般都是在程序启动事注册
//MyAccessHandleException 是收集EXC_BAD_ACCESS崩溃时系统回调的C方法名称
//MySignalExceptionHandler 是收集signal崩溃时系统回调的C方法名称
NSSetUncaughtExceptionHandler(&MyAccessHandleException);
signal(SIGHUP, MySignalExceptionHandler);
signal(SIGINT, MySignalExceptionHandler);
signal(SIGQUIT, MySignalExceptionHandler);
signal(SIGABRT, MySignalExceptionHandler);
signal(SIGILL, MySignalExceptionHandler);
signal(SIGSEGV, MySignalExceptionHandler);
signal(SIGFPE, MySignalExceptionHandler);
signal(SIGBUS, MySignalExceptionHandler);
signal(SIGPIPE, MySignalExceptionHandler);
步骤2:实现回调的C方法
void MyAccessHandleException(NSException *exception)
{
// 异常的堆栈信息
NSArray *stackArray = [exception callStackSymbols];
// 出现异常的原因
NSString *reason = [exception reason];
// 异常名称
NSString *name = [exception name];
//这里拿到了崩溃原因和异常名称后,就把信息保存在本地,等待下次启动程序的时候把信息发送到服务器,因为异常捕获是在主线程中完成的,所以在获取到异常崩溃信息后不能立马把信息发送到服务器,因为网络请求是异步的,主线程会直接崩溃,网络请求发送不出去.
}
#pragma mark - signal收集
void MySignalExceptionHandler(int signal)
{
//组装异常信息的可变字符串
NSMutableString *mstr = [[NSMutableString alloc] init];
[mstr appendString:@"Stack:\n"];
void* callstack[128];
int i, frames = backtrace(callstack, 128);
char** strs = backtrace_symbols(callstack, frames);
for (i = 0; i
当然也可以写一个卡死线程来阻止程序崩溃,先发送网络请求在卡死线程,等待网络请求成功后,在取消卡死线程。
例如:
//获取MainRunloop
CFRunLoopRef runLoop = CFRunLoopGetCurrent();
CFArrayRef allModes = CFRunLoopCopyAllModes(runLoop);
while (!_netWorkSendSuccess){ //根据_netWorkSendSuccess标记来判断当前是否需要继续卡死线程,可以在网络请求成功后修改_netWorkSendSuccess的值。
for (NSString *mode in (__bridge NSArray *)allModes) {
CFRunLoopRunInMode((CFStringRef)mode, 0.001, false);
}
}
//释放对象
CFRelease(allModes);
接入第三方数据收集
给应用接入相关数据收集的SDK,比如我自己写的龙渊统计SDK或者友盟、热云等统计SDK。这些SDK把应用的运行崩溃等情况数据都会返在他们服务器上,而我们只需要在他们服务器上查看自己想要了解的部分数据(为什么关于数据的收集这么简单,但是大多数应用游戏都不自己做,而是接入第三方,那是因为第三方不仅仅是收集数据,还提供了一套对收集的数据保存分析的处理,例如每年年末支付宝会提供自己一年的账单,告诉你这一年你的钱这么花的,买什么类型产品的比例最高等等)。
Xcode Crashes工具
Xcode Crashes工具
如果用户在设置手机的时候开启了“与应用开发者共享”功能,当应用崩溃的时候,应用的崩溃信息会返回给苹果,然后苹果会将崩溃信息返回到相应应用信息里(苹果返回给开发者崩溃信息会有延迟,可能有1天左右的延迟)。开发者可以在打包发布该应用的电脑的Xcode的Crashes工具中查看到信息。这是苹果官方说明
IOS系统会在在每个应用崩溃的时候把崩溃信息保存在手机本地,如果用户没有开启“与应用开发者共享”功能,我们可通过电脑链接用户手机通过Xcode的 Window --> Devices and Simulators -- > 选择需要查看的手机 --> View Device Log,查看自己想要看的应用的crash内容
苹果审核反馈的崩溃日志
在应用发布给苹果审核的时候,如果测出来应用有崩溃的情况,苹果会把崩溃信息返回给我们(会返回一个.crash和tns.mhfyxlpb.crash_symbolicated文件),但是苹果返回的信息是进行了十六进制转换的地址,其他人拿着这个崩溃日志并看不出任何头绪。只有使用发布该应该事打包的dSYM文件才能知道具体在代码中崩溃的地方。因为dSYM文件、app、.crash文件中有统一的UDID。只有相同的才能正确的解析出崩溃的位置。
看解析苹果返回的.crash文件的方法有2种
- 方法1:使用dSYM解析(官方)
.dSYM文件有一个UUID,和.app文件中的UUID相对应,代表着是一个应用。而.dSYM文件中每一条崩溃信息也有一个单独的UUID,用来和程序的UUID进行校对。.dSYM中存储着文件名、方法名、行号的信息,是和可执行文件的16进制函数地址对应的,通过分析崩溃的.Crash文件可以准确知道具体的崩溃信息。
注意,以下情况不会有崩溃信息产生 |
---|
内存访问错误(不是野指针错误) |
低内存,当程序内存使用过多会造成系统低内存的问题,系统会将程序内存回收 |
因为某种原因触发看门狗机制 |
步骤1:首先要确保应用在打包的时候设置了DWARF with dSYM File,查看Xcode --> Build Setting 搜索 "debug information format"查看是否为DWARF with dSYM File。如果不是请修改。这样打包后就会产生dSYM文件
步骤2:先在桌面任意创建一个文件夹
步骤3:然后在Finder-->应用程序 --> 中找到Xcode右键显示中点击显示包内容
--> SharedFrameworks --> DVTFoundation.framework --> Versions --> A --> Resources --> 找到symbolicatecrash文件,把它复制步骤2创建的文件夹中。
步骤4:在Xcode --> Window --> Organizer --> 选择 Archives --> 选择打包的应用 --> 选择Download dSYM (或者直接在Archives页面对应的包右键选择show in finder找到dSYM文件)。 把dSYM文件复制到步骤2创建的文件夹中
步骤5:把苹果反馈的.crash文件复制到步骤2创建的文件夹中
这个时候你的文件夹中应该有3个文件
步骤6:打开终端 CD到步骤2创建的文件夹下
步骤7:在终端中输入(attachment-1873276475927892228CrashLogsCollectorWorker-8390930539917002046.crash是苹果返回的.crash文件名 cytus2.app.dSYM是审核的包对应的dSYM文件 ,MyCrash.log是解析后结果返回的文件 )
./symbolicatecrash ./attachment-1873276475927892228CrashLogsCollectorWorker-8390930539917002046.crash ./cytus2.app.dSYM > MyCrash.log
注意:如果DEVELOPER_DIR,请在终端中先输入export DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer。在执行步骤7.
步骤8:文件夹中应该已经有了MyCrash.crash文件,这个就是我们解析出来的文件。打开就可以看见详细的错误内容
!!!注意:网上很多人说如果不使用打包时对应的dSYM文件会造成分析出来的位置不正确。可是我试过后发现,如果不是打包是对应的dSYM文件根本分析不出来。可能是苹果后门做了修改
- 方法2:不使用dSYM文件
之所以需要用dSYM来解析.crash文件是因为Xcode会在打包成ipa文件时除去Symbol Table(符号表)的非系统符号部分。导致地址无法对应函数名。这个方法是在网上看见大牛(iOS符号表恢复&逆向支付宝)提供的,我测试过确实可行,但注意其中但app包必须是审核的app包,应为包中的UDID无法对应会造成无法解析。
步骤1:查看苹果返回的.crash日志中的Code Type
,是ARM-64说明它的结构是arm64(确定结构是armv7、armv7s、arm64中的哪一种)
步骤2:查看Triggered by Thread
是 0 ,说明是0号线程崩溃的,那么就应该查看Thread 0 Crashed:
步骤3:在Thread 0 Crashed:
查看具体错误的栈地址
栈:是崩溃时调用的栈地址(虚拟内存地址,栈地址 = 基地址 + 偏移 )
基地址:基地址指向的地址是应用中这个模块加载到内存中的起始地址
偏移:十进制的偏移量
步骤4:找到Binary Images
,查看具体崩溃app的UUID。
步骤5:在打包的Xcode工程中找到对应的app文件show in finder,并用终端cd到该文件的位置。
步骤6:在终端中输入dwarfdump --uuid cytus2.app/cytus2
点击回车,会得到UUID,确认这里的UUID和刚刚步骤4查看的UUID是否相同,如果相同则说明是对应的app(cytus2是自己app的名称)
步骤7:在终端中输入atos -o cytus2.app/cytus2 -arch arm64 0x0000000100dd1be4
回车会得到该栈地址具体在代码中的位置。这样就找到了代码出错的地方(cytus2是应用名称 arm64是步骤1查看到的结构, 0x0000000100dd1be4是步骤3查看到的自己应用名下报错的栈地址)
.Crash文件结构说明
.crash文件中对应的名称 | 说明 |
---|---|
Incident Idnetifier | 崩溃报告的唯一标识符,不同的Crash |
CrashReporter Key | 设备标识相对应的唯一键值(并非真正的设备的UDID,苹果为了保护用户隐私iOS6以后已经无法获取)。通常同一个设备上同一版本的App发生Crash时,该值都是一样的。 |
Hardware Model | 代表发生Crash的设备类型,上图中的“iPad4,4”代表iPad Air,一般苹果审核都用的ipad |
Process | 代表Crash的进程名称,通常都是我们的App的名字, []里面是当时进程的ID |
Path | 可执行程序在手机上的存储位置,注意路径时到XXX.app/XXX,XXX.app其实是作为一个Bundle的,真正的可执行文件其实是Bundle里面的XXX,感兴趣的可以自己查一下相关资料 |
Identifier | 你的App的Indentifier,通常为“com.xxx.yyy”,xxx代表你们公司的域名,yyy代表某一个App |
Version | 当前App的版本号,由Info.plist中的两个字段组成,CFBundleShortVersionString and CFBundleVersion |
Code Type | 当前App的CPU架构 |
Parent Process | 当前进程的父进程,由于iOS中App通常都是单进程的,一般父进程都是launchd |
Date/Time | Crash发生的时间,可读的字符串 |
OS Version | 系统版本,()内的数字代表的时Bulid号 |
Report Version | Crash日志的格式,目前基本上都是104,不同的version里面包含的字段可能有不同 |
Exception Type | 异常类型 |
Exception Subtype | 异常子类型 |
Crashed Thread | 发生异常的线程号 |
Thread Backtrace | 发生Crash的线程的Crash调用栈,从上到下分别代表调用顺序,最上面的一个表示抛出异常的位置,依次往下可以看到API的调用顺序。上图的信息表明本次Crash出现xxxViewController的323行,出错的函数调用为orderCountLoadFailed。 |
Thread State | Crash时发生时刻,线程的状态,通常我们根据Crash栈即可获取到相关信息,这部分一般不用关心。 |
Binary Images | Crash时刻App加载的所有的库,其中第一行是Crash发生时我们App可执行文件的信息,可以看出为armv7,可执行文件的包得uuid位c0f……cd65,解析Crash的时候dsym文件的uuid必须和这个一样才能完成Crash的符号化解析。 |
网上还有一种解析.Crash文件的方法,是给Xcode添加插件的方式,本人没有用过,列出来大家参考下
程序稳定性防止崩溃
一般程序崩溃都是存在与对象打交道时发生异常情况,程序无法处理导致的。
就好比消息转发,当调用一个类或对象的方法时,系统会发送一个Send_Message消息。系统会在该类的方法链表中去查找是否有该方法,判断程序是否能响应该方法。如果不能,系统会在该类中去生成方法签名,如果能生成方法签名,就完成消息转发,如果不能生成就会调用在
- (void)doesNotRecognizeSelector:(SEL)aSelector
方法。从而crash。为什么会调用这个方法呢,因为我们可以通过在这个方法中做处理,来避免crash。程序之所以崩溃,是因为发生的情况让系统无法处理。所以我们只要对相应的异常情况做出相应处理。这样就能防止程序崩溃了。
NSArray、NSDictionary、NSMutableArray、NSMutableDictionary
的异常处理
NSMutableDictionary
NSMutableDictionary *dic = [NSMutableDictionary new];
//存值的时候如果值是nil的系统会直接crash
[dic setObject:nil forKey:@"123"];
//所以我们需要为NSMutableDictionary写一个category,添加一个方法,对存值进行判断
- (void)safeSetObject:(id _Nonnull)object forKey:(NSString * _Nonnull)key
{
if(!object)
{
return;
}
if(object == [NSNull class] || object == NULL )
{
return;
}
if(!key)
{
return;
}
if([key stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceCharacterSet]].length == 0)
{
return;
}
[self setObject:object forKey:key];
}
上面的category方法主要用于添加对象为object时,当加入字典对象固定为NSString类型时,可以直接用系统的方法- (void)setValue:(nullable ObjectType)value forKey:(NSString *)key
。该方法内部会自动判断value是否为空,为空时不会存入值到字典中。但仅适用于NSString类型。但该方法还是需要对Key的值进行判断
NSDictionary
id objetc1 = @"123";
id objetc2 = nil;
id objetc3 = nil;
id objetc4 = [NSMutableDictionary new];
NSString *key1 = nil;
NSString *key2 = @"12";
NSString *key3 = @"34";
NSString *key4 = nil;
//创建NSDictionary的时候会直接crash,因为有value或者ke 为nil
NSDictionary *dic = @{key1:objetc1,
key2:objetc2,
key3:objetc3,
key4:objetc4,
};
//所以我们需要一个方法来保证加入的value和key 不为nil
//检查每个value
//如果该value为nil并没有传入class时,返回空字符串。保证程序不会崩溃
//如果该value为nil并传入class时,可以根据传入的class 来创建对应class对象。
//既保证程序不会因为nil的value而crash,也不会在后面取值时因为取出的对象类型不对而造成操作错误。
- (id)checkObject:(id _Nonnull)object forClassValue:(Class)class
{
if(!object)
{
if (class) {
return [class new];
}else
{
return @"";
}
}
if(object == [NSNull class] || object == NULL )
{
if (class) {
return [class new];
}else
{
return @"";
}
}
return object;
}
//对于Key的判断
- (NSString *)checkString:(NSString *_Nonnull)string
{
if (![string isKindOfClass:[NSString class]]) {
//stringByTrimmingCharactersInSet是NSString方法,如果key原本不是string类型调用该方法会crash
return @"";
}
if (!string) {
return @"";
}
if(string == [NSNull class] || string == NULL )
{
return @"";
}
if ([string stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceCharacterSet]].length == 0) {
return @"";
}
return string;
}
NSMutableArray
NSMutableArray *array = [NSMutableArray new];
NSString *s = nil;
//添加s时会crash 因为为nil 同Dictionary一样。需要对添加的对象进行判断。
[array addObject:s];
对于NSMutableArray
、NSMutableDictionary
取值。尽量都要做相应的转换。一般情况下服务端传过来的数据类型都是协商好的。但要抱有对返回数据怀疑的态度去做判断处理。增加应用的健壮性
success:^(NSURLSessionDataTask * _Nonnull task, id _Nullable responseObject) {
NSMutableDictionary *dic = (NSMutableDictionary *)responseObject;
//当服务端传过来的type对象的类型本身就是string的时候,就会crash
//因为[dic objectForKey:@"type"]本身的类型就是 NSString,而NSString没有 stringValue这个方法。系统找不到stringValue方法消息转发失败,所以就会crash
NSString *type = [[dic objectForKey:@"type"] stringValue];
//因此我们在类型转换的时候要判断类型
if([dic objectForKey:@"type"] && ![[dic objectForKey:@"type"] isKindOfClass:[NSString class]])
{
NSString *type = [[dic objectForKey:@"type"] stringValue];
}
//一个接口有多个参数,就要判断多次。这样即使代码臃肿也让代码执行效率降低,不美观
//正常情况下都是与服务端同事协商好,每个接口参数的类型或者一开始就协商好所有数据都传string。
//但也会有比较坑的同事,啥都没有,而且数据类型还一直变。很尴尬的(特别是开发测试阶段)
//有时服务端会考虑到如果从数据库中取出来数据后在转换一次数据类型,会让效率降低。
//所以我们最好是直接把服务端返回的所有数据直接转成NSString。
//这样自己使用的时候根据情况自行转换。就不用担心服务端出什么毛病了
//最为直接的就是用stringWithFormat直接转,不用考虑用%@ 还是%ld
NSString *type = [NSString stringWithFormat:@"%@",[dic objectForKey:@"type"]];
};
对于Array
数组来说,在插入对象或者根据下坐标取值时,如果超过数组长度程序也会crash,因为数组越界了。最常见的就是在使用TableView等控件事,数据源是Array,取数据的时候越界。
很多时候涂方便使用self.tableArray[indexPath.row]
来取数据。这样是不好的。应该使用方法去取值。这样能在方法中进行判断防止crash。写一个Array的category来防止数据越界
- (id)safeObjectAtIndex:(NSUInteger)index
{
if (index >= [self count]) {
return nil;
}
id value = [self objectAtIndex:index];
if (value == [NSNull null]) {
return nil;
}
return value;
}
同上道理一样,在对String等对象 insertString、substringToIndex、substringWithRange、substringFromIndex做插入截取等操作时,也要判断是否存在下标越界的情况
NSMutableString *testString = [NSMutableString stringWithString:@"123456"];
if (testString.length >= 8) {
[testString insertString:@"-" atIndex:4];
[testString insertString:@"-" atIndex:7];
}
NSString *str = @"012345";
if (str.length > 8) {
[str substringFromIndex:8];
[str substringToIndex:8];
[str substringWithRange:NSMakeRange(5, 2)];
}
当然还有一种比较少用的方法,就是@try@catch。可以在预测会发生崩溃的地方加入,来抛出异常,防止崩溃
NSMutableDictionary *dic = [NSMutableDictionary new];
[dic setObject:@(1) forKey:@"123"];
@try
{
[[dic objectForKey:@"123"] stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceCharacterSet]];
}@catch(NSException *exception)
{
NSLog(@"发生了异常情况");
}