本文授權轉載,作者:Sindri的小巢(簡書)
從異常說起
我們都知道,在iOS中存在這麼一個通用類類型id,它可以用來表示任何對象的類型 —— 這意味著我們使用id類型的對象調用任何一個方法,編譯器都不會進行報錯。比如下面這段代碼:
id wrongArr = @"This is a NSString instance."; [wrongArr addObject: @"The operate will crash your application"];
不出意外的,編譯器會給你這麼一個信息然後華麗麗的崩潰了。相信幾乎所有的開發者們在開發生涯中都遇到過這種崩潰信息:
-[__NSCFConstantString addObject:]: unrecognized selector sent to instance 0x10675c060
很簡單,我們朝著一個地址為0x10675c060的實例對象發送了不屬於這個對象的方法。這句話不是instance 0x10675c060 called unrecognized selector,而是消息發送錯誤。實際上,我們每一次對OC對象的方法調用都是一次消息的發送
消息發送異常
關於靜態語言和動態語言
這裡要先介紹計算機的開發語言的一個專業名詞:動態語言和靜態語言。確切的說,OC是一門動態語言。動態語言和靜態語言兩者的區別如下:
靜態語言: 靜態語言在運行前會進行類型判斷,類的所有成員、方法都會在編譯階段確定好內存地址。類成員只能訪問屬於自己的方法和變量,像上面的調用代碼無法通過編譯,會直接引起編譯器報錯。但因為如此,靜態語言結構規范、便於調試、且可以進行多樣的性能優化。常見的靜態語言包括java/C++/C等
動態語言:大部分的判斷工作被推遲到運行時進行,類的成員變量、方法地址都在運行時確認。可以在運行時動態的添加類成員、方法等。具有較高的靈活性和可定制性、便於閱讀,但方法通常無法進行內聯等優化
兩種語言孰優孰略本人不在這裡做判斷,但是要知道的是smalltalk是動態語言的鼻祖,更是OC發展的最大推動力。在smalltalk中,所有的東西都是對象(或者都應該被當做對象),例如表達式2 + 3被理解成向對象2發送了消息+,其中接收的參數是 3
消息發送
在前篇runtime-屬性與變量中我們導入過runtime的頭文件實現了一鍵歸檔功能,今天我們要導入另外一個文件:
在OC中,調用一個方法的格式如下:
[davin playWith: friend];
在方法調用的時候,runtime會將上面的方法調用轉換成一個C語言的函數調用,表示朝著davin發送了一個playWith:消息,並傳入了friend這個參數:
objc_msgSend(davin, @selector(playWith:), friend);
那麼在這個C語言函數中發生了什麼事情?編譯器是如何找到這個類的方法的呢?蘋果開源了runtime的實現代碼,其中為了高度優化性能,蘋果使用匯編實現了這個函數(源碼處於Source/objc-msg-arm.s文件下):
/***************************************************************** * * id objc_msgSend(id self, SEL _cmd,...); * *****************************************************************/ ENTRY objc_msgSend MESSENGER_START cbz r0, LNilReceiver_f // 判斷消息接收者是否為nil ldr r9, [r0] // r9 = self->isa CacheLookup NORMAL // 到緩存中查找方法 LCacheMiss: // 方法未緩存 MESSENGER_END_SLOW ldr r9, [r0, #ISA] b __objc_msgSend_uncached LNilReceiver: // 消息接收者為nil處理 mov r1, #0 mov r2, #0 mov r3, #0 FP_RETURN_ZERO MESSENGER_END_NIL bx lr LMsgSendExit: END_ENTRY objc_msgSend
即使不懂匯編,上面的代碼通過注釋後也足以讓各位一窺究竟。從上述代碼中我們可以看到一個方法調用過程中發生的事情,包括:
判斷接收者是否為nil,如果為nil,清空寄存器,消息發送返回nil
到類緩存中查找方法,如果存在直接返回方法
沒有找到緩存,到類的方法列表中依次尋找
查找方法實現是通過_class_lookupMethodAndLoadCache3這個奇怪的函數完成的:
IMP _class_lookupMethodAndLoadCache3(id obj, SEL sel, Class cls) { return lookUpImpOrForward(cls, sel, obj, YES/*initialize*/, NO/*cache*/, YES/*resolver*/); } IMP lookUpImpOrForward(Class cls, SEL sel, id inst, bool initialize, bool cache, bool resolver) { Class curClass; IMP methodPC = nil; Method meth; bool triedResolver = NO; methodListLock.assertUnlocked(); // 如果傳入的cache為YES,到類緩存中查找方法緩存 if (cache) { methodPC = _cache_getImp(cls, sel); if (methodPC) return methodPC; } // 判斷類是否已經被釋放 if (cls == _class_getFreedObjectClass()) return (IMP) _freedHandler; // 如果類未初始化,對其進行初始化。如果這個消息是initialize,那麼直接進行類的初始化 if (initialize && !cls->isInitialized()) { _class_initialize (_class_getNonMetaClass(cls, inst)); } retry: methodListLock.lock(); // 忽略在GC環境下的部分消息,比如retain、release等 if (ignoreSelector(sel)) { methodPC = _cache_addIgnoredEntry(cls, sel); goto done; } // 遍歷緩存方法,如果找到,直接返回 methodPC = _cache_getImp(cls, sel); if (methodPC) goto done; // 遍歷類自身的方法列表查找方法實現 meth = _class_getMethodNoSuper_nolock(cls, sel); if (meth) { log_and_fill_cache(cls, cls, meth, sel); methodPC = method_getImplementation(meth); goto done; } // 嘗試向上遍歷父類的方法列表查找實現 curClass = cls; while ((curClass = curClass->superclass)) { // Superclass cache. meth = _cache_getMethod(curClass, sel, _objc_msgForward_impcache); if (meth) { if (meth != (Method)1) { log_and_fill_cache(cls, curClass, meth, sel); methodPC = method_getImplementation(meth); goto done; } else { // Found a forward:: entry in a superclass. // Stop searching, but don't cache yet; call method // resolver for this class first. break; } } // 查找父類的方法列表 meth = _class_getMethodNoSuper_nolock(curClass, sel); if (meth) { log_and_fill_cache(cls, curClass, meth, sel); methodPC = method_getImplementation(meth); goto done; } } // 沒有找到任何的方法實現,進入消息轉發第一階段“動態方法解析” // 調用+ (BOOL)resolveInstanceMethod: (SEL)selector // 征詢接收者所屬的類是否能夠動態的添加這個未實現的方法來解決問題 if (resolver && !triedResolver) { methodListLock.unlock(); _class_resolveMethod(cls, sel, inst); triedResolver = YES; goto retry; } // 仍然沒有找到方法實現進入消息轉發第二階段“備援接收者” // 先後會調用 -(id)forwardingTargetForSelector: (SEL)selector // 以及 - (void)forwardInvocation: (NSInvocation*)invocation 進行最後的補救 // 如果補救未成功拋出消息發送錯誤異常 _cache_addForwardEntry(cls, sel); methodPC = _objc_msgForward_impcache; done: methodListLock.unlock(); assert(!(ignoreSelector(sel) && methodPC != (IMP)&_objc_ignored_method)); return methodPC; }
上面就是一個方法調用的全部過程。主要分為三個部分:
查找是否存在對應的方法緩存,如果存在直接返回調用
為了優化性能,方法的緩存使用了散列表的方式,在下一部分會進行比較詳細的講述
未找到緩存,到類本身或順著類結構向上查找方法實現,返回的method_t *類型也被命名為Method
//非加鎖狀態下查找方法實現 static method_t * getMethodNoSuper_nolock(Class cls, SEL sel) { runtimeLock.assertLocked(); assert(cls->isRealized()); // fixme nil cls? // fixme nil sel? for (auto mlists = cls->data()->methods.beginLists(), end = cls->data()->methods.endLists(); mlists != end; ++mlists) { method_t *m = search_method_list(*mlists, sel); if (m) return m; } return nil; } // 搜索方法列表 static method_t * search_method_list(const method_list_t *mlist, SEL sel) { int methodListIsFixedUp = mlist->isFixedUp(); int methodListHasExpectedSize = mlist->entsize() == sizeof(method_t); if (__builtin_expect(methodListIsFixedUp && methodListHasExpectedSize, 1)) { // 對有序數組進行線性探測 return findMethodInSortedMethodList(sel, mlist); } else { // Linear search of unsorted method list for (auto& meth : *mlist) { if (meth.name == sel) return &meth; } } #if DEBUG // sanity-check negative results if (mlist->isFixedUp()) { for (auto& meth : *mlist) { if (meth.name == sel) { _objc_fatal("linear search worked when binary search did not"); } } } #endif return nil; }
如果在這個步驟中找到了方法的實現,那麼將它加入到方法緩存中以便下次調用能快速找到:
// 記錄並且緩存方法 static void log_and_fill_cache(Class cls, IMP imp, SEL sel, id receiver, Class implementer) { #if SUPPORT_MESSAGE_LOGGING if (objcMsgLogEnabled) { bool cacheIt = logMessageSend(implementer->isMetaClass(), cls->nameForLogging(), implementer->nameForLogging(), sel); if (!cacheIt) return; } #endif cache_fill (cls, sel, imp, receiver); } //在無加鎖狀態下緩存方法 static void cache_fill_nolock(Class cls, SEL sel, IMP imp, id receiver) { cacheUpdateLock.assertLocked(); if (!cls->isInitialized()) return; if (cache_getImp(cls, sel)) return; cache_t *cache = getCache(cls); cache_key_t key = getKey(sel); // 如果緩存占用不到3/4,進行緩存。 mask_t newOccupied = cache->occupied() + 1; mask_t capacity = cache->capacity(); if (cache->isConstantEmptyCache()) { cache->reallocate(capacity, capacity ?: INIT_CACHE_SIZE); } else if (newOccupied expand(); } bucket_t *bucket = cache->find(key, receiver); if (bucket->key() == 0) cache->incrementOccupied(); bucket->set(key, imp); }
如果在類自身中沒有找到方法實現,那麼循環獲取父類,重復上面的查找動作,找到後再將方法緩存到本類而非父類的緩存中
未找到任何方法實現,觸發消息轉發機制進行最後補救
其中消息轉發分為兩個階段,第一個階段我們可以通過動態添加方法之後讓編譯器再次執行查找方法實現的過程;第二個階段稱作備援的接收者,就是找到一個接盤俠來處理這個事件
void _class_resolveMethod(Class cls, SEL sel, id inst) { // 非beta類的情況下直接調用 resolveInstanceMethod 方法 if (! cls->isMetaClass()) { _class_resolveInstanceMethod(cls, sel, inst); } else { // 先調用 resolveClassMethod 請求動態添加方法 // 然後進行一次查找判斷是否處理完成 // 如果沒有添加,再調用 resolveInstanceMethod 方法 _class_resolveClassMethod(cls, sel, inst); if (!lookUpImpOrNil(cls, sel, inst, NO/*initialize*/, YES/*cache*/, NO/*resolver*/)) { _class_resolveInstanceMethod(cls, sel, inst); } } }
方法緩存
在上一篇runtime文章中筆者已經說過對於OC的每一個對象來說,本質上都是一個objc_class的結構體封裝,在最新的runtime源碼的objc-runtime-new.h中,objc_class的結構如下(筆者已經略去了大部分的函數):
struct objc_class : objc_object { Class superclass; // Class ISA; cache_t cache; // formerly cache pointer and vtable class_data_bits_t bits; // class_rw_t * plus custom rr/alloc flags class_rw_t *data() { return bits.data(); } void setData(class_rw_t *newData) { bits.setData(newData); } // ......... }
結構一目了然,很明顯cache存儲著我們在方法調用中需要查找的方法緩存。作為緩存方法的cache采用了散列表,以此來大幅度提高檢索的速度:
#define CACHE_HASH(sel, mask) (((uintptr_t)(sel)>>2) & (mask)) struct cache_t { struct bucket_t *_buckets; mask_t _mask; mask_t _occupied; // functions } // cache method buckets = (cache_entry **)cache->buckets; for (index = CACHE_HASH(sel, cache->mask); buckets[index] != NULL; index = (index+1) & cache->mask) { } buckets[index] = entry;
在每次調用完未被緩存的方法時,下面的那段緩存方法的代碼就會調用。蘋果利用了sel的指針地址和mask做了一個簡單的位運算,然後找到一個空槽存儲起來。 以此我們可以推出從緩存中查找sel實現的代碼CacheLookup,但是為了高度優化性能,蘋果同樣喪心病狂的使用匯編完成了查找的步驟,官方給出的注釋足夠我們大致看明白這段代碼:
.macro CacheLookup ldrh r12, [r9, #CACHE_MASK] // r12 = mask ldr r9, [r9, #CACHE] // r9 = buckets .if $0 == STRET || $0 == SUPER_STRET and r12, r12, r2 // r12 = index = SEL & mask .else and r12, r12, r1 // r12 = index = SEL & mask .endif add r9, r9, r12, LSL #3 // r9 = bucket = buckets+index*8 ldr r12, [r9] // r12 = bucket->sel 2: .if $0 == STRET || $0 == SUPER_STRET teq r12, r2 .else teq r12, r1 .endif bne 1f CacheHit $0 1: cmp r12, #1 blo LCacheMiss_f // if (bucket->sel == 0) cache miss it eq // if (bucket->sel == 1) cache wrap ldreq r9, [r9, #4] // bucket->imp is before first bucket ldr r12, [r9, #8]! // r12 = (++bucket)->sel b 2b .endmacro
具體的源碼可以從蘋果開源這裡下載,這個方法蘋果已經注釋的足夠清晰了。
上面所有的操作都是對方法的緩存、查找操作,那麼方法究竟是什麼?在OC中方法被抽象成的數據類型是Method,如果了解並且使用過runtime的讀者們可能了解這個類型,其結構如下:
struct old_method { SEL method_name; char *method_types; IMP method_imp; }; typedef struct method_t *Method;
method_imp方法的實現代碼,你可以把它看做一個block。事實上,後者確實可以轉換成一個IMP類型來實現某些黑魔法。
method_types方法的參數編碼,什麼意思?在屬性與變量中我說過每一種數據類型有著自己對應的字符編碼,這個表示方法返回值、參數的字符編碼,比如-(void)playWith:(id)的字符編碼為v@:@
method_name顧名思義,方法的名字。通常我們使用@selector()的方式獲取一個方法的sel地址,這個被用來進行散列計算存儲方法的imp實現。由於SEL類型采用了散列的算法,因此如果同一個類中存在同樣名字的方法,那麼就會導致方法的imp地址無法唯一化。這也是蘋果不允許同名不同參數類型的方法存在的原因
消息轉發
通常情況下,在我們調用不屬於某個對象的方法的時候,我們的應用就會崩潰crash,比如筆者經歷過好幾次因為後台返回的NSNull類型導致了測試反饋應用閃退。通過上面的方法調用源碼我們可以看到並不是沒有找到方法實現就直接發生了崩潰,在崩潰之前編譯器會進行消息轉發機制,總共給了我們三次機會來避免這樣的崩潰並盡可能的找到方法的響應者。
消息轉發階段
首先先看第一階段。我們都知道,在iOS開發當中我們需要非常的注意用戶體驗。單純的是因為數據類型錯誤而導致應用出現閃退,這樣的處理會極大的影響使用app的用戶。因此,我們可以通過class_addMethod這個函數來動態的添加這種錯誤的處理(類可以在objc_registerClassPair完成類的注冊之後動態的添加方法,但不允許動態添加屬性,參考category機制)
id wrongTypeGetter(id object, SEL sel) { return nil; } void wrongTypeSetter(id object, SEL sel, id value) { // do nothing } + (BOOL)resolveInstanceMethod: (SEL)selector { NSString * selName = NSStringFromSelector(selector); if ([sel hasPrefix: @"set"]) { class_addMethod(self, selector, (IMP)wrongTypeSetter, "v@:@"); } else { class_addMethod(self, selector, (IMP)wrongTypeGetter, "@@:") } }
在第二階段最開始的時候,這時候已經默許了你並不想使用消息接收者來響應這個方法,所以我們需要找到消息接盤俠 —— 這並不是一件壞事。在iOS中不支持多繼承,盡管我們可以通過協議和組合模式實現偽多繼承。偽多繼承和多繼承的區別在於:多繼承是將多個類的功能組合到一個對象當中,而偽多繼承多個類的功能依舊分布在不同對象當中,但是對象彼此對消息發送者透明。那麼,如果我們消息轉發給另一個對象可以用來實現這種偽多繼承。
@interface Person: NSObject @property (nonatomic, strong) NSNumber * age; @end @implementation Person - (id)forwardingTargetForSelector: (SEL)aSelector { // 甚至可以通過runtime遍歷自己屬性找到可以響應方法的接盤俠 NSString * selName = NSStringFromSelector(aSelector); if ([selName hasSuffix: @"Value"]) { return self.age; } return nil; } @end // View controller id p = [[Person alloc] init]; [p setAge: @(18)]; NSLog(@"%lu, %.2f", [p integerValue], [p doubleValue]); //18, 18.00
如果你依舊沒有為這個方法找到另外一個調用者,那麼阻止你app閃退的最後時刻到來了。runtime需要生成一個methodSignature變量來組裝,這將通過調用消息接收者的-(NSMethodSignature *)methodSignatureForSelector:獲取,這個變量包含了方法的參數類型、參數個數以及消息接收者等信息。接著把這個變量組裝成一個NSInvocation對象進行最後一次的消息轉發,調用接收者的-forwardInvocation:來進行最後的挽救機會。這意味著我們可以盡情的對invocation做任何事情,包括隨意修改參數值、消息接收者等。我最常拿來干的事情就是減少數組的遍歷工作:
@implementation NSArray(LXDRuntime) - (void)forwardInvocation: (NSInvocation *)anInvocation { for (id item in self) { if ([item respondsToSelector: anInvocation.selector]) { [anInvocation invokeWithTarget: item]; } } } @end
總的來說整個消息發送的過程可以歸納成下面這張圖:
消息發送全過程
雖然消息轉發可以幫助我們顯著的減少app的閃退率,但是在開發階段千萬不要加入這些特性。最好是在app申請上架的那個階段再加,這樣不至於app其他消息發送異常被我們忽略了。
消息機制黑魔法
上面筆者講解了關於一個調用方法之中發生的事情,確實非常的復雜。同樣的這些特性也非常值得我們去學習使用,runtime提供了一系列關於Method的方法給我們實現面向切面編程的工作。這些工作包括了替換原有方法實現,交換方法實現等等工作。
假設現在我需要一個圓角按鈕,並且保證點擊觸發事件的范圍必須要這個圓之內,那麼通過一個UIButton+LXDRuntime的擴展來替換舊有-pointInside:withEvent:方法
@interface UIButton(LXDRuntime) @property (nonatomic, assign) BOOL roundTouchEnable; @end const void * LXDRoundTouchEnableKey = & LXDRoundTouchEnableKey; @implementation UIButton(LXDRuntime) - (BOOL)roundTouchEnable { return [objc_getAssociatedObject(self, LXDRoundTouchEnableKey) boolValue]; } - (void)setRoundTouchEnable: (BOOL)roundTouchEnable { objc_setAssociatedObject(self, LXDRoundTouchEnableKey, @(roundTouchEnable), OBJC_ASSOCIATION_RETAIN_NONATOMIC); } - (BOOL)replacePointInside: (CGPoint)point withEvent: (UIEvent *)event { if (CGRectGetWidth(self.frame) != CGRectGetHeight(self.frame) || !self. roundTouchEnable) { return [super pointInside: point withEvent: event]; } CGFloat radius = CGRectGetWidth(self.frame) / 2; CGPoint offset = CGPointMake(point.x - radius, point.y - radius); return sqrt(offset.x * offset.x + offset.y * offset.y) <= radius; } // 替換方法實現 + (void)initialize { [super initialize]; Method replaceMethod = class_getInstanceMethod([self class], @selector(replacePointInside:withEvent:)); Method originMethod = class_getInstanceMethod([self class], @selector(pointInside:withEvent:)); method_setImplementation(originMethod, method_getImplementation(replaceMethod)); } @end
那麼當我需要我的按鈕只響應圓形點擊區域的時候,只需要設置button.roundTouchEnable = YES,就會自動實現了圓形點擊的判斷。除了上面的上面的方法替換,還有另一個常用的黑魔法是交換兩個方法的實現。歸功於Method的特殊結構,將方法名字sel跟代碼實現imp分隔開來。你可以把imp當做是一個block代碼塊,而交換實現的操作就相當於把這兩個block交換了。
@interface Person : NSObject @property (nonatomic, strong) NSString * name; @property (nonatomic, strong) NSNumber * age; @end @implementation Person + (void)initialize { [super initialize]; Method ageGetter = class_getInstanceMethod([self class], @selector(age)); Method nameGetter = class_getInstanceMethod([self class], @selector(name)); method_exchangeImplementations(ageGetter, nameGetter); } // View controller Person * p = [[Person alloc] init]; p.age = @(56); p.name = @"Job Steve"; NSLog(@"%@ is %@ year old", p.name, p.age); // LXDRuntimeDemo[7316:244912] 56 is Job Steve year old @end
上面的代碼交換了name和age的實現,用圖示來表示:
方法交換
應該不難看出,method_exchangeImplementations之所以被推崇的原因在於這種方式交換實現的時候不會導致原有的方法實現發生改變(從頭到尾,age的IMP跟name的IMP都沒有進行任何的修改),當然了,它的缺點也是非常明顯的:
多人開發對同一個方法都進行方法替換/交換時,會使得業務邏輯復雜,非常的不利於調試
被交換的方法實現會直接的影響到所有該類的實例對象以及子類,不適用於單個對象的實現
可以說runtime提供的這些黑魔法都是雙刃劍,合理的運用能讓我們更加的強大。另外,除了Method的黑魔法,還要提到一個IMP相關的使用陷阱。上文說過,IMP跟block是非常相似的東西,前者可以跟函數指針強制轉換,因此可以看做是一個特殊的block,同樣的系統提供了兩者相互轉換的方法:imp_implementationWithBlock和imp_getBlock。按照上面說的,當調用方法轉換成消息轉發的時候,objc_msgSend自身已經存在了兩個參數id object以及SEL aSelector,那麼按照這種思路IMP和block的切換應該是這樣的:
+ (void)initialize void (^requestBlock)(id object, SEL aSelector, id URL, id parameters) = ^(id object, SEL aSelector, id URL, id parameters) { // do some networking request }; IMP requestIMP = imp_implementationWithBlock(requestBlock); class_addMethod([self class], @selector(networkReuqest:parameters:), requestIMP, "v@:@@"); } // View controller [self performSelector: @selector(networkReuqest:parameters:) withObject: URL withObject: parameters];
上面這段代碼會crash的非常無厘頭,提示你EXC_BAD_ACCESS錯誤。重要的事情說三遍:
block參數不存在SEL!!
block參數不存在SEL!!
block參數不存在SEL!!
上面的代碼只要去掉了SEL aSelector這個參數,這段代碼就能正常執行。
尾話
runtime對於每一個iOS開發者來說,都應該去了解。通過runtime的源碼實現,我們可以看到蘋果為了性能優化武裝到牙齒的行為,也能看到我們書寫代碼深層之中不為人知的實現。作為runtime系列第二篇(第一篇),糾結了我很久才開始動工,期間看源碼看的頭都大了,但是確實對我在開發的認識以及代碼的結構上有了更多的了解。最後,本文無代碼,還是奉上蘋果的runtime源碼地址。