尋找 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 參數而崩潰。舉例,考慮以下片段:
NSString *name = @""; NSAttributedString *string = [[NSAttributedString alloc] initWithString:name];
如果 name 是 nil,這段代碼將崩潰。復雜的地方在於當這可能是一個你沒有發現的邊界用例(如 myObject 在大多數情況下是不可能為 nil 的)。當寫你自己的方法時,你可以添加一個自定義標記,用來通知編譯器你是否允許 nil 參數:
- (void)doSomethingWithRequiredString:(NSString *)requiredString bar:(NSString *)optionalString __attribute((nonnull(1)));
(來自:StackOverflow)
在添加這個標記之後,當你嘗試傳入一個 nil 參數時,會出現一個編譯器警告 。這挺好,因為你再也不用考慮這個邊界用例:你可以利用編譯器提供的功能替你做這樣的檢查。
另一種可行的方法是倒置信息流。比如,你可以創建一個自定義分類,比如在 NSString 添加一個 attributedString 的實例方法 :
@implementation NSString (Attributes) - (NSAttributedString*)attributedString { return [[NSAttributedString alloc] initWithString:self]; } @end
這段代碼的好處是你現在可以安全的構造一個 attributedString。你可以寫 [@"John" attributedString],但你也可以將這個消息發送給 nil([nil attributedString]),這樣做並不會崩潰,而是得到一個 nil 的結果。想看到關於這點的更多信息,請查閱 Graham Lee 的文章反轉信息流。
如果你想捕捉到更多必須成立的條件(如一個參數必須為某個確定的類),你也可以使用 NSParameterAssert。
你是否確定你可以向 nil 發送消息?
這其實不是一個太常見的原因,但是它卻在一個真實的 app 中出現過。有時,當我們處理標量時,發送一個消息給 nil 可能產生意外的結果。來看看下面這段看起來沒什麼問題的代碼片段:
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 (當然你得定期的運行它)。