本文是投稿文章,作者:唯敬
這其實是一個NSInvocation練習作業
GitHub源碼 vk_msgSend
引子
工作中難免會遇到一些場景,開發的時候不想引入整個頭文件,但是又想調用一些方法
動態創建,動態調用看起來比較酷
這種使用場景確實不常見,導入了頭文件最省事,最直接,但是這種方式我覺得能搞出很多好玩的東西
一個群裡聊天的時候聊到了一個場景,tableView內的cell有N種樣式,在cellForRow的時候,通過NSClassFromString從字符串創建對象,然後挨個對Cell的UI賦值,接下來問題就來了。
實在不想import如此繁多cell.h頭文件應該怎麼辦?
有一個辦法,所有cell都有個基類,基類統一所有UI賦值的接口,子類重載這些UI賦值,這樣創建出來的對象強轉成基類,調用基類的接口。這樣只需要import一個基類頭文件就夠了
1.這樣要求子類的接口必須和基類完全一致
2.如果子類設計很多樣,賦值UI的元素更多,就會不太合理
還有一個辦法performSelector,恩說實話,我覺得很不好用
會有人說用運行時Objc_msgSend,恩,這個靠譜,聽起來也挺易用的
老老實實引入各種頭文件,別搞什麼動態創建,動態調用的花樣了
聊聊performSelector
這裡不是說performSelector中關於異步調用的那一部分,而是單說同步的:
- (id)performSelector:(SEL)aSelector; - (id)performSelector:(SEL)aSelector withObject:(id)object; - (id)performSelector:(SEL)aSelector withObject:(id)object1 withObject:(id)object2;
這個是NSObject系統開放的performSelector同步接口,這個好用麼?我以前覺得很不好用
參數類型:我湊,不需要參數的接口用起來最直觀,我也覺得還算好用,一旦需要參數,withObject:id是什麼鬼?我傳BOOL,傳NSInteger怎麼傳啊?我包裝成NSNumber對面能認識麼?
參數個數:為毛只能不帶參數,1個參數,2個參數呢?我想調用的東西含參數特別多咋辦啊?
調用寫法:每個參數還得用withObject來傳,寫出來一點都不酷
就像我說的,以前我幾乎只會去用performSelector調用無參數的函數,一旦有參數,我都不愛用performSelector
聊聊objc_msgSend
大家都知道OC的消息機制,函數調用其實都是發送消息,這個太多的地方有講了,我就不多說了。
一個我們想要調用的函數
- (int) doSomething:(int) x { ... }
在32位的時代,想要實現我要的效果,可以直接使用objc_msgSend
objc_msgSend(self,@selector(doSomething:), 0);
但是一旦在64位設備上執行,就會產生崩潰,原因參見蘋果Converting Your App to a 64-Bit Binary,中Take Care with Functions and Function Pointers,這一部分。
簡單的說,64位下runtime調用和32位變化十分大,尤其是讀取函數參數列表,進行傳參這部分,所以蘋果列出了一句話
Always Define Function Prototypes
Function Pointers Must Use the Correct Prototype
直接的調用C函數指針的時候必須先進行嚴格的類型匹配強轉,不能直接使用Imp這個通用型的指針。
而objc_msgSend的內部實現也是一個這樣的過程,objc_msgSend學習
先從runtime method cache裡面查找selector,
找不到再從 method list裡查找,
找到selector,獲取具體實現的ImpC函數,
調用Imp
所以在64位下,直接使用objc_msgSend一樣會引起崩潰,必須進行一次強轉
((void(*)(id, SEL,int))objc_msgSend)(self, @selector(doSomething:), 0);
所以以前32位的時候objc_msgSend是我們最方便的做法,現在64位了,他已經不是那麼方便了,畢竟使用起來還需要人自行手寫這部分強轉工作
本著程序員偷懶大法,這部分能不能也省略了?變得更方便一些?
設計我的callSelector的接口
我希望我設計的接口是這樣的
Class cls = NSClassFromString(@"testClassA"); idabc = [[cls alloc]init]; NSError *err; NSString *return1 = [abc vk_callSelector:@selector(testfunction:withB:) error:&err,4,3.5f];
它是一個NSObject的Category,只要你對強轉成遵從
它像performSelector一樣輸入SEL做參數執行,但是傳參非常容易,基礎類型,struct都支持,不需要withObject,不需要轉成id,只需要像NSLog()一樣,按順序輸入可變參數就好。
有一個error指針可以用來返回錯誤信息,也可以填nil不傳
它支持類方法
SEL參數還可以改傳字符串
所以他的定義是這樣的
+ (id)vk_callSelector:(SEL)selector error:(NSError *__autoreleasing *)error,...; + (id)vk_callSelectorName:(NSString *)selName error:(NSError *__autoreleasing *)error,...; - (id)vk_callSelector:(SEL)selector error:(NSError *__autoreleasing *)error,...; - (id)vk_callSelectorName:(NSString *)selName error:(NSError *__autoreleasing *)error,...;
實現這樣的callSelector
可變參數接口透傳的問題
既然接口設計的希望使用者怎麼簡單怎麼來,使用者用可變參數的方式一字羅列所有參數,無需轉id之類的。那我們也得按照可變參數去處理。
這裡我遇到了一個問題,我一共設計4個接口,這4個接口其實大同小異,核心邏輯是一樣的,所以我肯定是用一個公共的方法進行處理,但是,可變參函數怎麼透傳呢?
- (id)vk_callSelectorName:(NSString*)selName error:(NSError*__autoreleasing*)error,...{ SEL selector = NSSelectorFromString(selName); [self vk_callSelector:selector error:error,...]; }
我希望這樣就能搞定,把...原封不動的塞到下面那個函數,可是xcode不認吶親╮(╯_╰)╭
後來公司討論組裡有位大神給出了建議,直接把va_list當做公共函數的參數,進行透傳
設計公共方法的接口聲明為,第一個參數就是va_list
static NSArray *vk_targetBoxingArguments(va_list argList, Class cls, SEL selector, NSError *__autoreleasing *error)
然後在調用的時候
va_list argList; va_start(argList, error); SEL selector = NSSelectorFromString(selName); NSArray *boxingAruments = vk_targetBoxingArguments(argList, [self class], selector, error); va_end(argList);
用va_start獲取va_list然後就可以一層層的透傳給公共方法進行處理了。
參數包裝
雖然輸入接口可以支持任意的類型,基礎類型,struct,id,但是我內部實現的時候,還是把它們統一轉換成了id,方便後續傳遞處理,這個步驟就是包裝一下所有傳進來的參數,也就是上面提到的vk_targetBoxingArguments
這個包裝的過程涉及到va_list的取值過程va_arg了,這裡我也踩了個大坑。容我細細道來
從va_list裡面一個一個的取出參數需要明確知道,每一個參數的類型,但是我們想做的是一個通用型的方法,這塊就不能寫死,可是從哪知道參數類型呢? -- NSMethodSignature
NSMethodSignature我理解他其實就是SEL的typeEncode的對象封裝,分別記錄了這個SEL的返回值類型和各個參數類型
我們有調用對象,就能獲取到對象的Class,我們有SEL,就能獲取到NSMethodSignature
methodSignature = [cls instanceMethodSignatureForSelector:selector];
有了NSMethodSignature我們就能按著循環去獲取每個參數類型,從而讀取va_list了。
for (int i = 2; i < [methodSignature numberOfArguments]; i++) { const char *argumentType = [methodSignature getArgumentTypeAtIndex:i]; switch (argumentType[0] == 'r' ? argumentType[1] : argumentType[0]) { //抽取參數 }
NSMethodSignature中前兩個分別代表返回值和reciever,我們在抽取參數,所以直接從[2]下標開始取值,剩下的就是一個根據typeEcode,從va_list取值,然後包裝成id,塞入數組的過程了,具體到每一種類型的case,可以參見源碼。
1)取基礎類型int,va_arg(argList, int)取值,包裝成NSNumber(只舉一個例子,其他見源碼)
int value = va_arg(argList, int); [argumentsBoxingArray addObject:@(value)]; break;
2)取CGSize,va_arg(argList, CGSize)取值,包裝成NSValue(只舉一個例子,其他見源碼)
CGSize val = va_arg(argList, CGSize); NSValue* value = [NSValue valueWithCGSize:val]; [argumentsBoxingArray addObject:value]; break;
3)取id,va_arg(argList, id),不包裝,直接塞進去啦
這裡要注意,如果傳入的參數為nil,需要特殊處理一下,nil無法放入數組,所以我創建了一個vk_nilObject對象,來表明這個位置傳進來nil了
id value = va_arg(argList, id); if (value) { [argumentsBoxingArray addObject:value]; }else{ [argumentsBoxingArray addObject:[vk_nilObject new]]; }
4)取SEL,va_arg(argList,SEL),處理成string
因為SEL本身的意義就是一個函數的名字類似string一樣的鍵值,是用來查找函數用的,所以當成字符串處理啦
SEL value = va_arg(argList, SEL); NSString *selValueName = NSStringFromSelector(value); [argumentsBoxingArray addObject:selValueName];
5)取block,其實block就是id,所以和id的處理一模一樣
//同id
6)取id*,va_arg(argList, void**)
這裡需要注意一下,因為我取出來的是一個pointer,是不能直接放入數組裡的,所以我創建了一個vk_pointer對象,持有一個void*屬性,然後就可以塞進數組了
void *value = va_arg(argList, void**); vk_pointer *pointerObj = [[vk_pointer alloc]init]; pointerObj.pointer = value; [argumentsBoxingArray addObject:pointerObj];
遇到了一個va_arg()的坑
我在調試中,發現當我對typeEncode的f取參數的時候
va_arg(argList, float)
xcode報了個warning
/Users/Awhisper/Desktop/GitHub/vk_msgSend/vk_msgSend/NSObject+vk_msgSend.m:280:49: Second argument to 'va_arg' is of promotable type 'float'; this va_arg has undefined behavior because arguments will be promoted to 'double'
一開始我看到warning沒管,就繼續編碼去了,結果運行的時候,參數裡含有float,發現了大問題
正如warning所說,此處編譯器是按著double實現的,但是我用va_arg()取的時候按著float取,就直接導致我取出來的float值不對,是0,(一個比較小的double值取了前面幾位自然都是0)
而float後面那個參數,id用va_arg(argList, id)取的時候直接崩潰,(指針已經亂了,從double的中間開始,按著id的長度取id,直接崩潰)
老老實實的修掉warning,改成用va_arg(argList, double)處理f,一切正常。
實現調用:NSInvocation
我們現在已經拿到了包裝好的參數數組NSArray,可以開始調用函數了,使用NSInvocation
1.首先先要生成NSInvocation
Class cls = [target class]; NSMethodSignature *methodSignature = vk_getMethodSignature(cls, selector); NSInvocation *invocation = [NSInvocation invocationWithMethodSignature:methodSignature];
2.設置target和SEL
[invocation setTarget:target]; [invocation setSelector:selector];
3.循環壓入參數
具體過程和Boxing一樣,遍歷methodSignature,按著typeEncode來從數組中取出id類型的參數,還原參數,壓入invocation。
遍歷的時候肯定是根據每個參數的typeEncode,去switch處理不同類型
for (int i = 2; i< [methodSignature numberOfArguments]; i++) { const char *argumentType = [methodSignature getArgumentTypeAtIndex:i]; id valObj = argsArr[i-2]; switch (argumentType[0]=='r'?argumentType[1]:argumentType[0]) { //switch case } }
這裡我會詳細分類別舉例如何壓入各種不同類型的參數,從[2]下表開始的原因和前邊一致
[invocation setArgument:&value atIndex:i];`的作用就是壓入參數
1)int等基礎類型參數,對應上文的參數包裝(只舉一個例子,其他見源碼)
int value = [valObj intValue]; [invocation setArgument:&value atIndex:i]; break;
2)CGSize基礎結構體參數,對應上文參數包裝(只舉一個例子,其他見源碼)
CGSize value = [val CGSizeValue]; [invocation setArgument:&value atIndex:i];
3)id參數,對應上文參數包裝
上文提到如果傳入的id為nil,被上文包裝成了vk_nilObject對象扔進數組的,所以這裡要針對這個處理一下
不是vk_nilObject的照常處理
是vk_nilObject,證明這個位置的參數傳入方為空,所以我准備了一個空指針
static vk_nilObject *vknilPointer = nil;
把這個空指針傳進去
if ([valObj isKindOfClass:[vk_nilObject class]]) { [invocation setArgument:&vknilPointer atIndex:i]; }else{ [invocation setArgument:&valObj atIndex:i]; }
4)SEL參數,對應上文包裝
上文提到,SEL被直接轉成了string,所以我們這裡要還原成SEL,然後直接壓入參數
NSString *selName = valObj; SEL selValue = NSSelectorFromString(selName); [invocation setArgument:&selValue atIndex:i];
5)block參數,對應上文包裝
上文提到block和id是一回事
//同id
6)id*的處理,對應上文包裝,這裡極其惡心,我會專門寫一篇詳細說一下,這裡只寫個大概吧
上文已經把void*包裝成了 vk_pointer,所以我們取出vk* 然後壓入參數
vk_pointer *value = valObj; void* pointer = value.pointer; [invocation setArgument:&pointer atIndex:i];
你以為這樣就可以了麼?你太天真了
如果斷點調試,整個call_selector的過程完全走完都不會有事,但是一旦放開斷點,徹底走完就崩潰。
為啥呢?因為在使用invocation的時候 invoke的過程中,如果對象在invoke內被創建初始化了,invoke結束後,在下一個autorelease的時間點就會產生zombie的crash,send release to a dealloc object
為什麼會這樣,簡單的說下我的理解不細說吧,invoke和直接函數調用不太一樣,如果發生了alloc對象,那麼這個對象系統會額外多一次autorelease,所以,不會立刻崩潰,但當autoreleasepool釋放的時候,就會發生過度release。
給幾個LINK有興趣大家可以深入探討一下:棧溢出1,棧溢出2
看一下我的解決辦法
vk_pointer *value = valObj; void* pointer = value.pointer; id obj = *((__unsafe_unretained id *)pointer); if (!obj) { if (argumentType[1] == '@') { if (!_vkNilPointerTempMemoryPool) { _vkNilPointerTempMemoryPool = [[NSMutableDictionary alloc] init]; } if (!_markArray) { _markArray = [[NSMutableArray alloc] init]; } [_markArray addObject:valObj]; } } [invocation setArgument:&pointer atIndex:i];
我會先判斷一下 void*指向的對象是否存在,如果傳入的是一個已經alloc init 好了的 mutableArray之類的對象,我會直接壓入參數,因為invoke過程內,只是往mutableArray裡面執行操作,並沒有在void*指針處重新new的操作的話,是安全的不會崩潰的。
如果void*指向的對象不存在,相當於我傳入了一個 NSError*,等著由invoke內部去創建,這樣外面可以捕獲,這種使用場景,就會導致crash,是因為過度release,那我的思路就是先把他持有一下。。。因為多了個release,那我再arc下不能強制retain,那我就add到一個字典裡,讓他被arc retain一下。
if ([_markArray count] > 0) { for (vk_pointer *pointerObj in _markArray) { void *pointer = pointerObj.pointer; id obj = *((__unsafe_unretained id *)pointer); if (obj) { @synchronized(_vkNilPointerTempMemoryPool) { [_vkNilPointerTempMemoryPool setObject:obj forKey:[NSNumber numberWithInteger:[(NSObject*)obj hash]]]; } } } }
這段代碼放在[invocation invoke]之後,因為只有執行之後我們才知道void*指向的位置是否創建了新對象,判斷obj是否存在,如果存在則向一個全局的static字典_vkNilPointerTempMemoryPool寫入這個對象。
有人說?我為什麼不是用棧溢出的答案?,棧溢出的答案卻是是保證不crash了,但是傳入的參數已經不是void** 而是一個 void***了,這樣會導致被調用的函數雖然創建了NSError,但是執行完畢後,並沒有賦值給有的指針,會導致外面看NSErro還是空(這麼表述可能不對,這幾天啃指針,這塊已經把我弄得有點亂了,但是大家在函數外取個地址&error看一下,然後在函數內看傳入的error地址,就會發現已經不對了)
有人說,你這樣不是內存洩露了麼?一個對象在用過以後就永久被添加進了一個static字典裡,我只能說是的,但是情況不是那麼絕對,crash的原因是系統的一次額外的release,並且還發生在代碼操作者無法掌控的autoreleasepool的drain時機,也就是說,在drain前,這個字典裡的這個值是正常的(如果沒有字典,此時並沒崩潰),在drain後,這個字典裡的值因為一次額外release了,此時這個字典內這個key還存在,但是他指向的對象已經野指針了(如果沒有字典,此時就崩潰了,因為對一個dealloc對象 release),我試過在幾秒之後肯定保證drain結束了,對字典執行removeAll,還是會崩潰!因為removeall的時候處理裡面的值,發現那個值野指針了。
有人有更好的辦法不?我想不到了,也求建議。
4.執行NSInvocation
[invocation invoke];
注意上文提到的invoke後處理一下 id* 的內存問題
5.取出返回值 具體可以看下一篇 NSInvocation內存處理
如同壓入參數一樣,還是通過typeEncode來判斷返回類型
const char *returnType = [methodSignature methodReturnType];
從invocation按類型取出返回值,返回
1)int 等基礎類型,注意我包裝成了NSNumber* 返回的,後文有講(只舉一個例子,其他見源碼)
int returnValue; [invocation getReturnValue:&returnValue]; return @(returnValue); break;
2)CGSize等基礎類型,注意我包裝成了NSValue* 返回的,後文有講(只舉一個例子,其他見源碼)
CGSize result; [invocation getReturnValue:&result]; NSValue * returnValue = [NSValue valueWithBytes:&(result) objCType:@encode(CGSize)];\ return returnValue;
3)id類型,這裡面也有個坑。我是這麼做的
void *result; [invocation getReturnValue:&result]; if (result == NULL) { return nil; } id returnValue; returnValue = (__bridge id)result; return returnValue;
為什麼這麼做,是因為getReturnValue只是拷貝返回值到指定的地址,你現在返回的是一個id,是一個指針,那麼實際對象會在函數runloop結束後自動釋放的,原因很類似之前的id*參數問題,但是這裡是返回值。
一個詳細介紹這一塊的博客
還有一點瑕疵
注意我的返回值被強迫指定成了id,也就是說,如果原函數返回的是NSInteger,我會返回一個NSNumber。
為什麼會這樣?我搞不定如何在聲明函數的時候,用一個兼容基礎和id,所有類型的符號來定義函數。。
參數之所以可以兼容id與基礎類型,是因為我用可變參數...繞過去了。。
但是返回值我就搞不定了,有人說用void *但我的初衷是希望使用者直接拿到最終的值,目前的困難不是如何把值傳出去。而是傳出去一個使用者不需要手動轉換的最終結果。
用void *這麼看和用id 其實也差不多,使用者拿到後都得轉一下。
感謝
感謝bang哥,好多invocation的使用都是學習bang哥的JSPatch裡面,拆解+學習
感謝彩虹,各種疑難雜症幫我一起動腦解決