你好,歡迎來到IOS教程網

 Ios教程網 >> IOS編程開發 >> IOS開發基礎 >> 調試:案例學習

調試:案例學習

編輯:IOS開發基礎

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

問題

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

1.gif

我的第一個猜測是,我們可能包含了 dismiss 視圖控制器的代碼,我們錯誤地 dismiss 了父視圖控制器。然而,當使用 Xcode 集成的視圖調試功能時,很明顯有一個全局 UIDimmingView 作為 first responder 來響應點擊事件:

2.jpg

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

使用 LLDB

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

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

(lldb) po [[[UIWindow keyWindow] rootViewController] _printHierarchy], state: disappeared, view:  not in the window  
   | , state: disappeared, view:  not in the window
   + , state: appeared, view: , presented with:    |    | , state: appeared, view:    |    |    | , state: appeared, view:    |    + , state: appeared, view: , presented with:    |    |    | , state: appeared, view:    |    |    |    | , state: appeared, view:

LLDB 非常強大並且可以腳本化。 Facebook 發布了一組名為 Chisel 的 Python 腳本集合 為日常調試提供了非常多的幫助。pviewspvc 等價於視圖和視圖控制器的層次打印。Chisel 的視圖控制器樹和上面方法打印的很類似,但是同時還顯示了視圖的尺寸。 

我通常用它來檢查響應鏈,雖然你可以對你感興趣的對象手動循環執行 nextResponder,或者添加一個類別輔助方法,但輸入 presponder object 依舊是迄今為止最快的方法。

添加斷點

我們首先要找出實際 dismiss 我們視圖控制器的代碼。最容易想到的是在 viewWillDisappear: 設置一個斷點來進行調用棧跟蹤:

(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 的斷點接口非常強大,它允許你添加條件,跳過計數,或者自定義動作,比如添加音效和自動繼續等。雖然它們可以節省相當多的時間,但在這裡我們不需要這些特性:

(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 棧上:

(lldb) po $r0

(lldb) p (SEL)$r1
(SEL) $1 = "dismissViewControllerAnimated:completion:"

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

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

使用 Runtime

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

#import "Aspects.h"

[UIPopoverPresentationController aspect_hookSelector:NSSelectorFromString(@"dimmingViewWasTapped:") 
                                         withOptions:0 
                                          usingBlock:^(id  info, UIView *tappedView) {
    NSLog(@"%@ dimmingViewWasTapped:%@", info.instance, tappedView);
} error:NULL];

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

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

PSPDFCatalog[84049:1079574]  dimmingViewWasTapped:  
PSPDFCatalog[84049:1079574]  dimmingViewWasTapped:

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

[UIViewController aspect_hookSelector:@selector(dismissViewControllerAnimated:completion:)
                          withOptions:0
                           usingBlock:^(id  info) {
    NSLog(@"%@ dismissed.", info.instance);
} error:NULL];
2014-11-22 19:24:51.900 PSPDFCatalog[84210:1084883]  dismissed.  
2014-11-22 19:24:52.209 PSPDFCatalog[84210:1084883]  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 是另一個很強大很昂貴的反匯編程序,通常可以提供更好的結果:

3.jpg

一些匯編語言的基礎知識對閱讀代碼會非常有用。不過,你也可以使用偽代碼視圖來得到類似於 C 代碼的結果:

4.jpg

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

- (BOOL)popoverPresentationControllerShouldDismissPopover:(UIPopoverPresentationController *)popoverPresentationController {
    return YES;
}

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

反饋 Radar

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

// 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。

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

參考

iOS Debugging Magic (TN2239)
iOS Runtime Headers
Debugging Tips for iOS Developers
Hopper — a reverse engineering tool
IDA by Hex-Rays
Aspects — Delightful, simple library for aspect-oriented programming.
Building Aspects
Event Delivery: The Responder Chain
Chisel — a collection of LLDB commands to assist debugging iOS apps
Where the top of the stack is on x86
Stack frame layout on x86-64
AMD64 ABI draft
ARM 64-bit Architecture
Decompiling assembly: IDA vs Hopper

原文 Debugging: A Case Study

  1. 上一頁:
  2. 下一頁:
蘋果刷機越獄教程| IOS教程問題解答| IOS技巧綜合| IOS7技巧| IOS8教程
Copyright © Ios教程網 All Rights Reserved