過去調(diào)試 Swift 代碼基本靠手寫 print 這種非常原始的手法,作為一個(gè)有進(jìn)取心的青年,覺得該好好修煉調(diào)試技能了。打開上次學(xué)習(xí) LLDB 的 Demo,發(fā)現(xiàn)是三個(gè)月前建立的;Objc.io 有一期 Debug 的專題,老實(shí)說,我看過幾遍了,實(shí)戰(zhàn)的次數(shù)少得可憐,至今依然沒有記住幾個(gè)命令。為何會這樣,學(xué)習(xí)資料可以說是汗牛充棟,光簡書上就有一堆入門文章 @ 大 V 轉(zhuǎn)發(fā)后收獲了相當(dāng)可觀的紅心數(shù),但 Swift Debug 對初學(xué)者來說依然是件很困難的事情。一是官方的 Debug 文檔并沒有針對如何在 Swift 下使用進(jìn)行過說明;二是幾乎所有的相關(guān)文章都是針對 Objective-C 的,當(dāng)你試圖在 Swift 上使用時(shí)往往不靈,這實(shí)在是令人沮喪;三是這些文章和官方文章真的只是功能性文檔,實(shí)際中如何應(yīng)用往往一籌莫展。這三點(diǎn)足以讓 OC 時(shí)代僅僅依靠 NSLog 調(diào)試的我望而卻步,在 Swift 里依然使用 print 這種低效的手法。不管怎樣,這種局面需要改變了,第一步就從擺脫 print 開始吧。我在調(diào)試中使用 print 的主要目的是查看變量和跟蹤調(diào)用流程,那么使用 Xcode 和集成的 LLDB 調(diào)試工具如何做到這點(diǎn)?不過,不要期待會看到其他地方看不到的東西,這里講的都是其他文章里說過的,唯一不同的是切換到 Swift 里了,差別大嗎?不大,但這點(diǎn)差別足以讓你放棄,而這篇文章就是讓你重拾信心。
為何要使用斷點(diǎn)(break point)?
通過添加 print(在 OC 中是 NSLog)調(diào)試時(shí),每次都需要重新編譯,而且在實(shí)際項(xiàng)目中還需要把這些 print 調(diào)用清除,或許你可以使用條件編譯來避免這點(diǎn),僅就我個(gè)人來說,正式發(fā)布的項(xiàng)目里不清除 print 很難受。在 OC 中調(diào)試時(shí)如果測試的應(yīng)用對性能敏感,建議不要使用 NSLog,見 NSLog效率低下的原因及嘗試lldb斷點(diǎn)打印Log。在 Swift 中 print 是否也有此隱患我未做調(diào)查,不過顯然使用斷點(diǎn)好處多多。調(diào)試時(shí)使用 print 的痛點(diǎn)在于每次添加一條 print 語句都要重新編譯,如果只是個(gè)小 Demo 或許沒什么,幾秒鐘的事情,稍大一點(diǎn)的工程或者電腦配置已經(jīng)跟不上,編譯太花時(shí)間,繼續(xù)使用這種方式就有點(diǎn)浪費(fèi)生命了,而斷點(diǎn)則完全沒有這種不便。iOS 應(yīng)用是事件驅(qū)動(dòng)的,在應(yīng)用運(yùn)行時(shí),斷點(diǎn)可以隨時(shí)加入或取消,這些操作都是立即生效的,無需重新編譯。當(dāng)你在源文件左側(cè)行數(shù)列表處點(diǎn)擊時(shí)添加了一條什么都不做只是暫停應(yīng)用運(yùn)行的斷點(diǎn),此時(shí) LLDB 無縫切入應(yīng)用的運(yùn)行狀態(tài),在控制臺利用 LLDB 能做的事比 print 強(qiáng)多了。
p and po in Swift
在控制臺使用p和po來替代 print,想必你知道p和po除了輸出的格式稍有不同外,都是expression命令的別名,在 LLDB 環(huán)境下使用help查詢這兩個(gè)命令可以看到:
'p' is an abbreviation for 'expression --'
'po' is an abbreviation for 'expression -O -- '
而-O選項(xiàng)的意義是:
-O ( --object-description ) Display using a language-specific description API, if possible.
上面的 description API 調(diào)用的是NSObject協(xié)議中description和debugDescription屬性,這兩個(gè)屬性默認(rèn)打印對象的內(nèi)容。但對于 Swift 對象,po命令會忽略對象的description屬性,你也可以直接打印對象的這個(gè)屬性。
要我說兩者的差別主要在于,對于 Int, String, Arrar, Dictionary 這些值類型,p輸出的內(nèi)容中類型信息多,占用行數(shù)少,所以下面的斷點(diǎn)設(shè)置面板示例里我采用p而不是po;而對于類這種引用類型,p會打印出內(nèi)部詳細(xì)的變量類型和值,po則只輸出干巴巴的內(nèi)存地址外加換行符,浪費(fèi)屏幕空間,不能忍。
LLDB 語言切換
調(diào)試 Swift 代碼時(shí)盡力切換至 Swift 語法,不過基本上只有 expression 命令需要使用 Swift(OC) 代碼,但有時(shí)候你會發(fā)現(xiàn)即使在命令中切換至 Swift 語法也不起作用,比如從與調(diào)試器共舞 - LLDB 的華爾茲教了這樣的實(shí)用技巧來打印應(yīng)用的視圖層次:
po [[[UIApplication sharedApplication] keyWindow] recursiveDescription]
但是你這個(gè)命令轉(zhuǎn)化為對應(yīng)的 Swift 代碼卻出現(xiàn)這樣的錯(cuò)誤:
error: <EXPR>:1:44: error: value of type 'UIWindow' has no member 'recursiveDescription'
你訪問了私有變量,因此出錯(cuò)了。但是這個(gè)技巧在 OC 代碼是正常的,可能和 UIKit 框架是 OC 語言有關(guān),解決辦法是在 LLDB 中切換到 OC 語言環(huán)境使用這樣的命令:
expr -l objc++ -O -- [[[UIApplication sharedApplication] keyWindow] recursiveDescription]
注意,這個(gè)切換只對當(dāng)前這條命令有效,解決方案來自這里,不過我在看 Advanced Swift Debugging in LLDB 這個(gè) session 時(shí)沒提到為何這里指定語言是 objc++ 而不是 objc,對 LLDB 的底層還沒什么了解,暫且記住切換到 OC 環(huán)境使用 objc++ 而不是 objc 就好了。語言選項(xiàng)還包括 objc 和 swift。
如果調(diào)試 Swift 代碼時(shí)直接使用po [[[UIWindow keyWindow] rootViewController] _printHierarchy]這樣的 OC 語法,會遇到error: expected ',' separator這樣的錯(cuò)誤;另外使用 Swift 語法很多時(shí)候會遇到可選鏈,由于這里沒有自動(dòng)補(bǔ)全,往往你敲了一大堆 LLDB 告訴你沒有對可選值進(jìn)行封裝不能執(zhí)行,這無疑很讓人苦惱,這時(shí)候統(tǒng)統(tǒng)切換到 OC 環(huán)境就能解決。
expr -l objc++ -O -- [[[UIWindow keyWindow] rootViewController] _printHierarchy]
F 家出品的 Chisel 這個(gè) LLDB 插件合集自定義了很多方便的命令,但文檔上寫的通過brew來安裝的版本在 Swift 環(huán)境下很多命令不兼容,不過有人添加了對 Swift 的支持,可以直接下載 Github 上的版本手動(dòng)安裝來解決 Swift 環(huán)境的兼容問題。
斷點(diǎn)面板
命令交互只能駐留在某處,有時(shí)候你想要了解多處的狀態(tài),就需要添加多個(gè)斷點(diǎn),并在運(yùn)行到這些地方時(shí)輸出信息,這時(shí)候就需要設(shè)置斷點(diǎn)了。


這個(gè)畫面你必然不陌生,暫且只看第3點(diǎn):
1.Condition:輸入框內(nèi)添加 Bool 表達(dá)式,使用 Swfit 的語法,使用的變量僅限于斷點(diǎn)所在類以及所在函數(shù)棧中的變量。如果不添加約束條件(Condition 后面的輸入框內(nèi)為空),則每次循環(huán)時(shí)都會執(zhí)行添加的動(dòng)作。
2.Ignore:跳過符合條件的前幾次觸發(fā),注意,這里很容易犯下錯(cuò)誤,這里的跳過次數(shù)是指在應(yīng)用的整個(gè)生命周期內(nèi),也就是說這只是一次性有效。比如上面的 for 循環(huán)所在的函數(shù)即使多次執(zhí)行,設(shè)定的忽略次數(shù)在用完后就完了,而不是每次 for 循環(huán)執(zhí)行時(shí)跳過指定的次數(shù)。這個(gè)參數(shù)是一次性有效,而第1點(diǎn)的條件約束則是永久有效。
3.Action:想要取代 print 的我暫時(shí)只需要第3和4個(gè)選項(xiàng),一個(gè)斷點(diǎn)可以添加多個(gè)動(dòng)作,這個(gè)才是我這篇的重點(diǎn)。
4.Options:如果不需要在斷點(diǎn)處暫停,勾選最后一個(gè)選項(xiàng)「Automatically continue after evaluation actions」,執(zhí)行操作后繼續(xù)運(yùn)行;否則,應(yīng)用將會暫停,此時(shí)可以在控制臺與應(yīng)用進(jìn)行交互,實(shí)際上 LLDB 此時(shí)讓你直接介入應(yīng)用的運(yùn)行,你可以像寫代碼一樣修改變量,執(zhí)行其他動(dòng)作,以 LLDB 的形式。
看了上面4點(diǎn)估計(jì)直接暈了,我只是想 print 下而已,這種強(qiáng)大而麻煩的功能會直接嚇跑初學(xué)者。暫時(shí)只關(guān)注 Action,除了勾選最后一個(gè)選項(xiàng)讓斷點(diǎn)不要暫停應(yīng)用的執(zhí)行,其他都不要管,立馬出結(jié)果才是初學(xué)者想要的,然而 Action 也比較麻煩,想要在控制臺 print 有兩個(gè)選擇:Debugger Command 和 Log Message。

控制臺的輸出(在下面的輸出中,LLDB 調(diào)試 Swift 類時(shí)以$R16之類的變量存儲輸出結(jié)果,OC 類中則是$16之類的變量):
(String) $R16 = "Debug: i:2, sum:1"
Log: i: 2, sum: 1
(String) $R20 = "Debug: i:3, sum:3"
Log: i: 3, sum: 3
(String) $R24 = "Debug: i:4, sum:6"
Log: i: 4, sum: 6
就是這樣簡單,加上無需重新編譯的先天優(yōu)勢,使用斷點(diǎn)豈不是完爆 print ?但三個(gè)月前我為什么沒有愛上斷點(diǎn)呢?因?yàn)檎Z法讓我受挫,事實(shí)上說出這個(gè)原因讓我有點(diǎn)羞愧,竟然是這個(gè)原因。當(dāng)你看著各種入門 LLDB 的文章時(shí),第一知道的肯定是p和po這兩個(gè) print 命令,在這些文章的示例里都是在交互狀態(tài)下使用這兩個(gè)命令;而在 Debugger Command 的輸入框里要按照 Swift 的格式化字符串的語法來編寫命令;Log Message 的輸入框的語法以上圖中的簡單規(guī)則來處理,輸出的字符不需要""來引用,對變量的訪問使用@variable@來引用。這給當(dāng)時(shí)的我?guī)砹艘稽c(diǎn)困擾,盡管現(xiàn)在看來這不算什么難事,然而我確定當(dāng)時(shí)放棄的原因就是因?yàn)檫@個(gè),太特么分裂了。除了語法問題,寫代碼有自動(dòng)補(bǔ)全,添加一行 print 幾秒的事情,而添加一個(gè)斷點(diǎn)以及編寫沒有自動(dòng)補(bǔ)全加持的輸出語句,比起前者盡管需要重新編譯依然讓人沒有動(dòng)力切換。有時(shí)候太舒服了就不愿意挪窩。
那么這兩個(gè)動(dòng)作如何選擇呢?Debugger Command 和 Log Message 兩者對普通的變量的輸出沒有什么差別,但是對數(shù)組、字典之類的對象的輸出差別很大,后者無法輸出此類對象的內(nèi)容。對于如下兩個(gè)變量:
var testArray: [Int] = [1,2]
var testDic: [String: Int] = ["seedante": 18, "iOS": 9]
Debugger Command 中使用p "testArray: \(testAray) testDic: \(testDic)"的輸出結(jié)果如下:
(String) $R52 = "testArray: [1, 2] testDic: [\"iOS\": 9, \"seedante\": 18]"
Log Message 中使用等效的命令testArray: @testArray@ testDic: @testDic@的輸出結(jié)果為:
testArray: 2 values testDic:
有時(shí)候也會是這樣的結(jié)果:
testArray: 2 values testDic: 1 key/value pair
Log Message 丟失了很多信息,而且使用正確的鍵訪問testDic中的內(nèi)容也無法得到結(jié)果,Debugger Command 則沒有這個(gè)問題。
在輸出簡單的提示信息時(shí),Debugger Command 和 Log Message 沒有什么差別;需要訪問復(fù)雜的變量時(shí),后者會丟失很多信息,這時(shí)候應(yīng)該使用前者。
總體來說,使用斷點(diǎn)來替代 print 并不是高成本的事情,而且很靈活,不過有時(shí)候?qū)憘€(gè) print 真的就是很順手的事情啊。
如果僅僅只是查看某個(gè)變量值,print 或許更方便;但如果需要同時(shí)查看該函數(shù)棧里其他變量的狀態(tài),斷點(diǎn)就方便多了:設(shè)置該斷點(diǎn)時(shí)不要勾選最后一個(gè)選項(xiàng),這樣運(yùn)行到該斷點(diǎn)時(shí)應(yīng)用便會暫停,控制臺左側(cè)的變量視圖則會展示出該函數(shù)棧中的所有變量以及屬于該類的變量,而且還支持?jǐn)?shù)據(jù)預(yù)覽,另外在右邊的控制臺中此時(shí)可以和應(yīng)用進(jìn)行交互,甚至可以修改變量,這樣的優(yōu)勢是 print 無法比擬的。


不過,稍有瑕疵,變量視圖里
testDic的消息有誤,和 Log Message 的錯(cuò)誤一樣。
強(qiáng)大而難用的符號斷點(diǎn)(Symbolic breakpoint)
除了跟蹤變量狀態(tài),print 另外一大用途是跟蹤函數(shù)的調(diào)用,符號斷點(diǎn)可以在指定的函數(shù)被調(diào)用時(shí)執(zhí)行動(dòng)作,這是「開發(fā)者的大事,大快所有人心的大好事」,print 可以拋棄了,為什么我沒能早點(diǎn)學(xué)到(T▽T)。添加符號斷點(diǎn)的過程如下:



添加符號斷點(diǎn)的文檔中添加符號的語法如下:

但是我高興得太早了,上面的語法直接換成 Swift 也會有各種不兼容,這里開始才是對 Swift 進(jìn)行調(diào)試真正困難的地方,官方文檔至今沒有針對 Swift 進(jìn)行更新,而且關(guān)于調(diào)試的文檔整體上都缺乏語法細(xì)節(jié),所以在 Swift 應(yīng)用中 Symbol 的寫法只能靠猜。總結(jié)如下:Swift 的部分用 Swift 語法,Objective-C 的部分維持不變。有點(diǎn)玄乎,拆開細(xì)說。
**A method name: **
只指定方法,而不關(guān)心是哪個(gè)類執(zhí)行的,只要有同名的方法執(zhí)行就能觸發(fā)斷點(diǎn)動(dòng)作。但子類沒有重寫父類方法的話則子類不會觸發(fā)該符號斷點(diǎn),這樣一來很雞肋,不重寫就無法觸發(fā),就像需要另外一只手電筒才能點(diǎn)亮的手電筒。在 OC 中這個(gè)問題可以使用 Chisel 的bmessage命令解決,該命令可以為子類中未重寫的方法添加符號斷點(diǎn),這樣一來在視圖控制器子類中不用重寫viewDidxxx和viewWillxxx這兩個(gè)系列的方法就可以獲取調(diào)用的順序,然而這個(gè)命令在 Swift 中無效。
Arguments:
<expression>; Type: string; Expression to set a breakpoint on, e.g. "-[MyView setFrame:]",
"+[MyView awesomeClassMethod]" or "-[0xabcd1234 setFrame:]"
這是bmessage命令的參數(shù)文檔,實(shí)際使用發(fā)現(xiàn)三種使用方式的前兩種都沒有效,最后一種必須使用內(nèi)存中的地址,可以使用pvc打印出結(jié)構(gòu)來找出信息,不過這樣一來使用很受限制,也多大用處了。
徹底消滅 print 變成了不可能,接下來談?wù)務(wù)Z法細(xì)節(jié)。
Swift 里擁有相同函數(shù)名的方法是靠參數(shù)來區(qū)分的,但 LLDB 作為調(diào)試工具做不到這一點(diǎn),比如下面的幾個(gè)方法在 LLDB 的眼里都是methodDemo。

所以由 Swift 實(shí)現(xiàn)的類(比如你寫的或是 Swift 標(biāo)準(zhǔn)庫)中的方法,無論是重寫父類的方法,或是新添加的方法,無論是否帶參數(shù),一律不帶:或(),只寫函數(shù)名:methodName。
由 Objective-C 類實(shí)現(xiàn)的方法(現(xiàn)行的 iOS 框架還是由 OC 實(shí)現(xiàn)的),則按照文檔中給出的語法書寫,如下圖所示,UIViewController中的實(shí)例方法,在符號斷點(diǎn)中就按照這樣的格式書寫,不帶-:


比如無參數(shù)方法func viewDidLoad(),在符號斷點(diǎn)中寫viewDidLoad;帶參數(shù)方法:
func prepareForSegue(_ segue: UIStoryboardSegue, sender sender: AnyObject?)`
在符號斷點(diǎn)中寫作performSegueWithIdentifier:sender:。如果在 Swift 子類中重寫了這兩類方法,在符號斷點(diǎn)中分別寫作viewDidLoad和performSegueWithIdentifier,如上面說的那樣,LLDB 無法區(qū)分 Swift 方法的參數(shù),只接受函數(shù)名。判斷輸入的 Symbol 是否被接受,在填寫后回車,斷點(diǎn)處就會顯示出會被檢測到的方法列表,沒有的話就表示當(dāng)前格式不可接受或者沒有這個(gè)方法。下圖中添加了performSegueWithIdentifier:sender:的符號斷點(diǎn),第一個(gè)匹配 UIKit 框架中的方法,第二個(gè)匹配我用 Swift 實(shí)現(xiàn)的UIViewController子類重寫的該方法。

可以看到匹配 Swift 中重寫的方法的信息超長,而且一個(gè)方法有兩種匹配信息,這意味著添加的動(dòng)作會被執(zhí)行兩次。而且子類重寫方法的符號斷點(diǎn)里對方法或是類相關(guān)的變量的訪問有點(diǎn)問題。以viewDidAppear:為例,添加的動(dòng)作如下:
p " \(self) viewDidAppear: \(animated)"
控制臺的輸出:
error: <EXPR>:1:5: error: use of unresolved identifier 'self'
" \(self) viewDidAppear: \(animated)"
^~~~
<EXPR>:1:28: error: use of unresolved identifier 'animated'
" \(self) viewDidAppear: \(animated)"
^~~~~~~~
(String) $R17 = " <LLDBDemo.ViewController: 0x7be36e30> viewDidAppear: true"
所有針對子類重寫方法的符號斷點(diǎn)的輸出都有一條重復(fù),可能和上面截圖中的@objc有關(guān)。事實(shí)上此時(shí)在 LLDB 中也無法訪問self或是自身變量,而子類中新添加的方法則沒有這個(gè)問題,對變量的訪問也是正常的。
小結(jié):針對方法添加的符號斷點(diǎn)只會被實(shí)現(xiàn)了該方法的類觸發(fā),無論是用 Swift 或 Objective-C 實(shí)現(xiàn)的。針對 OC 類,Chisel 的bmessage該命令可以為子類中未重寫的方法添加符號斷點(diǎn),不支持 Swift 類。為 Swift 類中的方法(只能針對已經(jīng)實(shí)現(xiàn)了的)添加符號斷點(diǎn)時(shí),只接受方法名(不帶參數(shù), :或()),因此無法區(qū)分同名但參數(shù)不同的方法;Swift 中的 Struct 和 Enum 也支持方法,匹配原則與 Swift 類一樣;為 Objective-C 類的方法添加符號斷點(diǎn)則需要給出完整的方法原型名(包括參數(shù)名和:,不帶())。 由于現(xiàn)存的框架基本上還是用 OC 實(shí)現(xiàn)的,所以匹配現(xiàn)有框架的方法還是用 OC 的語法。
A method of a particular class
在前一種符號斷點(diǎn)的基礎(chǔ)上再添加類別的約束條件,只需要在前一種的基礎(chǔ)上添加作為前綴的 Class 名,在 Swift 中則需要添加 Class/Struct/Enum 名。
func viewDidLoad()
func viewDidAppear(animated: Bool)
func performSegueWithIdentifier(identifier: String, sender: AnyObject?)
以下為用 Swift 實(shí)現(xiàn)的ViewController類中重寫的以上三個(gè)方法添加類別約束的符號斷點(diǎn):

而為UIViewController這種 OC 類添加類別符號斷點(diǎn)時(shí),則要回到 OC 的語法:

注意:這種符號斷點(diǎn)針對的是 Class 本身,因此添加的動(dòng)作里輸出的格式化字符串中無法使用self或自身屬性等變量。
A function name
因?yàn)檫@里沒有 Swift 的事,所以這個(gè)跟文檔的說明沒有區(qū)別,只接受函數(shù)的完整原型名(帶參數(shù)和:,沒有()),比如_objc_msgForward和performSelector:withObject:afterDelay:。
符號斷點(diǎn)小節(jié)結(jié)束,知道這個(gè)小節(jié)取名的原因了吧。Debugging in Swift: How Hard Can It Be?這個(gè)演講發(fā)布有大半年了,別的不說,光搞清楚斷點(diǎn)的用法對初學(xué)者來講都是極困難的事情,忽然覺得 print 真是個(gè)好東西。
小結(jié)
總體而言,使用 LLDB 進(jìn)行調(diào)試還是比較辛苦的,print 依然是個(gè)性價(jià)比高的趁手小工具,但僅限于此了,掌握 LLDB 這一利器是進(jìn)階的必經(jīng)之路,學(xué)習(xí)它,好好利用它。
據(jù)主觀統(tǒng)計(jì),我目前看到的有關(guān) LLDB 入門的中文文章 99.99% 取材自《與調(diào)試器共舞 - LLDB 的華爾茲》這篇文章,所以入門還是去看這篇吧,同時(shí)這期專題的其他文章也值得反復(fù)閱讀,比如像我還看不懂。
推薦閱讀: