這篇文章的作者是iOS Tutorial Team 的成員Matthijs Hollemans,他是一個經驗豐富的ios設計開發工作者,他的聯系方式:Google+ 和 Twitter
閱讀原文:原文鏈接
我們大多數開發人員經常遇到這樣的情況:我們的應用運行的好好的,突然——“砰”一下子crash了,抓狂!
別慌!
假如此時很郁悶的你立即開始嘗試修改代碼,并期望如同尋找到了合適的咒語一樣使得bug神奇消失,那么很可能導致更糟糕的問題。但是如果掌握了系統的定位crash問題的方法的話,解決crash問題也不是很復雜的事情。
首要的事情,是找到代碼中crash出現的確切位置:哪個文件的哪行代碼。本文將全面的闡述如何利用Xcode的調試工具定位代碼的奔潰位置。
本文面向所有的開發者,從初級到高級。即便是高級開發者,也可能通過本文獲得一些調試技巧或者以前不曾涉及的調試知識。
一、準備工作
下載示例程序.這是個有bug的程序。用Xcode打開,可以看到有至少八處編譯警告,通常編譯警告也是問題的前期表現。本文中用的Xcode4.3來做說明,但實際上在4.2的版本也是一樣的。
注: 本文的示例程序效果是在IOS5的模擬器上運行的效果,如果直接在手機上運行,同樣也會crash,但是crash出現的順序可能會有所不同
在模擬器上運行看看發生了什么情況。

嗯,代碼crash了。:-)
通常crash分兩種:SIGABRT (或者 EXC_CRASH) 和 EXC_BAD_ACCESS (通常用 SIGBUS 或 SIGSEGV表示的crash)。
SIGABRT的Crash通常情況下好定位很多,因為它是受控的crash(系統讓app去執行某個app本身并不支持的操作時,應用終端就會直接拋出該信號,讓程序crash)。
EXC_BAD_ACCESS的crash定位就要難很多。這種crash經常發生在應用進入了一個損壞狀態時,通常是由內存管理問題導致。
幸運的是,我們上面的第一個crash(目前暴露出來的)是SIGABRT。SIGABRT的crash發生時,一般在Xcode的調試輸出窗口(窗口的右下角)會有錯誤信息輸出。如果你看不到調試輸出窗口,通過View—》Debug Area-》Show Debug Area顯示調試輸出區域。此處,這個crash的錯誤信息如下:
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”的意思是說應用嘗試調用一個不存在的方法。通常是由調用方法的對象不正確導致。此處有問題的對象是一個UINavigationController對象(內存地址為0x6a33840),調用的方法是setList:。
知道了crash的原因就好了,當前的首要任務就是找出代碼中出錯的地方。必須找到源文件名字以及出錯行數。可以借助調用堆棧(also known as the stacktrace or the backtrace)。
當一個應用crash,Xcode窗口左側的區域會切換進入調試導航頁。顯示出當前應用中活動狀態的線程信息,并高亮標出crash掉的線程。 通常都是應用的主線程Tread 1,因為大部分的業務工作在這個線程中完成的。如果應用中使用了隊列或后臺線程,崩潰也會出現在這些線程中。

目前,Xcode會自動標出出錯點在main.m文件中的main()函數中。此處并不會提供更多信息,所以我們必須更深入一些尋找線索。
查看更多的堆棧信息,往右側拖動堆棧信息下方的滑塊,這樣將會完整顯示當前crash掉的線程信息:

列表中的每一條都是一個應用中或者某一個IOS frameworks的函數或方法。堆棧信息能顯示出應用中當前還處于活動狀態的方法和函數。調試器暫停了應用中斷了這些函數和方法的執行。
最后一條函數start()是入口。這個函數執行的過程中調用了它上面的函數, main()。是應用的入口點,經常顯示在靠近底部的位置。main() 調用了 UIApplicationMain(),就是編輯窗口中綠色箭頭指向的代碼行。
查看更深入的堆棧信息,UIApplicationMain() 調用了 UIApplication 對象的 _run 方法。_run調用了CFRunLoopRunInMode(),CFRunLoopRunInMode()又調用了CFRunLoopRunSpecific(),這樣層層調用直到 __pthread****_kill。

除了main(),調用堆棧中所有的函數和方法全部是灰化顯示。這是因為這些信息都來自編譯好的iOS庫,沒有有效的源代碼導致。
堆棧中的源代碼文件只有main.m,所以盡管main.m并不是真正引入crash的文件,但是Xcode文件編輯器提示崩潰的點還是在這個源文件里。這個經常弄迷糊新手,所以本文中將快速給出一個方便大家理解的途徑。
點擊堆棧信息中其他的任何一條信息,都能看到一堆毫無頭緒的匯編代碼:

哦,如果有源代碼就好了! :-)
二、異常斷點
因此到底該如何找到crash的代碼行呢?不論何時出現了上面的堆棧信息的時候,都是app拋出了異常。(也可以說是堆棧上調用到了objc_exception_rethrow函數)應用做了不該做的事情就會拋出異常。目前關注的是這個異常導致的結果:app做了些不應該做的事情,拋出了異常,Xcode將其呈現出來。我們想確切知道到底是哪里拋的異常。
幸運的是,Xcode還可以打全局斷點暫停程序。斷點可以幫助開發人員在某個場景暫停程序執行,本文的第二部分將會詳細闡述這塊。這里需要使用的是一種特殊的斷點——全局斷點,程序crash前進入全局斷點。
可以進入斷點導航頁設置全局斷點:

點擊底部的小+ 按鈕,選擇Add Exception Breakpoint:

新的全局斷點就添加好了:

點擊確定按鈕退出彈框提醒。此時Xcode的工具欄的斷點按鈕現在變成可用狀態。如果你想程序跑起來不進任何斷點的話,可以點擊這個斷點按鈕關閉斷點,但是現在,打開斷點并運行app。

好了,代碼編輯器現在不再是不再是匯編代碼,而是指向了源代碼,同時注意看左側的堆棧信息(是否切換出來堆棧信息,由Xcode的設置決定)也發生了變化。
明顯,問題指向AppDelegate的application:didFinishLaunchingWithOptions:方法中下面這行代碼:
viewController.list = [NSArray arrayWithObjects:@"One", @"Two"];
再來看看錯誤信息:
[UINavigationController setList:]: unrecognized selector sent to instance 0x6d4ed20
代碼中的“viewController.list = something”調用了setList:,因為“list”是MainViewController類的一個屬性。盡管在錯誤處看viewController并不是指向MainViewController對象的實例而是指向了UINavigationController,當然UINavigationController根本沒有一個“list”屬性。又混亂了!
打開Storyboard查看窗口的rootViewController屬性實際是這樣:

啊哈!storyboard的初始控制視圖是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” 錯誤, 首先檢查對象的類型是不是正確以及被調用的方法究竟是否存在。經常遇到的情況是指針并沒有指向正確的值導致實際調用的壓根就是預期外的其他類型對象的方法。
調用堆棧信息
另外,方法名拼寫錯誤也會導致出現該問題。稍后將會給出一個這樣的示例程序。
三、你的第一個內存錯誤
第一個問題修復了,再來運行應用看看。 喔,又在同一行Crash了, 只不過這次是 EXC_BAD_ACCESS 錯誤。看樣子,該應用還有內存處理上的問題。

定位內存相關的crash相對來說會比較困難,因為隱患可能出現在崩潰前已經運行很久的其他代碼中。有問題的代碼破壞了內存結構,并不會立即在程序中體現出來,而是到一段時間后,其他地方再訪問這段內存有問題了,才會爆出crash出來。
而且事實上,可能測試的時候這種bug根本就不會暴露出來,最終往往暴露在用戶的手機上。誰也不想出現這種情況。然而這種類型的crash也是比較容易處理的。如果仔細查看代碼編輯框就會發現,Xcode原來早就警告我們這些存在內存處理不妥當的代碼行了。看見代碼左側行標上的黃色三角形了么?那就是一個編譯警告。點擊黃色的三角形,Xcode會自動彈出一個“Fix-it”的修復建議,如下圖:

此處用一種對象的序列來初始化NSArray對象,這種序列需要以nil結尾,但是此處并沒有以nil結尾,編譯器不知道該何處是序列的結尾,所以就報了這個警告出來。運行時,因為沒有明顯的結尾標志,所以系統會在讀完了所有的參數后,還嘗試獲取并不存在的對象,添加到序列中,所以就崩潰了。
這種錯誤實在不應該犯,特別是Xcode已經給出警告后。修復這個bug可以像下面代碼一樣,給序列添加nil結尾項(或者,直接點擊“Fix-it”):
viewController.list = [NSArray arrayWithObjects:@"One", @"Two", nil];
四、“這個類不符合鍵值編碼”
在運行代碼,看還有哪些其他有趣的bug。但是你是怎么知道的?它又一次崩潰在了main.m中。盡管全局斷點還有效我們卻看不到任何高亮的代碼提示,這次代碼的crash實實在在沒有發生在我們應用的源代碼中。堆棧信息證實,除了main(),沒有一個方法屬于我們的應用:

自上而下查看方法名,可以發現有些事情發生跟NSObject和鍵值編碼(Key-Value Coding)有關系。其下,是對[UIRuntimeOutletConnection connect]的調用。不知道該怎么辦,但是看上去好像跟綁定(connect outlets)有關系.再往下調用的方法是從nib中加載view。這已經給出了線索。然而Xcode的調試窗還沒有很方便定位的錯誤信息,因為系統尚未拋出異常。全局斷點僅僅會在程序告訴你異常原因前中斷程序。有時候你會獲取到一個明確的錯誤信息,有時候并不能獲取到。
想看到完整的錯誤信息,點擊調試窗口工具欄的“Continue Program Execution”按鈕:

有時候可能需要多點幾下,才能看到打印出來的錯誤信息:
Problems[14961:f803] *** Terminating app due to uncaught exception 'NSUnknownKeyException',
reason: '[<MainViewController 0x6b3f590> 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
和之前一樣忽略下方的數字。它們表示的是調用棧信息,但是我們已經有關于它們的更方便和可讀的信息了!就是左側的調試導航頁面。
有趣的信息如下:
- NSUnknownKeyException
- MainViewController
- “this class is not key value coding-compliant for the key button”
異常的名字NSUnknownKeyException通常能很好的指示出問題原因。比如此處,就告訴我們代碼某處使用了系統不知道的“鍵值(unknown key)”。這里的某處很明顯是MainViewController,而且鍵值名應該就是“button”。
可以確定,問題就發生在加載nib的時候。雖然應用直接使用的是storyboard,但是更深入些storyboard實際就是所有nib的集合,所以問題應該就處在storyboard中。
檢查MainViewController中的所有outlet:

在鏈接監測區,可以看到試圖控制器中心的UIButton被鏈接到MainViewController的“button”了。所以storyboard/nib有一個出口叫“button”,但是根據錯誤信息看的話,實際根本沒有這個出口。
看看MainViewController.h:
@interface MainViewController : UIViewController
@property (nonatomic, retain) NSArray *list;
@property (nonatomic, retain) IBOutlet UIButton *button;
- (IBAction)buttonTapped:(id)sender;
@end
此處@property 定義了名為 “button” 出口, 所以到底是怎么回事? 如果你注意到編譯警告,或許就不難找到問題癥結了。
即使沒有,檢查下MainViewController.m文件中的@synthesiz列表。現在找到問題了么?
代碼并沒有準確的@synthesize按鈕屬性。它告訴MainViewController有一個名字叫“button”的屬性,但是卻并沒有提供實例變量以及存取方法(這些都是由@synthesize完成的)
在MainViewController.m中的@synthesize之下添加如下代碼來處理這個問題:
@synthesize button = _button;
現在運行程序將不再crash了!
注: “this class is not key value coding-compliant for the key XXX” 錯誤經常出現在加載聲明但是并未實現的屬性的nib時。一般當從源文件中刪除了一個outlet屬性,但是并沒有從nib去掉隊形鏈接時,就會出現這中錯誤。
五、點擊按鈕
現在應用可以運行了,或者說至少啟動沒問題了,來,點擊按鈕試試。

哇哦,應用崩潰在main.m中,還報了個SIGABRT錯誤信息。調試窗口的錯誤信息如下:
Problems[6579:f803] -[MainViewController buttonTapped]: unrecognized selector sent
to instance 0x6e44850
堆棧信息并不很明了,它列出了所有的可能通過這樣那樣途徑發消息或執行操作的方法,但是已經可以知道哪個操作有問題。畢竟這里是點擊了UIButton,調用IBAction method時出的問題。
當然,之前已經遇到過類似問題了。因為調用了一個并沒有實現的方法導致。不過這次目標對象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按鈕),接著將其再一次鏈接到主視圖控制器,不過這次選擇buttonTapped:方法。注意,這時鏈接窗口中的方法名末尾是包含了冒號的。
再運行程序后點擊按鈕。什么鬼?盡管現在已經使用了有冒號的正確格式的點擊方法buttonTapped:,還是報“unrecognized selector”錯誤信息,
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會使代碼看上去整潔,但是也沒有必要非將方法定義成IBAction。
注:如果留神編譯器的警告的話,這章節的問題都是比較容易定位的。就個人來說,我是將所有的警告都當做錯誤來處理 (在Xcode的編譯設置頁:Build Settings screen有一個設置項,將警告當做error) ,所以我會在程序運行前處理修復掉所有的警告信息。 Xcode能很好的指出如上的低級錯誤,留神這些警告信息能達到事半功倍的效果。
六、內存信息
繼續之前的操作:運行程序,點擊按鈕,等待崩潰。是呢,不負所望:

好驚訝,這次是EXC_BAD_ACCESS錯誤中的另一種。幸運的是,Xcode告訴我們崩潰發生的位置在buttonTapped:方法中這一行:
NSLog("You tapped on: %s", sender);
有時候,這種問題會讓我們反應不過來發生了什么,同樣不用擔心,Xcode提供了幫手,點擊黃色的三角形看看有什么錯誤:

NSLog()使用的是Objective-C類型的字符串,并不是C類型的字符串,所以插入一個@來修復:
NSLog(@"You tapped on: %s", sender);
仔細看發現黃色警告信息并沒有消除。因為這行還有其他不知道會不會導致崩潰的問題存在。這同樣很有趣,有時候代碼運行的好好的,或者說看上去執行的好好的,但是其他不知道什么時候它就崩潰了(當然此類型的崩潰經常發生在客戶手機上)。
讓我們來看看具體的警告信息:

%s表示的是C類型的字符串。C類型的字符串實際只是一串連續的以空字符(NULL character,實際值是0)結尾的byte類型數組。比如,C類型的字符串“Crash!”實際在內存中的存儲如下:

如果你的方法或函數中用到了C類型的字符串,那必須先確認字符串是以0結尾的,否則函數處理時沒辦法識別出字符串的結尾。
現在,當你在NSLog()格式的字符串或者NSString的stringWithFormat的字符串中使用%s,參數就會被當做C類型的字符串解析。這種情況下,作為參數傳入的“sender”實際是一個UIButton的對象,并不是個C類型的字符串。甚至當“sender”指向的內存中包含字節0,NSLog()將不會崩潰,而是輸出類似如下的信息:
You tapped on: x?j
可以準確的看到這些信息來自何處。再一次運行程序,點擊按鈕等待崩潰。在左側的調試區,右擊“sender”選擇“View Memory of *sender”項(確認點擊的是帶*號的)。

Xcode這時會這塊內存地址存儲的內容,也就是剛剛NSLog()輸出的信息。

然而并不能保證這塊內存中一定有NULL字節,所以EXC_BAD_ACCESS錯誤并不是能輕易報出的。如果一直在模擬器上運行程序,可能跑很長時間都出不來這個錯誤,因為你自己的測試環境總是你自己最喜歡的狀態。這也導致此類型的bug很難浮現。
當然,這種情況發生時Xcode已經給你了類型錯誤的警告了,所以這種特殊的bug也是很好查找的。但是不論何時,只要使用了C類型的字符串或者直接操作了內存,都要特別小心是不是影響了其他地方的內存,導致它們出了問題。
如果應用總是奔潰,那么恭喜你這個bug定位起來實際很容易,但是比較常見的是,應用程序時而崩潰時而不崩潰,這將是問題復現非常困難!這種情況下定位這個bug也將變成史詩級的難題。
修復此處NSLog()語句的問題,用下面的方法:
NSLog(@"You tapped on: %@", sender);
運行程序再一次點擊按鈕。NSLog()做了我們期望它做的事情,但是似乎我們還沒有處理好buttonTapped:的崩潰。
七、和調試器做朋友
Xcode告訴我們這個最新的crash在這一行:
[self performSegueWithIdentifier:@"ModalSegue" sender:sender];
調試窗口沒有信息輸出。你可以像之前一樣一直點擊繼續運行按鈕,或者你也可以在調試器中輸入一個命令去獲取錯誤信息。這樣做的好處是,程序還是中斷在之前的位置。
如果是在模擬器上運行,可以輸入如下的(lldb)命令:
(lldb) po $eax
LLDB是Xcode4.3及以上版本中默認調試器。如果使用的是早期的Xcode版本,可以使用GDB調試器。他們倆的基本命令一致,所以即便你的Xcode編譯命令前面的標記是(gdb)而不是上面的(lldb),也一樣可以繼續(順便補充一下,you can switch between debuggers in the Scheme editor in Xcode, under the Run action. And you can access the Scheme editor by Alt-tapping the Run icon at the top left corner of your Xcode window.)。po命令代表打印對象(print object)。參數$eax指向一個CPU寄存器。出現異常的情況下,這個寄存器中的數據將包含一個指向NSException對象的指針。注意:$eax僅在模擬器環境下有效,如果你使用真機調試,那么要訪問的寄存器是$r0。
例如,這樣輸入:
(lldb) po [$eax class]
將看到這樣的信息:
(id) $2 = 0x01446e84 NSException
數字并不重要,但是明顯你正在處理的NSException對象是存儲在這里的。
你可以通過這個對象調用任何NSException的方法。比如:
(lldb) po [$eax name]
這行命令將輸出該異常的名稱,比如這里是NSInvalidArgumentException,另外輸出如下命令:
(lldb) po [$eax reason]
將告訴我們異常的原因:
(unsigned int) $4 = 114784400 Receiver (<MainViewController: 0x6b60620>) has no
segue with identifier 'ModalSegue'
注: 調用“po $eax”的時候, 通常也會調用到對象的“description”方法并且輸出, 這種情況下一般就已經給出了錯誤消息。
正好就解釋了剛剛發生了什么:你的本意是執行一個名叫“ModalSegue”的segue,但是顯然MainViewController中不存在這個東東。
storyboard顯示一個segue使用的是模態方式,但是你忘記設置它的標識,這是個典型的錯誤:

修改segue的標識為“ModalSegue”。再一次運行程序,點擊按鈕,等待crash。這次不再崩潰了!但是呢,這里遺留了我們下一篇要討論的問題:顯示的tableview不應該為空!
八、相關篇章
所以這個空白的tableview到底是關于什么的? 這是本文的一個懸念,下一篇中會詳細解釋,同時也會解決一些很有趣的在你編程生涯中曾經遇到過bug。當然通過第二部分學習,你還可以更加充實自己的調試技能,比如NSLog()語句,斷點以及僵尸對象(Zombie Objects)。
當所有的這些做完講完,我承諾程序一定會按照我們期待的那樣運行!最重要的是,你已經掌握了技能當你的程序出現這些令人挫敗的問題時,你也必將能妥善處理它們。
有任何意見和建議請至論壇找我!
