尋找 bug 非常耗費時間;幾乎每一個有經驗的開發者,都曾在某一個 bug 上花費過很多天。在一個平臺上開發的時間越久,就會越容易找到 bug。然而,總有一些 bug 是難以找到與復現的。在最開始的時候,找到一種途徑去復現 bug 總是很有用的。一旦你找到了某種途徑,可以持續的復現 bug ,你就可以開始下一步工作,找到 bug。
這篇文章試圖闡釋的是我們在除錯中經常遇到的一些相對常見的問題。當你遇到了一個 bug 時,你可以把本文當做一份核對清單。通過核對這份清單列出的一些問題,可能會使你更快的找到這個 bug。更理想的情況下,這裡提到的一些技巧可以幫助我們在第一時間避免這些 bug 出現。
我們會從一系列引起 bug 的原因開始講起,其中一大部分 bug 對大家都已經不算陌生。
回撥是否在正確的執行緒進行?
一個引發意外行為的原因,是有些東西執行在錯誤的執行緒上。舉個例子,當你在非主執行緒的其它執行緒上更新 UIKit 的物件時,事情會變的很糟糕。有的時候,更新會正常運轉,但大多數情況下,發生的情況都很怪異,甚至會引起崩潰。在你的程式碼中,利用斷言來檢查你是否在主執行緒中的做法可以緩和這種情況。通常來說,可能(意外地)發生在後臺執行緒中的回撥,可以來自網路請求,計時器,檔案讀取,或者是外部庫。
另一個解決方法是劃分出一個執行緒獨立的區域。舉個例子,如果你正在構建一個基於網路 API 的封裝,你可以把所有的執行緒都封裝在那裡進行處理。在後臺執行緒中執行所有的網路請求,但把它們的回撥全部轉移到主執行緒中。如此一來,你就再也不必擔心呼叫程式碼中會出現什麼問題。一個簡單的設計在開發中真的很有用。
這個物件的類是否正確?
這個問題基本上只存在於 Objective-C;在 Swift 中,有一個強壯的型別系統,可以精確的保證物件或值的型別安全。而在 Objective-C 中,偶然把物件的型別弄錯是很常見的。
例如,在 Deckset中,我們加入了一個與字型相關的新特性。其中,有一個物件的某個陣列屬性命名為 fonts,然後我假定這個陣列中的物件型別都為 NSFont。可事實證明,陣列裡其實包含的是 NSString 型別的物件(字型名)。我花費了一些時間才找到了原因,這是因為,在大多數部分情況下,程式是正常工作的。在 Objective-C 中,一種檢查型別問題的方法是利用斷言。另一種可以幫到自己的方法,是在命名時新增型別資訊(如:這個陣列可以命名為 fontNames)。在 Swift 中,確定型別就可以避免這些錯誤(如:使用 [NSFont] 而非 [AnyObject])。
當不確定一個物件的型別是否正確時,你可以在偵錯程式中將型別列印出來。另外,使用 isKindOfClass:的斷言去檢查一個物件的類是否正確也很實用。在 Swift 中,因為可選值的存在,你還可以使用關鍵字 as? 在任何需要的地方去做型別適配, 這比直接用 as 做強制轉換好用的多。以上的方法會讓你大大減少錯誤的概率。
具體的 Build 設定
另一個常見的原因,是 build 設定中不同的配置間有一些不易被發現的出入。比如,有時編譯器譯器會做一些優化,這使在除錯中根本不會出現的 bug 卻在產品釋出版本中的存在。這個情況相對來說並不常見,不過在當前的 Swift 釋出版中,就有報告表明類似問題的存在。
還有一種原因,是某個確定的變數或巨集定義被不同的方式定義。比如,一些程式碼可能會在開發中被註釋起來。我們在一個例項中寫了一些錯誤的(足以引發崩潰的)使用者行為統計程式碼,但在開發中我們關掉了統計,所以我們在開發 app 時永遠看不到這些崩潰。
這幾種 bug 在開發中是很難被發現的。所以,一定要詳細且徹底的測試你的釋出版 app。當然,如果有其他人(比如 QA 組)可以測試它再好不過。
不同的裝置
不同的裝置,可用性會有所不同。如果你只在有限數量的裝置上進行測試,未覆蓋到的裝置就會成為可能的 bug 原因之一。經典的劇情 是隻在模擬中測試而從未使用真機。不過即便你在真機上做了測試,你也需要考慮到不同的裝置與可用性。比如,在處理內建攝像頭時,總是使用類似 isSourceTypeAvailable: 這樣的方法來檢測你是否可以使用某個輸入源。在你的裝置上或許有可以工作的攝像頭,但是在使用者的裝置上卻並不總是存在。(譯者注:比如坑爹的老版本 iPod Touch 5 16G 版就沒有後置攝像頭)
可變性
可變性也是一個很常見的難以追蹤的原因。比如,如果你在兩個執行緒中共享了一個物件,且它們同時修改了該物件,就可能出現很意外的情況。這類 bug 的痛點在於它們很難復現。
有一種解決方法是建立不可變物件。這樣,當你訪問物件時,你就知道這個操作是無法改變它的狀態的。關於這點有太多可講,不過更多的資訊,我們建議你閱讀以下文章:結構體和值型別,值物件,物件的可變性和關於可變性。
是否為空 (nil)
作為 Objective-C 的程式設計者,我們有時會因為 NullPointerException 取笑 JAVA 程式設計師。在很多情況下,我們可以安全的傳送訊息給 nil 不出現什麼問題。不過,也有一些棘手的 bug 可能因此出現。如果你寫 Swift 代替 Objective-C,你可以安全的跳過這節內容的大部分,因為 Swift 的可選值足以解決這其中大部分的問題。
你是否以 nil 做為引數呼叫了函式?
這個原因挺常見。一些方法會因為你傳入了 nil 引數而崩潰。舉例,考慮以下片段:
1 2 |
NSString *name = @""; NSAttributedString *string = [[NSAttributedString alloc] initWithString:name]; |
如果 name 是 nil,這段程式碼將崩潰。複雜的地方在於當這可能是一個你沒有發現的邊界用例(如 myObject 在大多數情況下是不可能為 nil 的)。當寫你自己的方法時,你可以新增一個自定義標記,用來通知編譯器你是否允許 nil 引數:
1 2 3 |
- (void)doSomethingWithRequiredString:(NSString *)requiredString bar:(NSString *)optionalString __attribute((nonnull(1))); |
(來自:StackOverflow)
在新增這個標記之後,當你嘗試傳入一個 nil 引數時,會出現一個編譯器警告 。這挺好,因為你再也不用考慮這個邊界用例:你可以利用編譯器提供的功能替你做這樣的檢查。
另一種可行的方法是倒置資訊流。比如,你可以建立一個自定義分類,比如在 NSString 新增一個 attributedString 的例項方法 :
1 2 3 4 5 6 7 |
@implementation NSString (Attributes) - (NSAttributedString*)attributedString { return [[NSAttributedString alloc] initWithString:self]; } @end |
這段程式碼的好處是你現在可以安全的構造一個 attributedString。你可以寫 [@”John” attributedString],但你也可以將這個訊息傳送給 nil([nil attributedString]),這樣做並不會崩潰,而是得到一個 nil 的結果。想看到關於這點的更多資訊,請查閱 Graham Lee 的文章反轉資訊流。
如果你想捕捉到更多必須成立的條件(如一個引數必須為某個確定的類),你也可以使用 NSParameterAssert。
你是否確定你可以向 nil 傳送訊息?
這其實不是一個太常見的原因,但是它卻在一個真實的 app 中出現過。有時,當我們處理標量時,傳送一個訊息給 nil 可能產生意外的結果。來看看下面這段看起來沒什麼問題的程式碼片段:
1 2 3 4 5 |
NSString *greeting = @"Hello objc.io"; NSRange range = [greeting rangeOfString:@"objc.io"]; if (range.location != NSNotFound) { NSLog(@"Found the keyword!"); } |
如果 greeting 包含了字串 “objc.io”,訊息會被列印。如果 greeting 不包含這個字串,則不會有訊息被列印。不過,當greeting 為 nil 時會發生什麼呢?range 會變成一個值全部為0的結構體,而 location 會變成0。因為 NSNotFound 被定義為-1,所以之後的訊息會被列印出來。所以,任何時候,當你處理純值和 nil時,要確保考慮了更多情況。同樣的,Swift 可以使用可選值避免這個問題。
是不是類中的有什麼東西沒有初始化?
有時,當程式碼執行到某個物件相關的部分時,可能因為呼叫了一個未完全初始化的物件而被中斷。因為在 init 中加入一些額外的程式碼並不常見,所以,有時在你使用某個物件之前,你需要提前呼叫這個物件的一些方法。如果你忘記了呼叫這些方法,這個類就可能因為無法完全的初始化而出現一些奇怪的情況。所以,一定要確保在指定的初始化方法執行之後,類已經處於可用狀態。如果你確實需要指定的帶引數的初始化方法被執行,同時又無法構建出一個只使用 init 方法的就能完成初始化的類的話,你也可以選擇過載init 來讓它崩潰。不過,當你之後偶爾不小心用到 init 來例項化物件的時候,你可能會浪費一點時間來進行修改。
KVO
一個常見的原因是錯誤的使用 KVO。壞訊息是,犯錯誤並不難,但好訊息是,有一系列方法去避免。
你是否清除了你的觀察者?
一個簡單的錯誤是新增觀察者物件,但不清除它們。在這種情況下,KVO 將持續的傳送訊息,但接收者可能已經被釋放了,於是引發了崩潰。繞開它的一種方法是使用成熟的框架如 ReactiveCocoa,還有一些輕量級的庫用起來也不錯。
還有一種方法是,無論你何時建立了一個新觀察者,立刻在 dealloc 裡寫一個移除。然而,這個過程可以自動執行:比直接新增觀察者更好的辦法是,你可以建立一個自定義物件來讓它幫你進行新增。這個物件負責新增觀察者並在它自己的 dealloc 裡移除它。這樣做的優勢是你的觀察者的生命週期會和這個物件的生命週期一樣。這意味著建立這個物件等價於新增了一個觀察者。然後你可以將它存為一個屬性,當容器物件被析構,屬性會自動被設定為 nil,然後移除觀察者。
有一點相對詳細的關於這種技術的解釋,包括一段簡單的程式碼,可以在這裡被找到。一個小巧的庫可以實現這個功能,那就是THObserversAndBinders,或者你可以看看 Facebook 的 KVOController。
另一個關於 KVO 的問題是回撥可能會從你預料之外的執行緒上返回 (就像我們在開頭執行緒部分描述的那樣)。同樣的方案,使用一個物件來解決這個問題 (如以上所說),你可以確保所有的回撥會在一個確定的執行緒上返回。
依賴鍵(Dependent Key)的路徑
如果你觀察的屬性基於於另一個屬性,你需要確保你註冊了依賴鍵。否則,當你的屬性變化時,你可能不會得到回撥。不久之前,我在我的依賴鍵宣告裡建立了一個遞迴依賴 (屬性依賴於自己),然後奇怪的事情就發生了。
檢視
Outlet 和 Action
在使用 Interface Builder 時有一個常見的錯誤,那就是忘記了連線 outlet 和 action。現在它們通常會被標記在程式碼旁 (你可以在 outlet 和 action 旁邊看到小的圓圈)。當然,想測試是否所有連線都和預想的一樣的話,可以通過新增單元測試來達到目的 (但是這可能會變成很嚴重的維護負擔)。
另外,為了確保無論這種情況在何時發生,你都能儘快發現,你也可以使用斷言。比如用 NSAssert 去驗證你的 outlet 不是 nil。
未釋放的物件
當你使用了 Interface Builder,你需要確保從一個 nib 檔案中載入的物件圖不會被釋放。有一些蘋果關於處理這個問題的要點。最好讀讀這篇文章且遵從那些建議,要麼你的物件可能在你眼皮底下消失,或者過度持有。在簡單的 XIB 檔案和 Storyboard 中也有一些不同,請確保你已經倒背如流。
檢視的生命週期
當處理檢視的時候,有很多可能的 bug 會出現。一個常見的錯誤是在檢視還沒有初始化的時候就使用它們。或者,你可能在一個檢視只是初始化,卻還沒有設定尺寸時使用就使用它們。這裡的關鍵是在檢視生命週期中,找到合適的節點去安排程式碼。花時間去深入理解它們是如何工作,相對於以後除錯的時間來說,絕對是穩賺不賠的。
當你往 iPad 上 移植一個已有的 app 時,這有時也是一個常見的 bug 原因。與此前不曾遇到的情況不同,你現在可能需要擔心一個檢視控制器是否是一個子檢視控制器,它們如何響應旋轉事件,還有一些細微區別。針對這種情況,自動佈局可能會有一些幫助,它可以自動響應很多類似的變化。
一個常見的錯誤是我們總是建立一個檢視,新增一些約束,然後將它新增進父檢視裡。不過,為了讓大部分約束能夠工作,這個檢視是需要新增在父檢視的檢視層級中的。勉強算作好訊息的是,大部分情況下這會直接讓你的程式碼崩潰,然後你可以很快的找到 bug。
最後
但願以上的技術會幫助你擺脫 bug 或者完全的避免它們。還有一些自動的幫助是可用的:在 Clang 設定中開啟所有的警告訊息,這可以向你展示很多可能的 bug。另外,使用靜態分析肯定能找到一些 bug (當然你得定期的執行它)。