本文由 大侠自来也 翻译(泰然翻译组),转载请注明出处并通知泰然。
有这样一种情形:当我们正在快乐的致力于我们的app时,并且什么看都是无比顺利,但是突然,坑爹啊,它崩溃了。(悲伤地音乐响起)
我们需要做的第一件事就是:不要惊慌。
修复崩溃不是很困难的。假如你崩溃了,并且胡乱的改些东西,而且还在不停的念着咒语希望bug神奇的自动消失,你大多数情况下都会使情况更麻烦。相反的,你需要知道一些系统的方法,并且学习怎么找到崩溃和他的原因。
第一件需要知道的就是在你的代码中准确的找到crash发生的地方:在那个文件,那一行。Xcode debugger将会帮助你,但是你需要懂得怎么样最好的使用它,这也是这篇教程展示给你的。
这篇教程对于所有的开发者都是有利的。即使你是一个很有经验的ios开发者,你也可能会从中学习到一些你不知道的小窍门。
准备开始
下载这个例子程序。你将会看到这是一个有bug的程序。当你打开这个项目的时候,xcode会显示至少8个编译警告,这个通常都是危险的信号。顺便说一下,我们使用xcode4.3来做这篇教程,4.2的版本也应该没有什么问题。
注意:为了跟随这篇教程,这个编译生成的app需要运行在ios5的模拟器上面。假如你运行这个app到你的设备上,你也会崩溃,但是他们可能不会发生和教程一样的情况。
在模拟器上面运行你的app,你将会看到发生了什么。
嘿,他崩溃了。
有两种最基本的crash类型常发生:SIGABRT(也叫EXC_CRASH)和EXC_BAD_ACCESS(也可能会是SIGBUS或者SIGSEGV)。
就crash而言,SIGABRT是一个比较好解决的,因为他是一个可掌控的crash。App会在一个目的地终止,因为系统意识到app做了一些他不能支持的事情。
EXC_BAD_ACCESS是一个比较难处理的crash了,当一个app进入一种毁坏的状态,通常是由于内存管理问题而引起的时,就会出现出现这样的crash。
幸运的是,第一种崩溃(也是大多数崩溃)是SIGABRT,SIGABRT通常会在xcode的Debug Output窗口(在窗口的右下角)输出一些错误的信息。假如你没有看到Debug Output窗口,在你的xcode窗口的右上角一组图标中点击中间那个,假如还是没有看到Debug Output窗口,你需要点击这个小窗口的右上角的中间那个图标,他靠近搜索框。在这个情况下,会展示一些下面东西:
Problems[14465:f803] -[UINavigationController setList:]: unrecognized selector sent to instance 0x6a33840 Problems[14465:f803] *** Terminating app due to uncaught exception 'NSInvalidArgumentException', reason: '-[UINavigationController setList:]: unrecognized selector sent to instance 0x6a33840' *** First throw call stack: (0x13ba052 0x154bd0a 0x13bbced 0x1320f00 0x1320ce2 0x29ef 0xf9d6 0x108a6 0x1f743 0x201f8 0x13aa9 0x12a4fa9 0x138e1c5 0x12f3022 0x12f190a 0x12f0db4 0x12f0ccb 0x102a7 0x11a9b 0x2792 0x2705) terminate called throwing an exception
了解这些错误消息是非常重要的,因为他们包含了错误在那里的重要线索,一下就是需要关注的部分:
[UINavigationController setList:]: unrecognized selector sent to instance 0x6a33840
“unrecognized selector sent to instance XXX” 这条错误消息意味着你的app正在试着执行一个不存在的方法。这种情况的发生,主要是都是一个方法被错误的对象调用了(也就是这个对象没有这个方法,但是你调用了他,就错了)。例如在这里这个问题上,对象就是UINavigationController (在内存地址0x6a33840上),方法就是setList:。
知道crash的原因是很好的,但是你的第一行动目的就是指出这个错误的发生在代码的那个地方。你需要找到源文件的名字和这个错误方法在那一行。你通过使用call stack(就像堆栈跟踪(stacktrace)或者回溯(backtrace))就可以知道这些东西。
当你的程序crash了时,在xcode窗口的左边小窗口会启动Debug Navigator(调试导航)。他会展示在这个app中那个线程是活动的,并且高亮显示crash了的线程。通常他会是线程1,这个app的主线程,这个线程也是你会做最多工作的线程。假如你的代码里面使用了队列(queues)或者后台线程(background threads),这个app也可能会在其他的线程里面崩溃。
当前xocde就高亮显示了main.m里面的main()函数。但是那些东西并没有告诉你很多,所以你需要继续的向深层次的挖掘。
为了看到堆栈的更多信息,拖拽Debug Navigator底部的滑块到最右边。它将会展示出崩溃时全部的堆栈信息:
这个列表里面的每一项都是一个来这个app或者ios的framework里的方法或者函数。堆栈展示了当前活跃在这个app里面的方法或者方法。调试器(debugger)已经暂停了这个程序,并且所有的这些方法和函数在这个时候也被冻结了。
在底部的函数start(),第一个被调用。在他的执行里面的有些地方,,main()函数在他之前。(Somewhere in its execution it called the function above it, main().)。他是应用程序的开始入口点,并且它经常在底部附近。Main()也叫UIApplicationMain()(这个针对的是ios哈,并不是其他所有程序都是这样的)。在这个编辑窗口里面用绿色箭头指示的那一行(就是在这个教程最开始前面程序崩溃时停止在那个图片上,高亮显示的部分)。
进一步来看看这个堆栈,UIApplication()在UIApplication对象里调用_run方法,_run方法里面又调用CFRunLoopRunInMode()方法,CFRunLoopRunInMode()方法里面又调用CFRunLoopSpecific()方法,就这样一直向下调用,一直到__pthread_kill。
所有在这个堆栈里面的函数和方法都是灰色的,除了main()函数。那是因为他们都来自内置的ios frameworks(ios内置框架)。所以没有针对他们可见的源码。
在这个堆栈里面唯一的东西就是你有main.m的源码,因此xcode的代码编辑器就显示了它,即使他不是这个崩溃的真正原因。但是这个经常混淆初学者,但是马上我将展示怎么样来弄懂它。
开个玩笑,点击这个堆栈里面的任意一项,你将会看到许多的汇编代码,这些你可能完全不理解:
加入我们得到那样的源码,我想很多人都会说:坑爹啊。
异常断点
你怎么样找到是代码里面的哪一行使app崩溃的?无论什么时候,你得到的一个想这样的堆栈路径,一个异常通过这个app抛出。(你多半会说因为堆栈里面有一个函数叫objc_exception_rethrow。)
当程序由于做了一些他不能完成的事情时,一个异常就会发生。你所看到的就是这个异常的结果:app做了一些错的事情,异常被抛出,xcode展示异常的结果。理想情况下,你想要的准确的看到异常在那里抛出的。
幸运的是,通过使用Exception Breakpoint(异常断点),你可以告诉xcode在一个特定的时候暂停这个程序。断点是一个在特定时刻暂停你的程序的调试工具。你将会第二篇教程里面看到更多关于他们的信息,但是现在你将会使用一个特殊的断点,它将会在抛出异常前暂停你的程序。
为了设置异常断点,我们不得不切换到Breakpoint Navigator(断点导航器):
在底部有一个小的加号(“+”)按钮。点击它,并且选择Add Exception Breakpoint:
一个新的断点将会被增加到这个列表里:
点击Done按钮使弹出的窗口消失。注意在xcode工具栏上面Breakpoints button(断点按钮)是有效的。加入你不想要带着任何断点运行你的app,你可以简单的开关这个按钮到off。但是现在,让它打开,并且再一次运行这个app。
太好了!代码编辑器现在停止并且指到了代码中的其中一行,不再在令人烦躁的汇编代码了,并且注意在在左边的的Debug Navigatot(调试导航器)里面显示的堆栈信息也不一样了。
显然的,问题就出在AppDelegate里面的application:didFinishLaunchingWithOptions:方法里:
viewController.list = [NSArray arrayWithObjects:@"One", @"Two"];
仔细再次看看这个错误消息:
[UINavigationController setList:]: unrecognized selector sent to instance 0x6d4ed20
在这个代码里面,“viewController.list = something”这种方式隐式的调用了setList:方法,也就是set方法,因为“list”是MainViewController类的一个属性。然而,通过这个错误消息,我们知道viewController这个变量没有指向MainViewController对象,而是指向了UINavigationController,所以显然的,UINavigationController没有“list”属性!所以这些变量在这里混淆了。
打开Storyboard文件,看看window的rootViewController属性实际上是指向那个的:
哈哈!Storyboard的最初的view controller是一个Navigation controller。这就是为什么window.rootViewController是一个UINavigationController对象,而不是你自认为的MainViewController。为了修改这里,使用下面的代码来替代application:didFinishLaunchingWithOptions:里面的:
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions { UINavigationController *navController = (UINavigationController *)self.window.rootViewController; MainViewController *viewController = (MainViewController *)navController.topViewController; viewController.list = [NSArray arrayWithObjects:@"One", @"Two"]; return YES; }
通过代码可以看出,首先你通过self.window.rootViewController得到UINavigationController,一旦你得到了上面的。你就可以通过请求navigation controller来得到topViewController,进而得到MainViewController。现在viewController变量就是指向了正确的对象了。
注意:一旦你得到“unrecognized selector sent to instance XXX”错误,你就需要检查这个对象是不是正确类型,并且检查它真的是有那个名字的方法么。你会经常发现你调用一个你认为是这个对象的方法,因为指针变量可能没有包含这个正确值,所以导致很多的错误。
另外一个经常出现错误的原因就是将方法名称拼写错误。一会儿你将会看到一个这样的例子。(译者:我个人认为有xcode的代码提示功能,这种错误应该还是比较少吧,多数应该出现在通过selector,或者传递函数指针的时候,应该会多点这个错误)。
你的第一个内存错误
你可能已经修复了你的第一个问题。再一次运行这个程序。坑爹啊,在同样的一行,又崩溃了,但是现在是EXC_BAD_ACCESS错误。那意味着这个app有内存管理的问题。
一个和内存相关的崩溃一般很难定位到源代码,因为这个恶魔可能很早就在程序中做了坏事了。假如一段有问题的代码混乱了内存结构,这样产生的蝴蝶效应可能会在之后很久才表现出来,并且总在不同的地方。
实际上,在你所有的测试中,这个bug可能永远不会出现,但是却在你的客户的设备上展露出它丑陋的脑袋。这种是很多人都不想的。
这种特别的崩溃但是却很容易修复。假如你看到你的代码编辑器,xcode其实一直就在警告你这一行代码。看到左边靠近行号的那个黄色三角形没有?那个指出一个编译警告。假如你点击那个黄色的三角形,xcode将会弹出一个“Fix-it”的建议,就像下面的一样:
这个代码使用了一系列的对象来初始化一个数组(NSArray),并且像那样的一系列的对象应该使用nil来终止,这个警告的标记就是想要表达一个这样的意思。但是代码却没有那样做,所以NSArray就很困惑,很迷茫。它试着读取一个不存在的对象,最后这个app艰难的崩溃了。
这种错误,你真的不应该犯,特别是xcode已经警告了你。修复这个错误,通过像下面一样增加一个nil(或者你可以简单的选择刚刚弹出来的菜单里面“Fix-it”):
viewController.list = [NSArray arrayWithObjects:@"One", @"Two", nil];
“This class is not key value coding-compliant”
重新运行这个程序,看看为你准备的其他有趣的bug。信不信由你?它又在main.m里面崩溃了。虽然Exception Breakpoint任然起作用了,但是我们没有看见任何高亮的程序代码,这次的崩溃真的没有发生在任何程序代码里。这个调用堆栈证实了这点:这里面的方法没有一个属于的程序的,除了main():
假如你从上到下浏览一下这些方法的名字,有些问题发生在NSObject和Key-Value Coding。在那之下调用了[UIRuntimeOutletConnection connect]。我不知道那个是干什么的,但是看起来好像它做了连接outlet的一些事情。在那之下的一些方法是从nib中加载view。因此以上那些也给你一些线索。
但是,在xcode的调试窗口,并没有易懂的错误消息。那是因为没有异常被抛出。在xcode告诉你异常的原因之前,Exception Breakpoint已经暂停了这个程序。有些时候你会从Exception Breakpoint得到一些局部的错误消息,但是有些时候就得不到。
为了得到全部的错误消息,点击调试器工具栏上的“Continue Program Execution”按钮:
你可能需要点击好几次才可以,然后你将会得到错误消息:
Problems[14961:f803] *** Terminating app due to uncaught exception 'NSUnknownKeyException', reason: '[ setValue:forUndefinedKey:]: this class is not key value coding-compliant for the key button.' *** First throw call stack: (0x13ba052 0x154bd0a 0x13b9f11 0x9b1032 0x922f7b 0x922eeb 0x93dd60 0x23091a 0x13bbe1a 0x1325821 0x22f46e 0xd6e2c 0xd73a9 0xd75cb 0xd6c1c 0xfd56d 0xe7d47 0xfe441 0xfe45d 0xfe4f9 0x3ed65 0x3edac 0xfbe6 0x108a6 0x1f743 0x201f8 0x13aa9 0x12a4fa9 0x138e1c5 0x12f3022 0x12f190a 0x12f0db4 0x12f0ccb 0x102a7 0x11a9b 0x2872 0x27e5) terminate called throwing an exception
就像之前的一样,你可以忽略下面的那些数字。他们展示了调用堆栈,但是在调试导航器的左边有更加直观的堆栈调用展示。
有趣的部分是:
NSUnknowKeyException
MainViewController
“this class is not key value coding-compliant for the key button”
这个异常的名字为NSUnknownKeyException,它是这个错误很好的指示器。它告诉你在某个地方有一个“unknown key”。这个某一个地方通常就是MainViewController,并且这个key就是“button”。
既然我们已经确定了,所有这些都是发生在装载nib的时候。这个应用使用的是storyboard,而不是nib文件,但是其实storyboard内部就是nib的集合(也就是可以有很多的nib),因此这个错误就在这个storyboard中。
检查一下MainViewController的outlets:
在Connections Inspector(连接检测器)里,你可以看见在viewcontroller中间的UIButton是连接到MainViewController的“button”outlet上的。因此storyboard引用了一个名叫“button”的outlet,但是通过这个错误消息说明它找不到这个outlet。
让我们来看看MainViewController.h:
@interface MainViewController : UIViewController @property (nonatomic, retain) NSArray *list; @property (nonatomic, retain) IBOutlet UIButton *button; - (IBAction)buttonTapped:(id)sender; @end
这里是为这个“button”定义了外部连接属性的(@property),因此这个问题是什么呢?假如你仔细观察了编译警告的话,你可以已经知道是什么地方的问题了。
假如还不知道的话,检查一下MainViewController.m的@synthesize的内容的话。你现在看出问题没有啊?
这个代码其实没有@synthesize这个button的属性。它(@synthesize)其实是告诉MainVIewController他自己有个“button”的属性,提供一个后台实例变量,并且提供getter和setter方法(这就是@synthesize所做的)。
把下面的增加到MainViewController.m里面已经存在的@synthesize行的下面来修复这个问题:
@synthesize button = _button;
现在这个app应该不会在你运行的时候崩溃了!
注意:“this class is not key value coding-compliant for the key XXX”的错误经常都是由于你装载这个nib,但是里面引用的一些熟悉可能不存在。特别是当你在代码中移除了outlet属性后,但是你却没有在nib中移除这个连接。
Push the Button
现在这个app正常工作,或者至少说启动的时候没有问题。是时候来点击这个按钮了。
哇!这个app崩溃在main.m里面,并且伴随着SIGABRT。在调试窗口打印出的错误消息是:
Problems[6579:f803] -[MainViewController buttonTapped]: unrecognized selector sent to instance 0x6e44850
堆栈跟踪也不是很有启发。只是列出了一些和一个方法相关的或者发送了事件并且执行了动作的方法,但是你已经知道到了被涉及的动作了。毕竟,你点击了一个按钮,这个按钮的IBAction方法应该被调用。
当然你之前应该已经看过了这个错误消息。一个被调用的方法不存在。这个时候目标对象应该是MainViewController,由于动作方法经常存在于一个包含按钮的view controller里面,所以这个看起来是正确。并且你看MainViewController.h文件,这个IBAction方法确实在里面:
- (IBAction)buttonTapped:(id)sender;
是这样的吗?错误消息显示这个方法的名字是buttonTapped,但是MainViewController的方法却是buttonTapped:(注意冒号),由于这个方法需要接受一个参数(名字是sender),所以在方法名字后面有个冒号。从这个错误消息看出,这个方法没有冒号,因此不需要参数。所以这个方法看其实应该是这样的:
- (IBAction)buttonTapped;
这个里发生了什么?这个方法最初的时候不需要参数(有些时候这样动作方法是被允许的),并且在那个时候,他为这个按钮在storyboard里面连接了Touch Up Inside的时间方法。然而,在那之后某个时候,这个方法的形式被修改为包含了一个“sender”参数,但是,却没有去更新storyboard。
你可以在storyboard里面看看,在这个按钮的连接检测器:
第一,断开Touch Up Inside 事件(点击这个小“X”),然后再次连接它到MainViewController里,但是这次选择这个buttonTapped:方法。注意在连接检查器里面看看这个方法后面是有一个冒号的。
运行这个app,再一次点击按钮。我们又得到了这个“unrecognized selector”消息,但是这次他正确的定位到了buttonTapped:方法里面。
Problems[6675:f803] -[MainViewController buttonTapped:]: unrecognized selector sent to instance 0x6b6c7f0
假如你仔细看的话,编译器警告应该又给你指出解决方案。Xcode提出MainViewController的实现是不完整的。特别的,buttonTapped:方法没有被发现。
是时候看看MainViewController.m了,在这里确实是有buttonTapped:方法啊……………..等等,拼写错误了:
- (void)butonTapped:(id)sender
很简单的修改,重命名这个方法:
- (void)buttonTapped:(id)sender
提示:你没必要声明这个方法为IBAction,假如你觉得这样是很优雅的,你可以这样做。
注意:假如你仔细注意到这些编译器警告的话,这些问题很容易看出来的。就个人而言,我把所有的警告当成严重的错误(在xcode里面的编译设置(Build Settings)里面可以设置警告作为错误提示的),在运行程序以前,我会修改所有的。Xcode在指出愚蠢的错误表现的相当好,就像这里这样,并且注意到这些提示是很明智的。
Messing with Memory(混乱内存)
经过了这么多,你知道崩溃一直在继续从未停止过。运行这个app,点击按钮,然后等待崩溃。好,现在就来了:
这里是另一种EXC_BAD_ACCESS崩溃。幸运的是,xcode已经准确给你指示出位置在那里了,在这个buttonTapped:方法里面:
NSLog("You tapped on: %s", sender);
有些时候,你可能在上面花费一些时间才会反应过来,但是xcode提供了帮助,仅仅需要点击这个黄色的三角形来看这个错误是什么:
NSLog呈现一个Objective-c类型的字符串,而不是一个c字符串,因此插入一个@符号来修改它:
NSLog(@"You tapped on: %s", sender);
你将会注意到这个警告的黄色三角形依然没有消失。这是因为在这行还有另外一个bug,这个bug可能会或者可能不会使你的程序崩溃。有些时候这个代码工作很好,或者现在看起来很好,但是有些时候他就会崩溃。(特别是有些时候只在你的客户的设备上面,绝不会在你的设备上)。
让我们来看看这个新的警告:
这个“%s”专门为c语言类型的字符串。一个c类型的字符串就是把这个内存分成片段(一个老式的字节数组),通过一个所谓的”NUL character”(其实就是一个为0的值)来终止。例如这个“Crash!”看起来就是这样的:
无论是什么时候,你使用一个函数或者方法来操作这个c类型的字符串,你不得不确定这个字符串是以一个0值来结尾的,否则这个函数将不知道这个字符串已经结束了。
现在来看看,当你指定了在NSlog()中用“%s”来格式化字符串,或者在NSString 的stringWithFormat里面,这个变量将会被当做是一个c类型的字符串。假如这个“sender”指向一个包含0字节的内存,这个NSlog()将不会崩溃,但是输出的东西就会像这样:
You tapped on: xËj
再一次运行这个app,点击这个按钮,等待它崩溃。现在在Debug窗口的左边部分,右击“sender”,并且选择“view Memory of “*sender””选项(确保是选择的是带有星号的sender)。
Xcode将会展示出这个内存地址的内容,恰恰这个就是NSlog()打印出来的内容。
然而,这里并不能保证这里有空位(结束标志位),所以你完全很容易执行到一个EXC_BAD_ACCESS的错误。 假如你经常在模拟器上面测试的话,这个很长时间都可能不会发生,然而这种情况一般都是在很特殊的情况环境下就可能发生。所以这种类型的bug很难跟踪。
当然,在这种情况下,xcode已经警告你这个错误的格式化字符串,因此这个特别的bug是很容易发现的。但是无论什么时候,你使用c类型的字符串或者手动直接操作内存的,都应该非常的小心的不要混乱了其他的内存。
假如你非常的幸运,这个app将会经常崩溃,这个bug很容易找到,但是通常情况是这个app会崩溃在某个时候,而且这个问题很难重现!之后寻找这个bug将会是一个史诗般的工程,十分麻烦。
修复这个NSLog()的形式,就像下面的一样:
NSLog(@"You tapped on: %@", sender);
运行这个app,并且再一次点击这个按钮。现在这个NSLog()做了他能做的了,并且看起来好像不会崩溃在buttonTapped:这个函数里面了。
和调试器交朋友(Making Friends With the Debugger)
看看这最近的崩溃,xcode指示到了这一行:
[self performSegueWithIdentifier:@"ModalSegue" sender:sender];
在Debug窗口里面没有消息打印出来。你可以点击这个继续执行这个程序的按钮,就像前面一样,但是你也可以在调试器里面输入一个命令来得到这个错误消息。这样做的好处就是,这个app可以保持暂停在这个同样的地方。
假如你准备在模拟器里面运行这个,你可以在“(lldb)”提示的后面输入下面的:
(lldb) po $eax
LLDB在xcode4.3或者之后的版本里面是默认的调试器。假如你正在使用老一点版本的xcode的话,你又GDB调试器。他们有一些基本的相同的命令,因此假如你的xcode使用的是“(gdb)”提示,而不是“(lldb)”提示的话,你也能够更随一起做,而没有问题。
“po”命令是“print object”(打印对象)的简写。“$eax”是cup的一个寄存器。在一个异常的情况下,这个寄存器将会包含一个异常对象的指针。注意:$eax只会在模拟器里面工作,假如你在设备上调试,你将需要使用”$r0″寄存器。
例如,假如你输入:
(lldb) po [$eax class]
你将会看像这样的东西:
(id) $2 = 0x01446e84 NSException
这些数字不重要,但是很明显的是你正在处理的NSException对象在这里。
你可以对这个对象调用任何方法。例如:
(lldb) po [$eax name]
这个将会输出这个异常的名字,在这里是NSInvalidArgumentException,并且:
(lldb) po [$eax reason]
这个将会输出错误消息:
(unsigned int) $4 = 114784400 Receiver () has no segue with identifier 'ModalSegue'
注意:当你仅仅使用了“po $eax”,这个命令将会对这个对象调用“description”方法和打印出来,在这个情况下,你也会得到错误的消息。
因此解释下那是什么情况:你正在尝试执行一个名叫“ModalSegue”的segue,但是很显然,在MainViewController里面并没有这样的的segue。
Storyboard展示出来这个segue是存在的,但是你忘记了设置它的标示,一个典型的错误:
改变这个segue的标示为“ModalSegue”。再一次运行这个app,等待一下,点击这个按钮 ,这个时候不再有crash了!但是这里只是我们下个部分的开端——-tableview不应该是空的!
何去何从?
在第二部分的教程里面,我们将会遇到更多的bug。并且学习到更多关于调试的工具,包括NSLog()陈述,断点和僵尸对象。