沒人寫的程式碼是完美無暇的,但除錯程式碼我們卻都應該有能力能做好。相比提供一個關於本話題的隨機小建議,我更傾向於選擇帶你親身經歷一個 bug 修復的過程,這是一個 UIKit 的 bug,我會展示我用來理解,隔離,並最終解決這個問題的流程。
問題
我收到了一個 bug 反饋報告,當快速點選一個按鈕來彈出一個 popover 並 dismiss 它的同時,父檢視控制器也會被 dismiss。謝天謝地,還附上了一個截圖示意,所以第一步 — 重現 bug — 已經被做到了:
我的第一個猜測是,我們可能包含了 dismiss 檢視控制器的程式碼,我們錯誤地 dismiss 了父檢視控制器。然而,當使用 Xcode 整合的檢視除錯功能時,很明顯有一個全域性 UIDimmingView
作為 first responder 來響應點選事件:
蘋果在 Xcode 6 中新增了除錯檢視層次結構的功能,這一舉動很可能是受到非常受歡迎的應用 Reveal 和 Spark Inspector 的啟發。相對於 Xcode,它們在許多方面表現更好,功能更多。
使用 LLDB
在視覺化除錯出現之前,最常見的做法是在 LLDB 使用 po [[UIWindow keyWindow] recursiveDescription]
來檢查層次結構。它可以以文字形式列印出完整的檢視層次結構。
類似於檢查檢視層次,我們也可以用 po [[[UIWindow keyWindow] rootViewController] _printHierarchy]
來檢查檢視控制器。這是一個蘋果默默在 iOS 8 中為 UIViewController
新增的私有輔助方法 。
1 2 3 4 5 6 7 8 9 |
(lldb) po [[[UIWindow keyWindow] rootViewController] _printHierarchy] <PSPDFNavigationController 0x7d025000>, state: disappeared, view: <UILayoutContainerView 0x7b3218d0> not in the window | <PSCatalogViewController 0x7b3100d0>, state: disappeared, view: <UITableView 0x7c878800> not in the window + <UINavigationController 0x8012c5d0>, state: appeared, view: <UILayoutContainerView 0x8012b7a0>, presented with: <_UIFullscreenPresentationController 0x80116c00> | | <PSPDFViewController 0x7d05ae00>, state: appeared, view: <PSPDFViewControllerView 0x80129640> | | | <PSPDFContinuousScrollViewController 0x7defa8e0>, state: appeared, view: <UIView 0x7def1ce0> | + <PSPDFNavigationController 0x7d21a800>, state: appeared, view: <UILayoutContainerView 0x8017b490>, presented with: <UIPopoverPresentationController 0x7f598c60> | | | <PSPDFContainerViewController 0x8017ac40>, state: appeared, view: <UIView 0x7f5a1380> | | | | <PSPDFStampViewController 0x8016b6e0>, state: appeared, view: <UIView 0x7f3dbb90> |
LLDB 非常強大並且可以指令碼化。 Facebook 釋出了一組名為 Chisel 的 Python 指令碼集合 為日常除錯提供了非常多的幫助。pviews
和 pvc
等價於檢視和檢視控制器的層次列印。Chisel 的檢視控制器樹和上面方法列印的很類似,但是同時還顯示了檢視的尺寸。
我通常用它來檢查響應鏈,雖然你可以對你感興趣的物件手動迴圈執行 nextResponder
,或者新增一個類別輔助方法,但輸入 presponder object
依舊是迄今為止最快的方法。
新增斷點
我們首先要找出實際 dismiss 我們檢視控制器的程式碼。最容易想到的是在 viewWillDisappear:
設定一個斷點來進行呼叫棧跟蹤:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
(lldb) bt * thread #1: tid = 0x1039b3, 0x004fab75 PSPDFCatalog`-[PSPDFViewController viewWillDisappear:](self=0x7f354400, _cmd=0x03b817bf, animated='\x01') + 85 at PSPDFViewController.m:359, queue = 'com.apple.main-thread', stop reason = breakpoint 1.1 * frame #0: 0x004fab75 PSPDFCatalog`-[PSPDFViewController viewWillDisappear:](self=0x7f354400, _cmd=0x03b817bf, animated='\x01') + 85 at PSPDFViewController.m:359 frame #1: 0x033ac782 UIKit`-[UIViewController _setViewAppearState:isAnimating:] + 706 frame #2: 0x033acdf4 UIKit`-[UIViewController __viewWillDisappear:] + 106 frame #3: 0x033d9a62 UIKit`-[UINavigationController viewWillDisappear:] + 115 frame #4: 0x033ac782 UIKit`-[UIViewController _setViewAppearState:isAnimating:] + 706 frame #5: 0x033acdf4 UIKit`-[UIViewController __viewWillDisappear:] + 106 frame #6: 0x033c46a1 UIKit`-[UIViewController(UIContainerViewControllerProtectedMethods) beginAppearanceTransition:animated:] + 200 frame #7: 0x03380ad8 UIKit`__56-[UIPresentationController runTransitionForCurrentState]_block_invoke + 594 frame #8: 0x033b47ab UIKit`__40+[UIViewController _scheduleTransition:]_block_invoke + 18 frame #9: 0x0327a0ce UIKit`___afterCACommitHandler_block_invoke + 15 frame #10: 0x0327a079 UIKit`_applyBlockToCFArrayCopiedToStack + 415 frame #11: 0x03279e8e UIKit`_afterCACommitHandler + 545 frame #12: 0x060669de CoreFoundation`__CFRUNLOOP_IS_CALLING_OUT_TO_AN_OBSERVER_CALLBACK_FUNCTION__ + 30 frame #20: 0x032508b6 UIKit`UIApplicationMain + 1526 frame #21: 0x000a119d PSPDFCatalog`main(argc=1, argv=0xbffcd65c) + 141 at main.m:15 (lldb) |
利用 LLDB 的 bt
命令,你可以列印斷點。bt all
可以達到一樣的效果,區別在於會列印全部執行緒的狀態,而不僅是當前的執行緒。
看看這個棧,我們注意到檢視控制器已經被 dismiss 途中,因為這個方法是在預定的動畫中被呼叫的,所以我們需要在更早的地方增加斷點。在這個例子中,我們關注的是對於 -[UIViewController dismissViewControllerAnimated:completion:]
的呼叫。我們在 Xcode 的斷點列表中新增一個符號斷點,並且重新執行示例程式碼。
Xcode 的斷點介面非常強大,它允許你新增條件,跳過計數,或者自定義動作,比如新增音效和自動繼續等。雖然它們可以節省相當多的時間,但在這裡我們不需要這些特性:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
(lldb) bt * thread #1: tid = 0x1039b3, 0x033bb685 UIKit`-[UIViewController dismissViewControllerAnimated:completion:], queue = 'com.apple.main-thread', stop reason = breakpoint 7.1 * frame #0: 0x033bb685 UIKit`-[UIViewController dismissViewControllerAnimated:completion:] frame #1: 0x03a7da2c UIKit`-[UIPopoverPresentationController dimmingViewWasTapped:] + 244 frame #2: 0x036153ed UIKit`-[UIDimmingView handleSingleTap:] + 118 frame #3: 0x03691287 UIKit`_UIGestureRecognizerSendActions + 327 frame #4: 0x0368fb04 UIKit`-[UIGestureRecognizer _updateGestureWithEvent:buttonEvent:] + 561 frame #5: 0x03691b4d UIKit`-[UIGestureRecognizer _delayedUpdateGesture] + 60 frame #6: 0x036954ca UIKit`___UIGestureRecognizerUpdate_block_invoke661 + 57 frame #7: 0x0369538d UIKit`_UIGestureRecognizerRemoveObjectsFromArrayAndApplyBlocks + 317 frame #8: 0x03689296 UIKit`_UIGestureRecognizerUpdate + 3720 frame #9: 0x032a226b UIKit`-[UIWindow _sendGesturesForEvent:] + 1356 frame #10: 0x032a30cf UIKit`-[UIWindow sendEvent:] + 769 frame #21: 0x032508b6 UIKit`UIApplicationMain + 1526 frame #22: 0x000a119d PSPDFCatalog`main(argc=1, argv=0xbffcd65c) + 141 at main.m:15 |
如我們所說!正如預期的,全屏 UIDimmingView
接收到我們的觸控並且在 handleSingleTap:
中處理,接著轉發到 UIPopoverPresentationController
中的 dimmingViewWasTapped:
方法來 dismiss 檢視控制器 (就像它該做的那樣),然而。當我們快速點選時,這個斷點被呼叫了兩次。這裡有第二個 dimming 檢視?還是說呼叫的是相同的例項?我們只有斷點時候的程式集,所以呼叫 po self
是無效的。
呼叫約定入門
根據程式集和函式呼叫約定的一些基本知識,我們依然可以拿到 self
的值。iOS ABI Function Call Guide 和在 iOS 模擬器時使用的 Mac OS X ABI Function Call Guide 都是極好的資源。
我們知道每個 Objective-C 方法都有兩個隱式引數:self
和 _cmd
。於是我們所需要的就是在棧上的第一個物件。在 32-bit 架構中,棧資訊儲存在 $esp
裡,所以在 Objective-C 方法中你可以你可以使用 po *(int*)($esp+4)
來獲取 self
,以及使用 p (SEL)*(int*)($esp+8)
來獲取 _cmd
。$esp
裡的第一個值是返回地址。隨後的變數儲存在 $esp+12
,$esp+16
以及依此類推的其他位置上。
x86-64 架構 (那些包含 arm64 晶片 iPhone 裝置的模擬器) 提供了更多暫存器,所以變數放置在 $rdi
,$rsi
,$rdx
,$rcx
,$r8
,$r9
中。所有後續的變數在 $rbp
棧上。開始於 $rbp+16
,$rbp+24
等。
armv7 架構的變數通常放置在 $r0
,$r1
,$r2
,$r3
中,接著移動到 $sp
棧上:
1 2 3 4 5 |
(lldb) po $r0 <PSPDFViewController: 0x15a1ca00 document:<PSPDFDocument 0x15616e70 UID:amazondynamososp2007_0c7fb1fc6c0841562b090b94f0c1c890 files:1 pageCount:16 isValid:1> page:0> (lldb) p (SEL)$r1 (SEL) $1 = "dismissViewControllerAnimated:completion:" |
arm64 類似於 armv7,然而,因為有更多的暫存器,從 $x0
到 $x7
的整個範圍都用來存放變數,之後回到棧暫存器 $sp
中。
你可以學到更多關於 x86,x86-64 的棧佈局知識,還可以閱讀 AMD64 ABI Draft 來進行深入。
使用 Runtime
跟蹤方法執行的另一種做法是重寫方法,並在呼叫父類之前加入日誌輸出。然而,手動 swizzling 除錯起來雖然方便,但是在要花的時間上來說其實效率不高。在前一陣子,我寫了一個很小的叫做 Aspects 的庫,來專門做這件事情。它可以用於生產程式碼,但是我大部分時候只用它來除錯和寫測試用例。(如果你對 Aspects 感興趣,你可以在這裡瞭解更多相關知識。)
1 2 3 4 5 6 7 |
#import "Aspects.h" [UIPopoverPresentationController aspect_hookSelector:NSSelectorFromString(@"dimmingViewWasTapped:") withOptions:0 usingBlock:^(id <AspectInfo> info, UIView *tappedView) { NSLog(@"%@ dimmingViewWasTapped:%@", info.instance, tappedView); } error:NULL]; |
這裡我們為 dimmingViewWasTapped:
新增了一個鉤子,它是私有方法 — 因此我們使用 NSSelectorFromString
。你可以驗證方法是否存在,並通過使用 iOS Runtime Headers 來查詢幾乎每個框架類的其他私有和公共方法。這個專案利用了不可能在執行時真正地隱藏方法這一事實,它在所有類中查詢方法並,從而建立了一個比蘋果所提供給我們的相比,更完整的標頭檔案。(當然,呼叫私有 API 並不是一個好主意 — 這裡只是用來便於理解到底發生了什麼)
在鉤子方法的日誌中,我們獲得如下輸出:
1 2 |
PSPDFCatalog[84049:1079574] <UIPopoverPresentationController: 0x7fd09f91c530> dimmingViewWasTapped:<UIDimmingView: 0x7fd09f92f800; frame = (0 0; 768 1024)> PSPDFCatalog[84049:1079574] <UIPopoverPresentationController: 0x7fd09f91c530> dimmingViewWasTapped:<UIDimmingView: 0x7fd09f92f800; frame = (0 0; 768 1024)> |
我們看到物件地址完全相同,所以我們可憐的 dimming 檢視真的被呼叫了兩次,我們可以使用 Aspects 來檢視具體 dismiss 方法呼叫在了哪個控制器上:
1 2 3 4 5 |
[UIViewController aspect_hookSelector:@selector(dismissViewControllerAnimated:completion:) withOptions:0 usingBlock:^(id <AspectInfo> info) { NSLog(@"%@ dismissed.", info.instance); } error:NULL]; |
1 2 |
2014-11-22 19:24:51.900 PSPDFCatalog[84210:1084883] <UINavigationController: 0x7fd673789da0> dismissed. 2014-11-22 19:24:52.209 PSPDFCatalog[84210:1084883] <UINavigationController: 0x7fd673789da0> dismissed. |
兩次 dimming 檢視都呼叫了主導航控制器的 dismiss 方法。如果子檢視控制器存在的話,檢視控制器的 dismissViewControllerAnimated:completion:
會將檢視控制器的 dismiss 請求轉發到它的子檢視控制器中,否則它將 dismiss 自己。所以第一次 dismiss 請求執行於 popover,而第二次,導航控制器本身被 dismiss 了。
查詢臨時方案
現在我們知道發生了什麼事情 — 接下來我們可以進入為何發生的環節。UIKit 是閉原始碼,但是我們使用像 Hopper 這樣的反彙編工具來解讀 UIKit 程式集並且仔細看看 UIPopoverPresentationController
裡發生了什麼事情。你可以在 /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator.sdk/System/Library/Frameworks/UIKit.framework
裡找到二進位制檔案。然後在 Hopper 裡使用 File -> Read Executable to Disassemble…,這將遍歷整個二進位制檔案並且將程式碼符號化。32-bit 反彙編是最成熟的一個。所以你選擇 32-bit 檔案可以拿到最好的結果。Hex-Rays 出品的 IDA 是另一個很強大很昂貴的反彙編程式,通常可以提供更好的結果:
一些組合語言的基礎知識對閱讀程式碼會非常有用。不過,你也可以使用虛擬碼檢視來得到類似於 C 程式碼的結果:
閱讀虛擬碼結果讓人大開眼界。這裡有兩個程式碼路徑 — 其中一個是如果 delegate 實現了 popoverPresentationControllerShouldDismissPopover:
時呼叫,另一個在沒有實現時呼叫 — 兩個程式碼路徑實際上相當不同。delegate 實現了委託方法的那個路徑中,包含了 if (controller.presented && !controller.dismissing)
,而另一個程式碼路徑 (我們現在實際進入的) 卻沒有,並總是呼叫 dismiss。通過內部資訊,我們可以嘗試通過實現我們自己的 UIPopoverPresentationControllerDelegate
來繞開這個 bug:
1 2 3 |
- (BOOL)popoverPresentationControllerShouldDismissPopover:(UIPopoverPresentationController *)popoverPresentationController { return YES; } |
我的第一次嘗試是把建立 popover 的主檢視控制器設為 delegate。然而它破壞了 UIPopoverController
。雖然文件沒提,但 popover 控制器會在 _setupPresentationController
中將自己設為 delegate,另外,移除這個 delegate 將造成破壞。之後,我使用了一個 UIPopoverController
的子類並直接新增了上面的方法。這兩個類之間的聯絡並沒有文件化,而且我們的解決方案依賴於這個沒有文件的行為;不過,這個實現是匹配預設行為的,它純粹是為了解決這個問題,所以它是經得起未來考驗的程式碼。
反饋 Radar
現在請不要停下。我們通常需要為這樣的繞開問題的方案寫一些文件,但還有一件重要的事情是,給 Apple 提交一個 radar。這麼做會帶來額外的好處,這能讓你驗證你是否真正理解這個 bug,並且在你的程式中沒有其他副作用 — 如果你之後放棄支援這個 iOS 版本,你可以很容易回滾程式碼並測試這個 radar 是否修正過。
1 2 3 4 5 6 7 8 9 10 |
// UIPopoverController 是它的 contentViewController,即 UIPopoverPresentationController 的預設的 delegate // // 這裡有一個 bug:當雙擊 diming 檢視時,presentation 檢視控制器將呼叫兩次 // dismissViewControllerAnimated:completion:,並 dismiss 掉它的父控制器. // // 通過實現這個 delegate 可以讓程式碼執行另一條正確地檢查了是否正在 dismiss 的程式碼路徑 // rdar://problem/19067761 - (BOOL)popoverPresentationControllerShouldDismissPopover:(UIPopoverPresentationController *)popoverPresentationController { return YES; } |
寫一個 Radar 實際上是非常有趣的挑戰,它並不像你想象的那麼花時間。用一個示例,你將幫助那些勞累蘋果工程師,沒有示例,工程師將很有可能推遲,甚至不考慮這個 radar。我為這個問題建立了一個大約 50 行程式碼的例子,還包括一些意見和解決方案。單檢視的模板通常是建立一個示例的最快方式。
現在,我們都知道蘋果的 Radar 網頁並沒有那麼好用,不過你可以不使用它。QuickRadar 是一個用來提交 radar 的非常優秀的 Mac 前端,同時它會自動提交一個副本到 OpenRadar。此外,複製 radar 也極其方便。你應該馬上下載它,另外,如果你覺得例子裡這樣的錯誤值得被修復,可以複製 rdar://19067761。
並不是所有問題都可以用一些簡單的方案繞開,但這些步驟將幫助你找到更好的解決問題的方法,或者至少幫助你的理解為什麼某些事情會發生。