Category無法覆寫系統(tǒng)方法?

這是一次非常有趣的解決問題經(jīng)歷,以至于我認(rèn)為解決方式可能比問題本身更有意思,另一點(diǎn)就是人多力量大,多人討論就會獲得多種思路。
首次提出這個問題的是反向抽煙,他遇到了不能用 Category 覆寫系統(tǒng)方法的現(xiàn)象。問題拋到我這,我驗(yàn)證了這個有點(diǎn)奇怪的現(xiàn)象,并決定好好探究一下,重看了 Category 那部分源碼仍沒有找到合理解釋,于是將這個問題拋到開發(fā)群里,最后由皮拉夫大王在此給出了最為合理的解釋。之后我又順著他的思路找到了一些更有力的證據(jù)。以下是這一過程的經(jīng)歷。
問題提出
以下內(nèi)容出自反向抽煙:
背景:想為 UITextField 提供單獨(dú)的屬性 placeholderColor ,用來直接設(shè)置占位符的顏色,這個時候使用分類設(shè)置屬性,重寫 setter 和 getter,set中直接使用 KVC 的方式對屬性的顏色賦值;這個時候就有個bug,如果在其他類中使用 UITextField 這個控件的時候,先設(shè)置顏色,再設(shè)置文字,會發(fā)現(xiàn)占位符的顏色沒有發(fā)生改變。
解決思路:首先想到 UITextField 中的 Label 是使用的懶加載,當(dāng)有文字設(shè)置的時候,就會初始化這個label,這時候就考慮先設(shè)置顏色根本就沒起到作用;
解決辦法:在分類中 placeholderColor 的 setter 方法中,使用runtime的objc_setAssociatedObject先把顏色保存起來,這樣就能保證先設(shè)置的顏色不會丟掉,然后需要重寫 placeholder的setter方法,讓在設(shè)置完文字的時候,拿到先前保存的顏色,故要在placeholderColor 的getter中用objc_getAssociatedObject取,這里有個問題點(diǎn),在分類中重寫 placeholder 的setter方法的話,在外面設(shè)置 placeholder 的時候,根本不走自己重寫的這個 setPlaceholder方法,而走系統(tǒng)自帶的,這里我還沒研究。然后為了解決這個問題,我自己寫了個setDsyPlaceholder方法,在setDsyPlaceholder里面對標(biāo)簽賦值,同時添加已經(jīng)保存好的顏色,然后與setPlaceholder做交換,bug修復(fù)。
這里大家先不要關(guān)注解決 placeholderColor 的方式是否正確,以免思路走偏。我們應(yīng)該避免使用Category 覆寫系統(tǒng)方法的,但這里引出了一個問題:如果就是要覆寫系統(tǒng)的方法,為啥沒被執(zhí)行?
問題探索
我測試發(fā)現(xiàn)自定義類是可以通過 Category 覆寫的,只有系統(tǒng)方法不可以。當(dāng)時選的是 UIViewController 的viewDidLoad 方法,其他幾個 UIViewController 方法也試了都不可以。
測試代碼如下:
#import "UIViewController+Test.h"
@implementation UIViewController (Test)
- (void)viewDidLoad {
NSLog(@"viewDidLoad");
}
@end
所以猜測:系統(tǒng)方法被做了特殊處理都不能覆寫,只有自定義類可以覆寫。
有一個解釋是:系統(tǒng)方法是會被緩存的,方法查找走了緩存,沒有查完整的方法表。
這個說法好像能說得通,但是系統(tǒng)緩存是庫的層面,方法列表的緩存又是另一個維度了。方法列表的緩存應(yīng)該是應(yīng)用間獨(dú)立進(jìn)行的,這樣才能保證不同應(yīng)用對系統(tǒng)庫的修改不會相互影響,所以這個解釋站不住腳。
這時有朋友提出他們之前使用Category 覆寫過 UIScreen 的 mainScreen,是可以成功的。我試了下確實(shí)可以,觀察之后發(fā)現(xiàn)該屬性是一個類屬性。又試了其他幾個系統(tǒng)庫的類屬性,也都是可以的。
所以猜測變成了:只有系統(tǒng)實(shí)例方法不能被覆寫,類屬性,類方法可以覆寫。
這時已經(jīng)感覺奇怪了,這個規(guī)律也說不通。后來又有朋友測試通過 Xcode10.3 能夠覆寫系統(tǒng)方法,好嘛。。。
這時的猜測又變成了:蘋果在某個特定版本開始才做了系統(tǒng)方法覆寫的攔截。
感覺在離奇的路上越走越遠(yuǎn)了。
可靠的證據(jù)
皮拉夫大王在此提出了很關(guān)鍵的信息,他驗(yàn)證了iOS12系統(tǒng)可以覆寫系統(tǒng)方法(后來驗(yàn)證iOS13狀況相同),iOS14不能覆寫。
但iOS14的情況并不是所有的系統(tǒng)方法都覆蓋不了,能否覆蓋與類方法還是實(shí)例方法無關(guān)。
例如:UIResponder的分類,重寫init 和 isFirstResponder,init可以覆蓋,isFirstResponder不能覆蓋。在iOS14的系統(tǒng)上NS的類,很多都可以被分類覆蓋,但是UIKit的類,在涉及到UI的方法時,很多都無法覆蓋。
這里猜測:系統(tǒng)做了白名單,命中白名單的函數(shù)會被系統(tǒng)攔截和處理。
以下是對 iOS14 狀況的驗(yàn)證,覆寫isFirstResponder,打印method_list:
unsigned int count;
Method *list = class_copyMethodList(UIResponder.class, &count);
for (int i = 0; i < count; i++) {
Method m = list[i];
if ([NSStringFromSelector(method_getName(m)) isEqualToString:@"isFirstResponder"]) {
IMP imp = method_getImplementation(m);
}
}
isFirstResponder會命中兩次,兩次po imp的結(jié)果是:
//第一次
(libMainThreadChecker.dylib`__trampolines + 67272)
//第二次
(UIKitCore`-[UIResponder isFirstResponder])
同樣的代碼,在iOS12的設(shè)備也會命中兩次,結(jié)果為:
//第一次
(SwiftDemo`-[UIResponder(xx) isFirstResponder] at WBOCTest.m:38)
//第二次
(UIKitCore`-[UIResponder isFirstResponder])
所以可以確認(rèn)的是,分類方法是可以正常添加到系統(tǒng)類的,但在iOS14的系統(tǒng)中,覆寫的方法卻被libMainThreadChecker.dylib里的方法接管了,導(dǎo)致沒有執(zhí)行。
那么問題來了,這個libMainThreadChecker.dylib庫是干嘛的,它做了什么?
這個庫對應(yīng)了Main Thread Checker這個功能,它是在Xcode9新增的,因?yàn)殚_銷比較小,只占用1-2%的CPU,啟動時間占用時間不到0.1s,所以被默認(rèn)置為開的狀態(tài)。它在調(diào)試期的作用是幫助我們定位那些應(yīng)該在主線程執(zhí)行,卻沒有放到主線程的代碼執(zhí)行情況。

另外官方文檔還有一個解釋[1]:
The Main Thread Checker tool dynamically replaces system methods that must execute on the main thread with variants that check the current thread. The tool replaces only system APIs with well-known thread requirements, and doesn’t replace all system APIs. Because the replacements occur in system frameworks, Main Thread Checker doesn’t require you to recompile your app.
這個家伙會動態(tài)的替換嘗試重寫需要在主線程執(zhí)行的系統(tǒng)方法,但也不是所有的系統(tǒng)方法。
終于找到了!這很好的解釋了為什么本應(yīng)被覆蓋的系統(tǒng)方法卻指向了libMainTreadChecker.dylib這個庫,同時也解釋了為什么有些方法可以覆寫,有些卻不可以。
測試發(fā)現(xiàn)當(dāng)我們關(guān)閉了這個開關(guān),iOS14的設(shè)備就可以正常執(zhí)行覆寫的方法了。
到此基本完事了,但還留有一個小疑問,那就是為什么iOS14之前的設(shè)備,不受這個開關(guān)的影響?目前沒有找到實(shí)質(zhì)的證據(jù)表明蘋果是如何處理的,但可以肯定的是跟 Main Thread Checker 這個功能有關(guān)。
總結(jié)
稍微抽象下一開始處理問題的方式:遇到問題 -> 猜想 -> 佐證 -> 推翻猜想 -> 重新猜想 -> 再佐證。
這其實(shí)是錯誤的流程,猜想和佐證可以,但他們一般只會成為一個驗(yàn)證的樣例,而不能帶給我們答案。所以正確的處理方式是,不要把太多時間浪費(fèi)在猜想和佐證猜想上,而應(yīng)該去深挖問題本身。新的解題思路可以是這樣的:遇到問題 -> 猜想 -> 深挖 -> 根據(jù)挖到的點(diǎn)佐證結(jié)果。
參考資料
Diagnosing Memory, Thread, and Crash Issues Early: https://developer.apple.com/documentation/xcode/diagnosing_memory_thread_and_crash_issues_early?language=objc
