除錯:案例學習

發表於2015-03-17

沒人寫的程式碼是完美無暇的,但除錯程式碼我們卻都應該有能力能做好。相比提供一個關於本話題的隨機小建議,我更傾向於選擇帶你親身經歷一個 bug 修復的過程,這是一個 UIKit 的 bug,我會展示我用來理解,隔離,並最終解決這個問題的流程。

問題

我收到了一個 bug 反饋報告,當快速點選一個按鈕來彈出一個 popover 並 dismiss 它的同時,父檢視控制器也會被 dismiss。謝天謝地,還附上了一個截圖示意,所以第一步 — 重現 bug — 已經被做到了:

 

0064cTs2jw1eq8wicbqncg30fx0latp4.gif

我的第一個猜測是,我們可能包含了 dismiss 檢視控制器的程式碼,我們錯誤地 dismiss 了父檢視控制器。然而,當使用 Xcode 整合的檢視除錯功能時,很明顯有一個全域性 UIDimmingView 作為 first responder 來響應點選事件:

0064cTs2jw1eq8wibj6gwj327y1hkar8.jpg

蘋果在 Xcode 6 中新增了除錯檢視層次結構的功能,這一舉動很可能是受到非常受歡迎的應用 Reveal 和 Spark Inspector 的啟發。相對於 Xcode,它們在許多方面表現更好,功能更多。

使用 LLDB

在視覺化除錯出現之前,最常見的做法是在 LLDB 使用 po [[UIWindow keyWindow] recursiveDescription] 來檢查層次結構。它可以以文字形式列印出完整的檢視層次結構

類似於檢查檢視層次,我們也可以用 po [[[UIWindow keyWindow] rootViewController] _printHierarchy] 來檢查檢視控制器。這是一個蘋果默默在 iOS 8 中為 UIViewController 新增的私有輔助方法 。

LLDB 非常強大並且可以指令碼化。 Facebook 釋出了一組名為 Chisel 的 Python 指令碼集合 為日常除錯提供了非常多的幫助。pviews 和 pvc 等價於檢視和檢視控制器的層次列印。Chisel 的檢視控制器樹和上面方法列印的很類似,但是同時還顯示了檢視的尺寸。
我通常用它來檢查響應鏈,雖然你可以對你感興趣的物件手動迴圈執行 nextResponder,或者新增一個類別輔助方法,但輸入 presponder object 依舊是迄今為止最快的方法。

新增斷點

我們首先要找出實際 dismiss 我們檢視控制器的程式碼。最容易想到的是在 viewWillDisappear: 設定一個斷點來進行呼叫棧跟蹤:

利用 LLDB 的 bt 命令,你可以列印斷點。bt all 可以達到一樣的效果,區別在於會列印全部執行緒的狀態,而不僅是當前的執行緒。

看看這個棧,我們注意到檢視控制器已經被 dismiss 途中,因為這個方法是在預定的動畫中被呼叫的,所以我們需要在更早的地方增加斷點。在這個例子中,我們關注的是對於 -[UIViewController dismissViewControllerAnimated:completion:] 的呼叫。我們在 Xcode 的斷點列表中新增一個符號斷點,並且重新執行示例程式碼。

Xcode 的斷點介面非常強大,它允許你新增條件,跳過計數,或者自定義動作,比如新增音效和自動繼續等。雖然它們可以節省相當多的時間,但在這裡我們不需要這些特性:

如我們所說!正如預期的,全屏 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 棧上:

arm64 類似於 armv7,然而,因為有更多的暫存器,從 $x0 到 $x7 的整個範圍都用來存放變數,之後回到棧暫存器 $sp 中。

你可以學到更多關於 x86x86-64 的棧佈局知識,還可以閱讀 AMD64 ABI Draft 來進行深入。

使用 Runtime

跟蹤方法執行的另一種做法是重寫方法,並在呼叫父類之前加入日誌輸出。然而,手動 swizzling 除錯起來雖然方便,但是在要花的時間上來說其實效率不高。在前一陣子,我寫了一個很小的叫做 Aspects 的庫,來專門做這件事情。它可以用於生產程式碼,但是我大部分時候只用它來除錯和寫測試用例。(如果你對 Aspects 感興趣,你可以在這裡瞭解更多相關知識。)

這裡我們為 dimmingViewWasTapped: 新增了一個鉤子,它是私有方法 — 因此我們使用 NSSelectorFromString。你可以驗證方法是否存在,並通過使用 iOS Runtime Headers 來查詢幾乎每個框架類的其他私有和公共方法。這個專案利用了不可能在執行時真正地隱藏方法這一事實,它在所有類中查詢方法並,從而建立了一個比蘋果所提供給我們的相比,更完整的標頭檔案。(當然,呼叫私有 API 並不是一個好主意 — 這裡只是用來便於理解到底發生了什麼)

在鉤子方法的日誌中,我們獲得如下輸出:

我們看到物件地址完全相同,所以我們可憐的 dimming 檢視真的被呼叫了兩次,我們可以使用 Aspects 來檢視具體 dismiss 方法呼叫在了哪個控制器上:

兩次 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 是另一個很強大很昂貴的反彙編程式,通常可以提供更好的結果:

0064cTs2jw1eq8wiaruxbj32h41m81kx.jpg

一些組合語言的基礎知識對閱讀程式碼會非常有用。不過,你也可以使用虛擬碼檢視來得到類似於 C 程式碼的結果:

0064cTs2jw1eq8wi9pfa8j31oy19k14o.jpg

閱讀虛擬碼結果讓人大開眼界。這裡有兩個程式碼路徑 — 其中一個是如果 delegate 實現了 popoverPresentationControllerShouldDismissPopover: 時呼叫,另一個在沒有實現時呼叫 — 兩個程式碼路徑實際上相當不同。delegate 實現了委託方法的那個路徑中,包含了 if (controller.presented && !controller.dismissing),而另一個程式碼路徑 (我們現在實際進入的) 卻沒有,並總是呼叫 dismiss。通過內部資訊,我們可以嘗試通過實現我們自己的 UIPopoverPresentationControllerDelegate 來繞開這個 bug:

我的第一次嘗試是把建立 popover 的主檢視控制器設為 delegate。然而它破壞了 UIPopoverController。雖然文件沒提,但 popover 控制器會在 _setupPresentationController 中將自己設為 delegate,另外,移除這個 delegate 將造成破壞。之後,我使用了一個 UIPopoverController 的子類並直接新增了上面的方法。這兩個類之間的聯絡並沒有文件化,而且我們的解決方案依賴於這個沒有文件的行為;不過,這個實現是匹配預設行為的,它純粹是為了解決這個問題,所以它是經得起未來考驗的程式碼。

反饋 Radar

現在請不要停下。我們通常需要為這樣的繞開問題的方案寫一些文件,但還有一件重要的事情是,給 Apple 提交一個 radar。這麼做會帶來額外的好處,這能讓你驗證你是否真正理解這個 bug,並且在你的程式中沒有其他副作用 — 如果你之後放棄支援這個 iOS 版本,你可以很容易回滾程式碼並測試這個 radar 是否修正過。

寫一個 Radar 實際上是非常有趣的挑戰,它並不像你想象的那麼花時間。用一個示例,你將幫助那些勞累蘋果工程師,沒有示例,工程師將很有可能推遲,甚至不考慮這個 radar。我為這個問題建立了一個大約 50 行程式碼的例子,還包括一些意見和解決方案。單檢視的模板通常是建立一個示例的最快方式。

現在,我們都知道蘋果的 Radar 網頁並沒有那麼好用,不過你可以不使用它。QuickRadar 是一個用來提交 radar 的非常優秀的 Mac 前端,同時它會自動提交一個副本到 OpenRadar。此外,複製 radar 也極其方便。你應該馬上下載它,另外,如果你覺得例子裡這樣的錯誤值得被修復,可以複製 rdar://19067761。

並不是所有問題都可以用一些簡單的方案繞開,但這些步驟將幫助你找到更好的解決問題的方法,或者至少幫助你的理解為什麼某些事情會發生。

相關文章