尋找 bug 非常耗費(fèi)時(shí)間;幾乎每一個(gè)有經(jīng)驗(yàn)的開發(fā)者,都曾在某一個(gè) bug 上花費(fèi)過很多天。在一個(gè)平臺上開發(fā)的時(shí)間越久,就會(huì)越容易找到 bug。然而,總有一些 bug 是難以找到與復(fù)現(xiàn)的。在最開始的時(shí)候,找到一種途徑去復(fù)現(xiàn) bug 總是很有用的。一旦你找到了某種途徑,可以持續(xù)的復(fù)現(xiàn) bug ,你就可以開始下一步工作,找到 bug。
這篇文章試圖闡釋的是我們在調(diào)試中經(jīng)常遇到的一些相對常見的問題。當(dāng)你遇到了一個(gè) bug 時(shí),你可以把本文當(dāng)做一份核對清單。通過核對這份清單列出的一些問題,可能會(huì)使你更快的找到這個(gè) bug。更理想的情況下,這里提到的一些技巧可以幫助我們在第一時(shí)間避免這些 bug 出現(xiàn)。
我們會(huì)從一系列引起 bug 的原因開始講起,其中一大部分 bug 對大家都已經(jīng)不算陌生。
一個(gè)引發(fā)意外行為的原因,是有些東西運(yùn)行在錯(cuò)誤的線程上。舉個(gè)例子,當(dāng)你在非主線程的其它線程上更新 UIKit 的對象時(shí),事情會(huì)變的很糟糕。有的時(shí)候,更新會(huì)正常運(yùn)轉(zhuǎn),但大多數(shù)情況下,發(fā)生的情況都很怪異,甚至?xí)鸨罎?。在你的代碼中,利用斷言來檢查你是否在主線程中的做法可以緩和這種情況。通常來說,可能(意外地)發(fā)生在后臺線程中的回調(diào),可以來自網(wǎng)絡(luò)請求,計(jì)時(shí)器,文件讀取,或者是外部庫。
另一個(gè)解決方法是劃分出一個(gè)線程獨(dú)立的區(qū)域。舉個(gè)例子,如果你正在構(gòu)建一個(gè)基于網(wǎng)絡(luò) API 的封裝,你可以把所有的線程都封裝在那里進(jìn)行處理。在后臺線程中執(zhí)行所有的網(wǎng)絡(luò)請求,但把它們的回調(diào)全部轉(zhuǎn)移到主線程中。如此一來,你就再也不必?fù)?dān)心調(diào)用代碼中會(huì)出現(xiàn)什么問題。一個(gè)簡單的設(shè)計(jì)在開發(fā)中真的很有用。
這個(gè)問題基本上只存在于 Objective-C;在 Swift 中,有一個(gè)強(qiáng)壯的類型系統(tǒng),可以精確的保證對象或值的類型安全。而在 Objective-C 中,偶然把對象的類型弄錯(cuò)是很常見的。
例如,在 Deckset中,我們加入了一個(gè)與字體相關(guān)的新特性。其中,有一個(gè)對象的某個(gè)數(shù)組屬性命名為 fonts,然后我假定這個(gè)數(shù)組中的對象類型都為 NSFont??墒聦?shí)證明,數(shù)組里其實(shí)包含的是 NSString 類型的對象(字體名)。我花費(fèi)了一些時(shí)間才找到了原因,這是因?yàn)?,在大多?shù)部分情況下,程序是正常工作的。在 Objective-C 中,一種檢查類型問題的方法是利用斷言。另一種可以幫到自己的方法,是在命名時(shí)添加類型信息(如:這個(gè)數(shù)組可以命名為 fontNames)。在 Swift 中,確定類型就可以避免這些錯(cuò)誤(如:使用 [NSFont] 而非 [AnyObject])。
當(dāng)不確定一個(gè)對象的類型是否正確時(shí),你可以在調(diào)試器中將類型打印出來。另外,使用 isKindOfClass:的斷言去檢查一個(gè)對象的類是否正確也很實(shí)用。在 Swift 中,因?yàn)榭蛇x值的存在,你還可以使用關(guān)鍵字 as? 在任何需要的地方去做類型適配, 這比直接用 as 做強(qiáng)制轉(zhuǎn)換好用的多。以上的方法會(huì)讓你大大減少錯(cuò)誤的概率。
另一個(gè)常見的原因,是 build 設(shè)置中不同的配置間有一些不易被發(fā)現(xiàn)的出入。比如,有時(shí)編譯器譯器會(huì)做一些優(yōu)化,這使在調(diào)試中根本不會(huì)出現(xiàn)的 bug 卻在產(chǎn)品發(fā)布版本中的存在。這個(gè)情況相對來說并不常見,不過在當(dāng)前的 Swift 發(fā)布版中,就有報(bào)告表明類似問題的存在。
還有一種原因,是某個(gè)確定的變量或宏定義被不同的方式定義。比如,一些代碼可能會(huì)在開發(fā)中被注釋起來。我們在一個(gè)實(shí)例中寫了一些錯(cuò)誤的(足以引發(fā)崩潰的)用戶行為統(tǒng)計(jì)代碼,但在開發(fā)中我們關(guān)掉了統(tǒng)計(jì),所以我們在開發(fā) app 時(shí)永遠(yuǎn)看不到這些崩潰。
這幾種 bug 在開發(fā)中是很難被發(fā)現(xiàn)的。所以,一定要詳細(xì)且徹底的測試你的發(fā)布版 app。當(dāng)然,如果有其他人(比如 QA 組)可以測試它再好不過。
不同的設(shè)備,可用性會(huì)有所不同。如果你只在有限數(shù)量的設(shè)備上進(jìn)行測試,未覆蓋到的設(shè)備就會(huì)成為可能的 bug 原因之一。經(jīng)典的劇情 是只在模擬中測試而從未使用真機(jī)。不過即便你在真機(jī)上做了測試,你也需要考慮到不同的設(shè)備與可用性。比如,在處理內(nèi)置攝像頭時(shí),總是使用類似 isSourceTypeAvailable: 這樣的方法來檢測你是否可以使用某個(gè)輸入源。在你的設(shè)備上或許有可以工作的攝像頭,但是在用戶的設(shè)備上卻并不總是存在。(譯者注:比如坑爹的老版本 iPod Touch 5 16G 版就沒有后置攝像頭)
可變性也是一個(gè)很常見的難以追蹤的原因。比如,如果你在兩個(gè)線程中共享了一個(gè)對象,且它們同時(shí)修改了該對象,就可能出現(xiàn)很意外的情況。這類 bug 的痛點(diǎn)在于它們很難復(fù)現(xiàn)。
有一種解決方法是創(chuàng)建不可變對象。這樣,當(dāng)你訪問對象時(shí),你就知道這個(gè)操作是無法改變它的狀態(tài)的。關(guān)于這點(diǎn)有太多可講,不過更多的信息,我們建議你閱讀以下文章:結(jié)構(gòu)體和值類型,值對象,對象的可變性和關(guān)于可變性。
作為 Objective-C 的編程者,我們有時(shí)會(huì)因?yàn)?NullPointerException 取笑 JAVA 程序員。在很多情況下,我們可以安全的發(fā)送消息給 nil 不出現(xiàn)什么問題。不過,也有一些棘手的 bug 可能因此出現(xiàn)。如果你寫 Swift 代替 Objective-C,你可以安全的跳過這節(jié)內(nèi)容的大部分,因?yàn)?Swift 的可選值足以解決這其中大部分的問題。
nil 做為參數(shù)調(diào)用了函數(shù)?這個(gè)原因挺常見。一些方法會(huì)因?yàn)槟銈魅肓?nil 參數(shù)而崩潰。舉例,考慮以下片段:
NSString *name = @"";
NSAttributedString *string = [[NSAttributedString alloc] initWithString:name];
如果 name 是 nil,這段代碼將崩潰。復(fù)雜的地方在于當(dāng)這可能是一個(gè)你沒有發(fā)現(xiàn)的邊界用例(如 myObject 在大多數(shù)情況下是不可能為 nil 的)。當(dāng)寫你自己的方法時(shí),你可以添加一個(gè)自定義標(biāo)記,用來通知編譯器你是否允許 nil 參數(shù):
- (void)doSomethingWithRequiredString:(NSString *)requiredString
bar:(NSString *)optionalString
__attribute((nonnull(1)));
(來自:StackOverflow)
在添加這個(gè)標(biāo)記之后,當(dāng)你嘗試傳入一個(gè) nil 參數(shù)時(shí),會(huì)出現(xiàn)一個(gè)編譯器警告 。這挺好,因?yàn)槟阍僖膊挥每紤]這個(gè)邊界用例:你可以利用編譯器提供的功能替你做這樣的檢查。
另一種可行的方法是倒置信息流。比如,你可以創(chuàng)建一個(gè)自定義分類,比如在 NSString 添加一個(gè) attributedString 的實(shí)例方法 :
@implementation NSString (Attributes)
- (NSAttributedString*)attributedString {
return [[NSAttributedString alloc] initWithString:self];
}
@end
這段代碼的好處是你現(xiàn)在可以安全的構(gòu)造一個(gè) attributedString。你可以寫 [@"John" attributedString],但你也可以將這個(gè)消息發(fā)送給 nil([nil attributedString]),這樣做并不會(huì)崩潰,而是得到一個(gè) nil 的結(jié)果。想看到關(guān)于這點(diǎn)的更多信息,請查閱 Graham Lee 的文章反轉(zhuǎn)信息流。
如果你想捕捉到更多必須成立的條件(如一個(gè)參數(shù)必須為某個(gè)確定的類),你也可以使用 NSParameterAssert。
nil 發(fā)送消息?這其實(shí)不是一個(gè)太常見的原因,但是它卻在一個(gè)真實(shí)的 app 中出現(xiàn)過。有時(shí),當(dāng)我們處理標(biāo)量時(shí),發(fā)送一個(gè)消息給 nil 可能產(chǎn)生意外的結(jié)果。來看看下面這段看起來沒什么問題的代碼片段:
NSString *greeting = @"Hello objc.io";
NSRange range = [greeting rangeOfString:@"objc.io"];
if (range.location != NSNotFound) {
NSLog(@"Found the keyword!");
}
如果 greeting 包含了字符串 "objc.io",消息會(huì)被打印。如果 greeting 不包含這個(gè)字符串,則不會(huì)有消息被打印。不過,當(dāng) greeting 為 nil 時(shí)會(huì)發(fā)生什么呢?range 會(huì)變成一個(gè)值全部為0的結(jié)構(gòu)體,而 location 會(huì)變成0。因?yàn)?NSNotFound 被定義為-1,所以之后的消息會(huì)被打印出來。所以,任何時(shí)候,當(dāng)你處理純值和 nil時(shí),要確??紤]了更多情況。同樣的,Swift 可以使用可選值避免這個(gè)問題。
有時(shí),當(dāng)代碼運(yùn)行到某個(gè)對象相關(guān)的部分時(shí),可能因?yàn)檎{(diào)用了一個(gè)未完全初始化的對象而被中斷。因?yàn)樵?init 中加入一些額外的代碼并不常見,所以,有時(shí)在你使用某個(gè)對象之前,你需要提前調(diào)用這個(gè)對象的一些方法。如果你忘記了調(diào)用這些方法,這個(gè)類就可能因?yàn)闊o法完全的初始化而出現(xiàn)一些奇怪的情況。所以,一定要確保在指定的初始化方法運(yùn)行之后,類已經(jīng)處于可用狀態(tài)。如果你確實(shí)需要指定的帶參數(shù)的初始化方法被運(yùn)行,同時(shí)又無法構(gòu)建出一個(gè)只使用 init 方法的就能完成初始化的類的話,你也可以選擇重載 init 來讓它崩潰。不過,當(dāng)你之后偶爾不小心用到 init 來實(shí)例化對象的時(shí)候,你可能會(huì)浪費(fèi)一點(diǎn)時(shí)間來進(jìn)行修改。
一個(gè)常見的原因是錯(cuò)誤的使用 KVO。壞消息是,犯錯(cuò)誤并不難,但好消息是,有一系列方法去避免。
一個(gè)簡單的錯(cuò)誤是添加觀察者對象,但不清除它們。在這種情況下,KVO 將持續(xù)的發(fā)送消息,但接收者可能已經(jīng)被釋放了,于是引發(fā)了崩潰。繞開它的一種方法是使用成熟的框架如 ReactiveCocoa,還有一些輕量級的庫用起來也不錯(cuò)。
還有一種方法是,無論你何時(shí)創(chuàng)建了一個(gè)新觀察者,立刻在 dealloc 里寫一個(gè)移除。然而,這個(gè)過程可以自動(dòng)執(zhí)行:比直接添加觀察者更好的辦法是,你可以創(chuàng)建一個(gè)自定義對象來讓它幫你進(jìn)行添加。這個(gè)對象負(fù)責(zé)添加觀察者并在它自己的 dealloc 里移除它。這樣做的優(yōu)勢是你的觀察者的生命周期會(huì)和這個(gè)對象的生命周期一樣。這意味著創(chuàng)建這個(gè)對象等價(jià)于添加了一個(gè)觀察者。然后你可以將它存為一個(gè)屬性,當(dāng)容器對象被析構(gòu),屬性會(huì)自動(dòng)被設(shè)置為 nil,然后移除觀察者。
有一點(diǎn)相對詳細(xì)的關(guān)于這種技術(shù)的解釋,包括一段簡單的代碼,可以在這里被找到。一個(gè)小巧的庫可以實(shí)現(xiàn)這個(gè)功能,那就是 THObserversAndBinders,或者你可以看看 Facebook 的 KVOController。
另一個(gè)關(guān)于 KVO 的問題是回調(diào)可能會(huì)從你預(yù)料之外的線程上返回 (就像我們在開頭線程部分描述的那樣)。同樣的方案,使用一個(gè)對象來解決這個(gè)問題 (如以上所說),你可以確保所有的回調(diào)會(huì)在一個(gè)確定的線程上返回。
如果你觀察的屬性基于于另一個(gè)屬性,你需要確保你注冊了依賴鍵。否則,當(dāng)你的屬性變化時(shí),你可能不會(huì)得到回調(diào)。不久之前,我在我的依賴鍵聲明里創(chuàng)建了一個(gè)遞歸依賴 (屬性依賴于自己),然后奇怪的事情就發(fā)生了。
在使用 Interface Builder 時(shí)有一個(gè)常見的錯(cuò)誤,那就是忘記了連接 outlet 和 action?,F(xiàn)在它們通常會(huì)被標(biāo)記在代碼旁 (你可以在 outlet 和 action 旁邊看到小的圓圈)。當(dāng)然,想測試是否所有連接都和預(yù)想的一樣的話,可以通過添加單元測試來達(dá)到目的 (但是這可能會(huì)變成很嚴(yán)重的維護(hù)負(fù)擔(dān))。
另外,為了確保無論這種情況在何時(shí)發(fā)生,你都能盡快發(fā)現(xiàn),你也可以使用斷言。比如用 NSAssert 去驗(yàn)證你的 outlet 不是 nil。
當(dāng)你使用了 Interface Builder,你需要確保從一個(gè) nib 文件中載入的對象圖不會(huì)被釋放。有一些蘋果關(guān)于處理這個(gè)問題的要點(diǎn)。最好讀讀這篇文章且遵從那些建議,要么你的對象可能在你眼皮底下消失,或者過度持有。在簡單的 XIB 文件和 Storyboard 中也有一些不同,請確保你已經(jīng)倒背如流。
當(dāng)處理視圖的時(shí)候,有很多可能的 bug 會(huì)出現(xiàn)。一個(gè)常見的錯(cuò)誤是在視圖還沒有初始化的時(shí)候就使用它們?;蛘撸憧赡茉谝粋€(gè)視圖只是初始化,卻還沒有設(shè)置尺寸時(shí)使用就使用它們。這里的關(guān)鍵是在視圖生命周期中,找到合適的節(jié)點(diǎn)去安排代碼?;〞r(shí)間去深入理解它們是如何工作,相對于以后調(diào)試的時(shí)間來說,絕對是穩(wěn)賺不賠的。
當(dāng)你往 iPad 上 移植一個(gè)已有的 app 時(shí),這有時(shí)也是一個(gè)常見的 bug 原因。與此前不曾遇到的情況不同,你現(xiàn)在可能需要擔(dān)心一個(gè)視圖控制器是否是一個(gè)子視圖控制器,它們?nèi)绾雾憫?yīng)旋轉(zhuǎn)事件,還有一些細(xì)微區(qū)別。針對這種情況,自動(dòng)布局可能會(huì)有一些幫助,它可以自動(dòng)響應(yīng)很多類似的變化。
一個(gè)常見的錯(cuò)誤是我們總是創(chuàng)建一個(gè)視圖,添加一些約束,然后將它添加進(jìn)父視圖里。不過,為了讓大部分約束能夠工作,這個(gè)視圖是需要添加在父視圖的視圖層級中的。勉強(qiáng)算作好消息的是,大部分情況下這會(huì)直接讓你的代碼崩潰,然后你可以很快的找到 bug。
但愿以上的技術(shù)會(huì)幫助你擺脫 bug 或者完全的避免它們。還有一些自動(dòng)的幫助是可用的:在 Clang 設(shè)置中打開所有的警告消息,這可以向你展示很多可能的 bug。另外,使用靜態(tài)分析肯定能找到一些 bug (當(dāng)然你得定期的運(yùn)行它)。