概述
我們都知道Xcode默認的調試器是LLDB(在此之前使用的是GDB),但是關於LLDB的debug技巧並非所有人都比較清楚,可能所有人都知道p或者po命令打印一些變量。但是實際的情況時這些還遠遠不夠。比如說有沒有一些情況下crash無法定位到有用信息,直接出現exc_bad_access,有沒有遇到過每次調試一個UI就要重新run一次Xcode(話說編譯時間真的影響一個UI開發者的效率)。
LLDB命令
po/p variable:打印變量信息or表達式
p和po有什么區別呢?
本質上兩個都是expression(可以縮寫成e)表達式的簡單表達(p = expression --,因為lldb會自動進行前綴匹配,但是使用pr不行因為無法消除和process的歧義;而po = e -o -- = expression -o --
,以對象的方法打印),但是不同的是po打印對象的description(類似於NSLog/print)。
既然它們等價於expression表達式,當然它們也可以進行一些變量賦值操作了,比如:e let $a = 1
(后面就是使用p $a + 1
),只要記住這個變量必須以$開頭,否則后面使用時無效。
p還有另外一個用法是格式化(po則不行),例如:p/x 16
則會打印出:0x0000000000000010
有些時候通過expression執行了UI操作后並不想直接中斷接下來的調試而想要看到更新效果可以執行e CATransaction.flush()
,因為lldb的斷點會中斷UI更新所以使用flush是一種比較好的更新方式。
關於更多lldb命令的說明,一個簡單額技巧是直接輸入help ,另外有時會可能會遇到無法打印變量,可以看一下SWIFT_OPTIMIZATION_LEVEL,當然如果你在一個block內打印一個外部變量因為默認並不會捕獲這個變量所以也應該無法打印此值。
frame
frame用於查看當前棧幀信息(棧上面的函數調用內存片段),比如說frame info
就會打印當前所在函數、行數和所在文件等信息。
frame variable
:查看當前棧幀中的變量(包括外部全局變量,當然如果只想要看當前文件中的全局變量使用 target variable = ta v
)
frame variable x = fr v x = v x
:查看當前棧幀中的變量值(如果配合-L
參數可以查看一個變量的內存地址),但是不同於expression的是frame variable僅僅是查看當前棧幀中的實際存儲變量,如swift中的一個計算變量它是沒辦法看到的,因為它的運行原理並不是像expression一樣會在一個context中執行相關的代碼進行結果輸出。
watchpoint
除了breakpoint之外watchpoint應該是一種比較常用的內存訪問斷點機制,有時會隨着界面越來越復雜,有些變量可能會在多處修改造成看起來你的修改並不能達到你理解的目的,這時如果使用watchpoint監控所有的修改甚至讀取就比較有用了。watchpoint使用有兩種方式:
watchpoint set variable
:監聽一個變量的變化
watchpoint set expression
:通過一個表達式為一個地址添加斷點
比如說:watchpoint set variable self.isLoad
(假設isLoad是VC中一個標記viewDidLoad()
是否執行過的標記),此當可以看到輸出:
Watchpoint created: Watchpoint 1: addr = 0x100a04500 size = 1 state = enabled type = w declare @ '/Users/cuijiangtao/Library/Mobile Documents/com.apple.CloudDocs/XcodeDebug/XcodeDebug/ViewController.swift:73' watchpoint spec = 'self.isLoad' new value: false watchpoint set variable self.isLoad
一旦self.isLoad被修改則會命中斷點,同時輸出:
Watchpoint 1 hit: old value: false new value: true
但是到了這里可能我們還會遇到一種情況是如果要是想要監聽一個控件的屬性修改怎么辦呢,可以試一下用上面的方法watchpoint set variable subview1.frame
(subview1是某個UIView類型的屬性)應該會提示沒有frame屬性。常用的方式可以通過斷點到一個UIView的setFrame方法上(注意:這里的語法是OC,即使在swift環境中),比如:breakpoint set -F '-[UIView setBounds:]' -c '((int*)$esp)[1] == 0x10131e440'
當然這里最主要是還是獲取寄存器索引和對應UIView的地址,可以使用:po self.view
。
注意在Xcode的variable面板其實右鍵也可以監控某個變量狀態,甚至可以查看其內存。
thread backtrace
顯示程序停止在breakpoint前當前線程所有幀,用於追蹤調用堆棧信息。值得一提的是類似功能的還有bt
,它並非thread backtrace的簡寫(如果是簡寫應該是tb才對吧)而是_regexp-bt
簡寫。
image(target modules)
借助image我們能夠查看當前的Binary Images相關的信息,日常開發我們主要利用它尋址。比如有了棧地址通過image lookup -a xxx
即可查找到具體執行的位置。
其他第三方腳本
chiles
因為lldb內部完整的支持python調用,比如執行一段python腳本:script print (sys.version)
,所以可以借助這個功能實現更加復雜的命令進行調試。
借助lldb提供的python api可以讓debug更加得心應手,不僅可以導入一個python文件還可以通過在每次lldb啟動時自動加載script來讓你的配置使用更加方便(將python api放到此目錄:~/.lldbinit
)。當然這樣以來你的調試腳本就可以分發給更多人使用,比較常用的就是facebook的Chisel。這里簡單列舉一下chiles常用的命令。
pvc
:查看當前控制器狀態
pviews
:查看UIWindow及其子視圖層級關系
presponder
:打印一個對象的響應鏈關系
pclass
:根據內存地址打印相關信息
visualize
:使用mac系統preview程序查看UIImage、CGImage、UIView、CALayer、NSData(of an UIImage)、UIColor、CIColor。
show/hide
:顯示or隱藏一個UIView
mask/umask
:給一個UIView或CALayer添加一個半透明蒙版
border/unborder
:給指定的UIView或CALayer添加邊框或移除邊框用於調試,記得執行后緊接着執行caflush
caflush
:刷新界面UI,類似於前面介紹的flush
bmessage
:添加一個斷點,即使這個函數在子類沒有實現(比如說在UIViewController中想在viewWillAppear中打斷點,但是很可能沒有實現父類方法,就可以通過bmessage [UIViewController viewWillAppear:]
添加)
wivar
:相當於kvo,監聽一個變量,例如wivar self _subviews
taplog
:開啟點擊log功能,當點擊某個控件時會打印相關控件的信息
paltrace
: 打印指定view的自動布局信息,比如:paltrace self.view
ptv
:打印當前界面中的UITableView,相對應的還有pcells
打印當前界面中的UITableViewCell
pdata
:Data的string解碼
vs
:搜索指定的view並加上半透明蒙版(包含子命令),例如:vs 0x13a9efe00
就可以標注出對應的控件
slowanim/unslowanim
:降低(或取消)動畫速度,默認0.1 ,可以在任意斷點或Xcode暫定執行slowanim即可,方便動畫調試
lldb_commands
lldb_commands是另一個第三方的lldb擴展庫,其中提供了很多實用的文件操作,可以讓你的調試更加如虎添翼。
ls
:顯示指定路徑的目錄或文件列表
pexecutable
:打印當前可執行文件所在位置
dumpenv
:查看環境信息,比如說沙盒地址
yoink
:拷貝指定目錄的文件到mac的臨時目錄
keychain
:查看keychain信息
debug流程控制
debug流程控制本質還是lldb,但是為了方便大家理解這里單獨列出來。大家都知道Xcode調試器下面的四個按鈕:continue
,step over
,step into
,step out
。但是如果你不想點擊也可以直接在lldb中使用c
、n
、s
、finish
(or thread step-out
)
另外有一個流程控制的命令比較常用就是thread return [variable]
不過到目前為止swift貌似還不支持此命令,存在已知bug。
有時會想要使用lldb查看一些信息,比如說查看當前在運行的程序視圖層級而並沒有添加合適的斷點,可以使用XCode debug區域的暫停(debug狀態它是continue)即可進入調試模式。比如此時打印UI層級:po [[UIApplication sharedApplication].keyWindow recursiveDescription]
可以看到類似信息:
<UIWindow: 0x127e085d0; frame = (0 0; 414 896); gestureRecognizers = <NSArray: 0x281403120>; layer = <UIWindowLayer: 0x281a584a0>> | <UITransitionView: 0x127d0b210; frame = (0 0; 414 896); autoresize = W+H; layer = <CALayer: 0x281a6d520>> | | <UIDropShadowView: 0x127d0b920; frame = (0 0; 414 896); clipsToBounds = YES; autoresize = W+H; layer = <CALayer: 0x281a6d420>> | | | <UIView: 0x127d0a710; frame = (0 0; 414 896); autoresize = W+H; layer = <CALayer: 0x281a6d3e0>>
這時比如說想要修改rootView的顏色直接執行:
e id $v = (id)0x127d0a710 e (void)[$v setBackgroundColor:[UIColor yellowColor]] e (void)[CATransaction flush]
前面提到了recursiveDescription
,但是確是在swift項目中的打印。大家有可能也會注意到在swift的環境有時運行的代碼方式還是objc風格,之所以可以在swift項目中這么打印因為在暫停狀態其實就是在端口mach_msg_trap中,這是一個objc運行環境,可以點擊左側調用棧看一下:
從注釋也可以看出都objc_msgSend
,所以此時用objc語法在lldb中調用是可以的。但是如果在Swift項目中你在某處斷點然后打印上面的命令應該會看到:
error: <EXPR>:3:17: error: expected ',' separator [[UIApplication sharedApplication].keyWindow recursiveDescription], error: <EXPR>:3:46: error: expected ',' separator [[UIApplication sharedApplication].keyWindow recursiveDescription]
明顯的語法錯誤,那換成swift語法(po UIApplication.shared.keyWindow?.recursiveDescription
)是不是就行了呢,這是會看到如下的錯誤,倒不是語法錯誤,是說UIWindow壓根就沒有recursiveDescription屬性(在swift中根本沒有實現這個方法)。
error: <EXPR>:3:33: error: value of type 'UIWindow' has no member 'recursiveDescription' UIApplication.shared.keyWindow?.recursiveDescription
不過不用擔心,新的lldb已經支持在swift運行環境執行objc代碼,將上面的命令換成如下命令即可,不過注意swift語法必須在``中:
e -l objc -o -- [`UIApplication.shared.keyWindow` recursiveDescription]
當然你也可以直接使用objc語法:
e -l objc -o -- [[UIApplication sharedApplication].keyWindow recursiveDescription]
常見異常解決
通過添加全局異常捕獲斷點並非可以定位到所有問題,有些問題的定位並不會那么直接,首先看一下常見的幾種crash(異常中斷類型,當然有些中斷並不是Crash例如exit、main函數執行結束、最后一個線程結束等):
-
NSException:遇到OC沒有捕獲的異常或者C++ Exception,這個也是一般第三方crash收集工具常用的方法(NSSetUncaughtExceptionHandler(&UncaughtExceptionHandler);),因為iOS本身如果遇到了沒有捕獲的異常會調用一個UncaughtExceptionHandler,比如數組訪問越界。但是也並不是所有的Exception都可以執行到此回調,有些objc_exception_throw是無法執行到此handler的(可能直接terminate、abort然后執行pthread_kill,比如非主線程UI布局)。
-
CPU無法執行的指令(EXC_BAD_ACCESS、除0運算):通常是Signal錯誤(軟件中斷,通常可以使用
signal(SIGABRT,MySIGABRTHandler)
來攔截信號中斷),這類問題需要利用unix標准的signal機制,例如中斷信號是SIGINT、SIGABRT(abort函數生成的信號,通常EXC_BAD_ACCESS錯誤就是系統最終調用了abort函數發出SIGABRT信號終止的),當然也有可能出現Mach Exception進而拋出EXC_BAD_ACCESS。 -
操作系統策略(WatchDog、OOM、OverHeat):因為系統CPU占用過高,內存過高、過熱引起的系統中斷,通常解決方式是找到大任務、內存泄漏或突然內存峰值過高的地方進行修復。
-
異常情況程序主動退出(abort、assert):一般是開發者設置的屏障函數,避免因為沒有滿足必要條件而調用。
那么針對不同的異常(NSException、C++ Exception、Mach Exception和Signal)怎么debug呢?
捕獲全局異常
大家在debug過程中常見的問題是出錯了直接崩潰無法定位到相關出錯代碼,常用的方式就是在Xcode的breakpoint navigator中添加一個Swift Error BreakPoint(或者OC則添加All Objective-C Exceptions),當然用lldb命令也是可以解決的:
Swift Error BreakPoint: br s -E swift
此時可以看到輸出:Breakpoint x: where = swiftlibswiftCore.dylib`swift_willThrow, address = 0x00000001a1616aa0
All Objective-C Exceptions:br s -E objc
此時可以看到輸出:Breakpoint x: where = libobjc.A.dylib`objc_exception_throw, address = 0x0000000193b3008c
使用上面的命令在遇到swift或者oc異常拋出時則會停在斷點處。
另一種方式是使用image尋址也,比如image lookup --address 0x0001b2666
也可以輸出具體的文件和函數等信息。
EXC_BAD_ACCESS
有些bug可以通過全局異常捕獲就可以輕松定位到具體位置,EXC_BAD_ACCESS一般就無法定位到具體位置了,通常出錯信息是這樣的:MACH_Exception Crashed with mach exception EXC_BAD_ACCESS, fault_address: 0x0000000000000001
,它根本上就是訪問了野指針造成的,一般在iOS中就是訪問了一個已經釋放了的對象。但是Xcode提供了Zombie Objects檢測功能,只要在Xcode中的Diagnostics啟用即可。
Zombie Objects用來檢測僵屍對象,關於Xcode檢測僵屍對象的原理可以在CFRuntime.c源碼中看到,其中定義了一個__CFZombifyNSObject
(可以在Xcode中添加Symbolic breakpoint,Symbol輸入__CFZombifyNSObject即可)。它的實現原理就是swizzle,將NSObject的dealloc
替換成了__dealloc_zombie
,首先判斷__CFZombieEnabled是否開啟,如果開啟了,當對象引用計數器為0的時候將根據當前類名生成一個_NSZombie_%s
的新類,然后使用object_setClass
將當前對象類型替換成新的類型,之后一旦再給這個對象發送消息則拋出異常並打印相關信息。
比如使用下面的代碼進行test:
NSObject *obj = [[NSObject alloc] init];
[obj release];
[obj release];
開啟僵屍對象檢測會打印:*** -[NSObject release]: message sent to deallocated instance 0x108b12df0
並定位到出錯位置。但是並不是所有的情況都可以用Zombie Objects檢測出來,比如說下面的代碼:
char *p = malloc(8);
p[9] = 'a';
NSLog(@"%c",p[9]);
free(p);
這段代碼看起來有明顯的越界訪問內存錯誤,不過多數情況下並不會報錯,可以正常運行,偶爾報錯也會定位到main函數中。但是一旦啟用了Diagnostics中的Address Sanitizer所有的問題迎刃而解:
具體崩潰原因是Heap buffer overflow溢出了。
Address Sanitizer支持的檢測類型包括下面幾種:
- Use-after-free
- Heap buffer overflow
- Stack buffer overflow
- Global variable overflow
- Overflows in C++ containers
- Use-after-return
Address Sanitizer 的原理是當程序創建變量分配一段內存時,將此內存后面的一段內存也凍結住,標識poisoned memory。當程序訪問到中毒內存時(buffer overflow),就會拋出異常,並打印出相應相關信息。如果對象釋放了,對象所占的內存也會標識為中毒內存,這時候訪問這段內存同樣會拋出異常(Use-after-free),除此之外從Xcode 9開始支持use-after-scope/return,不過對於use-after-return記得打開檢測時在Xcode中額外勾選Detect use of stack after return。
Memory Leak
內存泄漏檢測應該是iOS開發人員經常進行的一項調試工作,常見的就是Retain Cycle循環引用,比如說大家遇到最多的block引用。
首先最有效的辦法就是使用Instruments的leak checks進行檢測,這種方式不會遺漏任何內存泄漏的情況,而且可以精准定位到泄漏處(通過切換到Cycles & Roots面板)。但是,這種方式自然有有它的缺點,那就是對於龐大的項目來說每次運行Instruments分析一次要花費數十分鍾(畢竟要先archive一次)。
當然Xcode從iOS 8就提供了Debug Memory Graph可以更加快速的定位leak(可以通過Xcode左側Debug Navigator面板下發的Show only leaked blocks過濾leak block)。當然如果你使用這個工具建議首先在scheme中打開Diagnostics中的Malloc Stack以便記錄調用log方便在Xcode Memory Inspector中回溯。
但是即使如此每寫一段代碼就運行一次也不一定是所有開發者可以做到的,何況有些情況下Debug Memory Graph並不能檢測到泄漏(這一點就不如Instruments了),不過相信即使每次都能檢測到泄漏大家也不會每次編碼都執行一次。那么如果有一種方式在運行app時給出泄漏的提示就最好了,這樣就沒有額外的檢測成本了。當然也有一些第三方庫可以幫助我們進行leak檢測比如說MLeaksFinder就可以幫助我們檢測沒有釋放的控制器(如果是其他對象需要編寫一些代碼),而它本身也可以使用facebook的FBRetainCycleDetector進行更加強大的檢測功能。
其實crash修復無非也就幾種情況一種是可以直接定位到具體代碼上,這種通常堆棧信息比較完整,出錯位置可以定位到具體代碼而不是main函數(這種問題修復的主要路徑是debug或者人為分析代碼邏輯);另外一種堆棧信息幾乎沒有有用信息(基本都系統庫調用沒有其他額外信息)這種情況相對比較復雜。因為代碼分析的范圍並不確定,更不用說復現了,但是相信借助上面的工具還是可以解決絕大多數問題的。
當然,關於具體根據dsym分析crash並不在文章分析的范圍,這個有大量的文章進行分析這里不再贅述。
Xcode 工具
編輯斷點
其實前面更多的強調使用lldb進行debug,事實上Xcode提供了很多UI工具,比如說在一個breakpoint處點擊編輯斷點(如下圖),可以在conditon中添加條件比如說a == 1,這樣只有符合這個條件才會命中這個斷點。也可以在下面的action中進行表達式輸出,比如說程序已經運行了想要查看c變量的值而不用中斷執行就可以勾選上Automatically continue after evaluation actions,同時在action中通過lldb命令e print(c)打印,當然如果你喜歡可以在這里使用任何lldb命令臨時修改你的程序(比如返回前把c賦值成其它值,甚至修改某些界面元素)而不需要重新運行,這對你調試一些UI至關重要。
匯編調用棧中打印函數實參
有些時候問題的調試並不能准確定位在某個斷點,可能是某類調用,比如說你發現一個UILabel的text被莫名的修改了(有時會也可能是被hook修改了),前面也提到過這時可以使用Xcode的符號斷點標記符號:-[UILabel setText:]
每次命中這個符號就會斷點到調用的位置。強大的是此時Xcode中支持在匯編調用棧中打印函數實參,方法是是用:po $arg1($arg2 etc)
,可以看到對應的參數信息:
UI debugger
前面也提到了很多和UI有關的debug方法,包括打印視圖層級、查看視圖約束等,當然由此也產生了很多UI調試工具比如大名鼎鼎的Reveal應該是很多開發者必備的UI調試工具,當然它也有缺點,比如說復雜視圖它經常會卡死(視頻debug經常遇到,特別是出現OpenGL等渲染的地方)。當然蘋果顯然也意識到了這個問題,所以從Xcode 6開始在debug工具欄可以點擊Debug View Hierarchy查看視圖層級,當然它的缺點就是不如Reveal可以直接修改一些界面屬性。
Main Thread Checker
相信升級了iOS 13 SDK的朋友都會有一個明顯的感受,一旦出現異步線程操作UI必然crash,這個在之前的版本中可能會出現一些異常的情況,比如說MBProgress無法展示或者半天才出現等,但是在iOS 13 則會直接crash。事實上為了解決這類問題Xcode 9就加入了Main Thread Checker功能,只要在Diagnostics中啟用即可。具體原理,其實是hook一個認為應該在主線程執行的方法,調用前進行線程檢測,如果發現某個主線程操作現在在其他線程執行則會打印log:Main Thread Checker: UI API called on a background thread: -[UIView init]
並斷點到具體位置。其實現在Xcode 的Main Thread Checker本身也是一個breakpoint,可以在斷點界面查看到有一類Runtime issue breakpoint,里面除了支持Main Thread Checker還支持Thread Sanitizer、Undefined Behavior和System Frameworks檢測。
當然隨着lldb的發展、Xcode的升級有很多新的調試功能和技巧在這里無法一一贅述,比如說Xcode 9就開始加入的無線調試,Xcode 11增加的熱壓力模擬、網絡狀態模擬,又比如關於SpriteKit、OpenGL的等一些偏渲染層面的調試就不在本文中介紹了。另外關於Instruments更是一個強大的調試工具集,例如其中的內存泄漏、內存占用、性能分析等都是比較常用的一些工具,其具體用法本身也是一個比較大的topic這里也不再具體介紹等有機會單獨分析。
其他補充
網絡調試利器-Charles
Charles應該是多數iOS開發者甚至其他mac開發者必備的網絡調試工具,查看網絡請求、響應內容,查看響應速度,查看ssl加密內容(前提是app沒有進行證書驗證),添加斷點調試,模擬網絡請求,模擬網絡帶寬,甚至做一些響應的轉發(支持本地和遠程轉發),都可以大大幫助你提升調試效率。
Woodpecker
最后再說一下國內開發者開發的WoodPecker,這個mac應用實現了查看沙盒文件,監控App網絡請求,查看UserDefaults,KeyChain數據等等功能,常常用來查看沙盒中的一些數據庫或者文件,當然盡管這些操作使用其他命令也可以實現,但是畢竟一個直觀的界面可以讓你更高效。