實戰:因為 Category 造成的 Crash

KKBOX 在 2014 年十月推出 iOS 版本 6.0.26 版本,推出之後,就收到不少客訴反應應用程式在執行到特定的地方—瀏覽線上精選畫面—時會發生 crash。這件事情非常奇怪,因為這個版本在我們的開發到 QA 驗證的過程中,從來就沒有在這個地方發生過crash,如果按照客訴在電話中的描述,我們也完全無法重現問題。

所幸我們可以收集到來自用戶的 crash report。

看到這份 crash log 的第一印象就是用戶做了 JB。用戶用的機種明明就是 iPhone 5S,應該要執行 armv7s 的 library,但是卻載入了一大堆 armv6 的 library,而你看到 CydiaSubstrate 的時候,心底也有個底了。

接著來看 crash 類型,是在 -[__NSPlaceholderDictionary initWithObjects:forKeys:count:] 裡頭發生了 exception,__NSPlaceholderDictionary 是 NSDictionary 的內部實作,當我們在建立 NSDictionary 的時候,Foundation 其實會回傳的是另外一個介面相同的 subclass 回來。至於 NSDictionary 會產生的 exception 幾乎都跟 nil 有關—嘗試把 nil 插入到 NSDictionary 裡頭造成的。

我們收集到了 exception 發生時的 console log:

*** Terminating app due to uncaught exception 'NSInvalidArgumentException', reason: '*** -[__NSPlaceholderDictionary initWithObjects:forKeys:count:]: attempt to insert nil object from objects[2]'

我們嘗試建立 Dictionary 的時候,第三筆傳遞進去的資料(objects[2])是 nil。我們繼續根據解開來的位置,檢查一下 KKExploreCardCollectionItemCell.m 的第 159 行,drawRect: 這個 method。 KKExploreCardCollectionItemCell 是線上精選頁面 collection view 裡頭使用到的一個 cell,符合「瀏覽線上精選」會 crash 這條描述。

以下程式從 142 行開始

 - (void)drawRect:(CGRect)rect
 {
    UIBezierPath *path = [UIBezierPath bezierPathWithRoundedRect:self.bounds cornerRadius:3.0];
    [[UIColor whiteColor] set];
    [path fill];
    [path addClip];

    NSMutableParagraphStyle *paragraphStyle = [[NSMutableParagraphStyle alloc] init];
    paragraphStyle.alignment = NSTextAlignmentLeft;

    if (self.subtitle.length) {
        CGSize titleSize = [self.title boundingRectWithSize:CGSizeMake(self.frame.size.width - 22, 20.0) options:NSStringDrawingTruncatesLastVisibleLine | NSStringDrawingUsesLineFragmentOrigin attributes:@{NSFontAttributeName: [UIFont systemFontOfSize:14.0], NSParagraphStyleAttributeName: paragraphStyle} context:nil].size;
        CGRect titleRect = CGRectMake(11.0, CGRectGetMaxY(imageFrame) + 10.0, titleSize.width, titleSize.height);
        [self.title drawWithRect:titleRect options:NSStringDrawingTruncatesLastVisibleLine | NSStringDrawingUsesLineFragmentOrigin attributes:@{NSFontAttributeName: [UIFont systemFontOfSize:14.0], NSParagraphStyleAttributeName: paragraphStyle, NSForegroundColorAttributeName: [UIColor blackColor]} context:nil];

        CGSize subtitleSize = [self.subtitle boundingRectWithSize:CGSizeMake(self.frame.size.width - 22, 20.0) options:NSStringDrawingTruncatesLastVisibleLine | NSStringDrawingUsesLineFragmentOrigin attributes:@{NSFontAttributeName: [UIFont systemFontOfSize:12.0], NSParagraphStyleAttributeName: paragraphStyle} context:nil].size;
        CGRect subtitleRect = CGRectMake(11.0, self.frame.size.height - 10.0 - subtitleSize.height, subtitleSize.width, subtitleSize.height);
        [self.subtitle drawWithRect:subtitleRect options:NSStringDrawingTruncatesLastVisibleLine | NSStringDrawingUsesLineFragmentOrigin attributes:@{NSFontAttributeName: [UIFont systemFontOfSize:12.0], NSParagraphStyleAttributeName: paragraphStyle, NSForegroundColorAttributeName: [UIColor colorWithHexString:@"#888888"]} context:nil];
    }
    else {
        CGSize titleSize = [self.title boundingRectWithSize:CGSizeMake(self.frame.size.width - 22, 40.0) options:NSStringDrawingTruncatesLastVisibleLine | NSStringDrawingUsesLineFragmentOrigin attributes:@{NSFontAttributeName: [UIFont systemFontOfSize:14.0], NSParagraphStyleAttributeName: paragraphStyle} context:nil].size;
        CGRect titleRect = CGRectMake(11.0, CGRectGetMaxY(imageFrame) + 11.0, titleSize.width, titleSize.height);
        [self.title drawWithRect:titleRect options:NSStringDrawingTruncatesLastVisibleLine | NSStringDrawingUsesLineFragmentOrigin attributes:@{NSFontAttributeName: [UIFont systemFontOfSize:14.0], NSParagraphStyleAttributeName: paragraphStyle, NSForegroundColorAttributeName: [UIColor blackColor]} context:nil];
    }
}

當中第 159 行是

[self.subtitle drawWithRect:subtitleRect options:NSStringDrawingTruncatesLastVisibleLine | NSStringDrawingUsesLineFragmentOrigin attributes:@{NSFontAttributeName: [UIFont systemFontOfSize:12.0], NSParagraphStyleAttributeName: paragraphStyle, NSForegroundColorAttributeName: [UIColor colorWithHexString:@"#888888"]} context:nil];

在這一行中,我們要把 subtitle 這個字串畫到畫面中,我們傳入了三個樣式設定:字體要是 12 point 大小、一個特定的段落樣式、以及將顏色設定成「#888888」,傳入三個樣式這件事情,符合前面「建立一個 Dictionary」的條件,那麼,object[2] 就會是 [UIColor colorWithHexString:@"#888888"] 傳回的結果了。

[UIColor colorWithHexString:@"#888888"] 為什麼會變成 nil?

colorWithHexString: 是 KKBOX 使用到的一個 UIColor category,可以根據傳入的色碼(Hex code)產生 UIColor 物件,平常使用都沒問題,為什麼會在用戶的環境裡頭變成 nil?

還記得我們在第一章就提到,在 Objective-C 裡頭,由於 Objective-C 的動態特性,所以每一個 method 都有可能在 run time 被換掉?由於用戶 JB 過,所以可以載入額外的 library,很有可能在用戶所載入的眾多 library 中(至於具體來說是那一個呢?鬼才曉得),也有名稱一樣叫做 colorWithHexString: 的 method,把我們原本的實作換掉了,更換之後的 colorWithHexString: 實作並不認得 @"#888888",回傳 nil,接著我們又把 nil 插入 NSDictionary。

你可能聽說過,JB 會造成系統不穩定,這就是 JB 造成系統不穩定的好例子:因為 JB 之後,額外安裝的 library 會導致軟體本身的行為改變,超過了開發人員的預料,於是不會 crash 的地方也 crash 了。

那我們可以做什麼呢?我們可以把自己這份 colorWithHexString: 換個名字,避免與別人載入的 UIColor category 同名,所以現在許多人也建議在 category 名稱前方加上自己的 prefix,我們可能要改名叫做 kk_colorWithHexString:,但,這麼做的目的是為了避免讓 App 在用戶 JB 過的環境下 crash,著實讓人產生強烈的無力感。

而這種問題在測試階段是無法發現的。我們不可能測試所有 JB 之後的各種環境的組合。

results matching ""

    No results matching ""