沒人寫的代碼是完美無暇的,但調(diào)試代碼我們卻都應(yīng)該有能力能做好。相比提供一個(gè)關(guān)于本話題的隨機(jī)小建議,我更傾向于選擇帶你親身經(jīng)歷一個(gè) bug 修復(fù)的過程,這是一個(gè) UIKit 的 bug,我會(huì)展示我用來理解,隔離,并最終解決這個(gè)問題的流程。
我收到了一個(gè) bug 反饋報(bào)告,當(dāng)快速點(diǎn)擊一個(gè)按鈕來彈出一個(gè) popover 并 dismiss 它的同時(shí),父視圖控制器也會(huì)被 dismiss。謝天謝地,還附上了一個(gè)截圖示意,所以第一步 -- 重現(xiàn) bug -- 已經(jīng)被做到了:
http://wiki.jikexueyuan.com/project/objc/images/19-1.gif" alt="" />
我的第一個(gè)猜測(cè)是,我們可能包含了 dismiss 視圖控制器的代碼,我們錯(cuò)誤地 dismiss 了父視圖控制器。然而,當(dāng)使用 Xcode 集成的視圖調(diào)試功能時(shí),很明顯有一個(gè)全局 UIDimmingView
作為 first responder 來響應(yīng)點(diǎn)擊事件:
http://wiki.jikexueyuan.com/project/objc/images/19-2.png" alt="" />
蘋果在 Xcode 6 中添加了調(diào)試視圖層次結(jié)構(gòu)的功能,這一舉動(dòng)很可能是受到非常受歡迎的應(yīng)用 Reveal 和 Spark Inspector 的啟發(fā)。相對(duì)于 Xcode,它們?cè)谠S多方面表現(xiàn)更好,功能更多。
在可視化調(diào)試出現(xiàn)之前,最常見的做法是在 LLDB 使用 po [[UIWindow keyWindow] recursiveDescription]
來檢查層次結(jié)構(gòu)。它可以以文本形式打印出完整的視圖層次結(jié)構(gòu)。
類似于檢查視圖層次,我們也可以用 po [[[UIWindow keyWindow] rootViewController] _printHierarchy]
來檢查視圖控制器。這是一個(gè)蘋果默默在 iOS 8 中為 UIViewController
添加的私有輔助方法 。
(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 非常強(qiáng)大并且可以腳本化。 Facebook 發(fā)布了一組名為 Chisel 的 Python 腳本集合 為日常調(diào)試提供了非常多的幫助。pviews
和 pvc
等價(jià)于視圖和視圖控制器的層次打印。Chisel 的視圖控制器樹和上面方法打印的很類似,但是同時(shí)還顯示了視圖的尺寸。
我通常用它來檢查響應(yīng)鏈,雖然你可以對(duì)你感興趣的對(duì)象手動(dòng)循環(huán)執(zhí)行 nextResponder
,或者添加一個(gè)類別輔助方法,但輸入 presponder object
依舊是迄今為止最快的方法。
我們首先要找出實(shí)際 dismiss 我們視圖控制器的代碼。最容易想到的是在 viewWillDisappear:
設(shè)置一個(gè)斷點(diǎn)來進(jìn)行調(diào)用棧跟蹤:
(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
命令,你可以打印斷點(diǎn)。bt all
可以達(dá)到一樣的效果,區(qū)別在于會(huì)打印全部線程的狀態(tài),而不僅是當(dāng)前的線程。
看看這個(gè)棧,我們注意到視圖控制器已經(jīng)被 dismiss 途中,因?yàn)檫@個(gè)方法是在預(yù)定的動(dòng)畫中被調(diào)用的,所以我們需要在更早的地方增加斷點(diǎn)。在這個(gè)例子中,我們關(guān)注的是對(duì)于 -[UIViewController dismissViewControllerAnimated:completion:]
的調(diào)用。我們?cè)?Xcode 的斷點(diǎn)列表中添加一個(gè)符號(hào)斷點(diǎn),并且重新執(zhí)行示例代碼。
Xcode 的斷點(diǎn)接口非常強(qiáng)大,它允許你添加條件,跳過計(jì)數(shù),或者自定義動(dòng)作,比如添加音效和自動(dòng)繼續(xù)等。雖然它們可以節(jié)省相當(dāng)多的時(shí)間,但在這里我們不需要這些特性:
(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
如我們所說!正如預(yù)期的,全屏 UIDimmingView
接收到我們的觸摸并且在 handleSingleTap:
中處理,接著轉(zhuǎn)發(fā)到 UIPopoverPresentationController
中的 dimmingViewWasTapped:
方法來 dismiss 視圖控制器 (就像它該做的那樣),然而。當(dāng)我們快速點(diǎn)擊時(shí),這個(gè)斷點(diǎn)被調(diào)用了兩次。這里有第二個(gè) dimming 視圖?還是說調(diào)用的是相同的實(shí)例?我們只有斷點(diǎn)時(shí)候的程序集,所以調(diào)用 po self
是無效的。
根據(jù)程序集和函數(shù)調(diào)用約定的一些基本知識(shí),我們依然可以拿到 self
的值。iOS ABI Function Call Guide 和在 iOS 模擬器時(shí)使用的 Mac OS X ABI Function Call Guide 都是極好的資源。
我們知道每個(gè) Objective-C 方法都有兩個(gè)隱式參數(shù):self
和 _cmd
。于是我們所需要的就是在棧上的第一個(gè)對(duì)象。在 32-bit 架構(gòu)中,棧信息保存在 $esp
里,所以在 Objective-C 方法中你可以你可以使用 po *(int*)($esp+4)
來獲取 self
,以及使用 p (SEL)*(int*)($esp+8)
來獲取 _cmd
。$esp
里的第一個(gè)值是返回地址。隨后的變量保存在 $esp+12
,$esp+16
以及依此類推的其他位置上。
x86-64 架構(gòu) (那些包含 arm64 芯片 iPhone 設(shè)備的模擬器) 提供了更多寄存器,所以變量放置在 $rdi
,$rsi
,$rdx
,$rcx
,$r8
,$r9
中。所有后續(xù)的變量在 $rbp
棧上。開始于 $rbp+16
,$rbp+24
等。
armv7 架構(gòu)的變量通常放置在 $r0
,$r1
,$r2
,$r3
中,接著移動(dòng)到 $sp
棧上:
(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,然而,因?yàn)橛懈嗟募拇嫫?,?$x0
到 $x7
的整個(gè)范圍都用來存放變量,之后回到棧寄存器 $sp
中。
你可以學(xué)到更多關(guān)于 x86,x86-64 的棧布局知識(shí),還可以閱讀 AMD64 ABI Draft 來進(jìn)行深入。
跟蹤方法執(zhí)行的另一種做法是重寫方法,并在調(diào)用父類之前加入日志輸出。然而,手動(dòng) swizzling 調(diào)試起來雖然方便,但是在要花的時(shí)間上來說其實(shí)效率不高。在前一陣子,我寫了一個(gè)很小的叫做 Aspects 的庫,來專門做這件事情。它可以用于生產(chǎn)代碼,但是我大部分時(shí)候只用它來調(diào)試和寫測(cè)試用例。(如果你對(duì) Aspects 感興趣,你可以在這里了解更多相關(guān)知識(shí)。)
#import "Aspects.h"
[UIPopoverPresentationController aspect_hookSelector:NSSelectorFromString(@"dimmingViewWasTapped:")
withOptions:0
usingBlock:^(id <AspectInfo> info, UIView *tappedView) {
NSLog(@"%@ dimmingViewWasTapped:%@", info.instance, tappedView);
} error:NULL];
這里我們?yōu)?dimmingViewWasTapped:
添加了一個(gè)鉤子,它是私有方法 — 因此我們使用 NSSelectorFromString
。你可以驗(yàn)證方法是否存在,并通過使用 iOS Runtime Headers 來查找?guī)缀趺總€(gè)框架類的其他私有和公共方法。這個(gè)項(xiàng)目利用了不可能在運(yùn)行時(shí)真正地隱藏方法這一事實(shí),它在所有類中查找方法并,從而創(chuàng)建了一個(gè)比蘋果所提供給我們的相比,更完整的頭文件。(當(dāng)然,調(diào)用私有 API 并不是一個(gè)好主意 — 這里只是用來便于理解到底發(fā)生了什么)
在鉤子方法的日志中,我們獲得如下輸出:
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)>
我們看到對(duì)象地址完全相同,所以我們可憐的 dimming 視圖真的被調(diào)用了兩次,我們可以使用 Aspects 來查看具體 dismiss 方法調(diào)用在了哪個(gè)控制器上:
[UIViewController aspect_hookSelector:@selector(dismissViewControllerAnimated:completion:)
withOptions:0
usingBlock:^(id <AspectInfo> info) {
NSLog(@"%@ dismissed.", info.instance);
} error:NULL];
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 視圖都調(diào)用了主導(dǎo)航控制器的 dismiss 方法。如果子視圖控制器存在的話,視圖控制器的 dismissViewControllerAnimated:completion:
會(huì)將視圖控制器的 dismiss 請(qǐng)求轉(zhuǎn)發(fā)到它的子視圖控制器中,否則它將 dismiss 自己。所以第一次 dismiss 請(qǐng)求執(zhí)行于 popover,而第二次,導(dǎo)航控制器本身被 dismiss 了。
現(xiàn)在我們知道發(fā)生了什么事情 — 接下來我們可以進(jìn)入為何發(fā)生的環(huán)節(jié)。UIKit 是閉源代碼,但是我們使用像 Hopper 這樣的反匯編工具來解讀 UIKit 程序集并且仔細(xì)看看 UIPopoverPresentationController
里發(fā)生了什么事情。你可以在 /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator.sdk/System/Library/Frameworks/UIKit.framework
里找到二進(jìn)制文件。然后在 Hopper 里使用 File -> Read Executable to Disassemble...,這將遍歷整個(gè)二進(jìn)制文件并且將代碼符號(hào)化。32-bit 反匯編是最成熟的一個(gè)。所以你選擇 32-bit 文件可以拿到最好的結(jié)果。Hex-Rays 出品的 IDA 是另一個(gè)很強(qiáng)大很昂貴的反匯編程序,通??梢蕴峁?a rel="nofollow" >更好的結(jié)果:
http://wiki.jikexueyuan.com/project/objc/images/19-3.png" alt="" />
一些匯編語言的基礎(chǔ)知識(shí)對(duì)閱讀代碼會(huì)非常有用。不過,你也可以使用偽代碼視圖來得到類似于 C 代碼的結(jié)果:
http://wiki.jikexueyuan.com/project/objc/images/19-4.png" alt="" />
閱讀偽代碼結(jié)果讓人大開眼界。這里有兩個(gè)代碼路徑 — 其中一個(gè)是如果 delegate 實(shí)現(xiàn)了 popoverPresentationControllerShouldDismissPopover:
時(shí)調(diào)用,另一個(gè)在沒有實(shí)現(xiàn)時(shí)調(diào)用 — 兩個(gè)代碼路徑實(shí)際上相當(dāng)不同。delegate 實(shí)現(xiàn)了委托方法的那個(gè)路徑中,包含了 if (controller.presented && !controller.dismissing)
,而另一個(gè)代碼路徑 (我們現(xiàn)在實(shí)際進(jìn)入的) 卻沒有,并總是調(diào)用 dismiss。通過內(nèi)部信息,我們可以嘗試通過實(shí)現(xiàn)我們自己的 UIPopoverPresentationControllerDelegate
來繞開這個(gè) bug:
- (BOOL)popoverPresentationControllerShouldDismissPopover:(UIPopoverPresentationController *)popoverPresentationController {
return YES;
}
我的第一次嘗試是把創(chuàng)建 popover 的主視圖控制器設(shè)為 delegate。然而它破壞了 UIPopoverController
。雖然文檔沒提,但 popover 控制器會(huì)在 _setupPresentationController
中將自己設(shè)為 delegate,另外,移除這個(gè) delegate 將造成破壞。之后,我使用了一個(gè) UIPopoverController
的子類并直接添加了上面的方法。這兩個(gè)類之間的聯(lián)系并沒有文檔化,而且我們的解決方案依賴于這個(gè)沒有文檔的行為;不過,這個(gè)實(shí)現(xiàn)是匹配默認(rèn)行為的,它純粹是為了解決這個(gè)問題,所以它是經(jīng)得起未來考驗(yàn)的代碼。
現(xiàn)在請(qǐng)不要停下。我們通常需要為這樣的繞開問題的方案寫一些文檔,但還有一件重要的事情是,給 Apple 提交一個(gè) radar。這么做會(huì)帶來額外的好處,這能讓你驗(yàn)證你是否真正理解這個(gè) bug,并且在你的程序中沒有其他副作用 — 如果你之后放棄支持這個(gè) iOS 版本,你可以很容易回滾代碼并測(cè)試這個(gè) radar 是否修正過。
// UIPopoverController 是它的 contentViewController,即 UIPopoverPresentationController 的默認(rèn)的 delegate
//
// 這里有一個(gè) bug:當(dāng)雙擊 diming 視圖時(shí),presentation 視圖控制器將調(diào)用兩次
// dismissViewControllerAnimated:completion:,并 dismiss 掉它的父控制器.
//
// 通過實(shí)現(xiàn)這個(gè) delegate 可以讓代碼運(yùn)行另一條正確地檢查了是否正在 dismiss 的代碼路徑
// rdar://problem/19067761
- (BOOL)popoverPresentationControllerShouldDismissPopover:(UIPopoverPresentationController *)popoverPresentationController {
return YES;
}
寫一個(gè) Radar 實(shí)際上是非常有趣的挑戰(zhàn),它并不像你想象的那么花時(shí)間。用一個(gè)示例,你將幫助那些勞累蘋果工程師,沒有示例,工程師將很有可能推遲,甚至不考慮這個(gè) radar。我為這個(gè)問題創(chuàng)建了一個(gè)大約 50 行代碼的例子,還包括一些意見和解決方案。單視圖的模板通常是創(chuàng)建一個(gè)示例的最快方式。
現(xiàn)在,我們都知道蘋果的 Radar 網(wǎng)頁并沒有那么好用,不過你可以不使用它。QuickRadar 是一個(gè)用來提交 radar 的非常優(yōu)秀的 Mac 前端,同時(shí)它會(huì)自動(dòng)提交一個(gè)副本到 OpenRadar。此外,復(fù)制 radar 也極其方便。你應(yīng)該馬上下載它,另外,如果你覺得例子里這樣的錯(cuò)誤值得被修復(fù),可以復(fù)制 rdar://19067761。
并不是所有問題都可以用一些簡單的方案繞開,但這些步驟將幫助你找到更好的解決問題的方法,或者至少幫助你的理解為什么某些事情會(huì)發(fā)生。