你好,歡迎來到IOS教程網

 Ios教程網 >> IOS編程開發 >> IOS開發基礎 >> 嘗試手寫一個更好用的performSelector

嘗試手寫一個更好用的performSelector

編輯:IOS開發基礎

KPacholak__MG_2066 (1).jpg

本文是投稿文章,作者:唯敬

這其實是一個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,只要你對強轉成遵從的id對象,就能直接調用

  • 它像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裡面,拆解+學習

感謝彩虹,各種疑難雜症幫我一起動腦解決

  1. 上一頁:
  2. 下一頁:
蘋果刷機越獄教程| IOS教程問題解答| IOS技巧綜合| IOS7技巧| IOS8教程
Copyright © Ios教程網 All Rights Reserved