遷移老文章到掘金
這是上一篇部落格提到的程式碼的深入剖析
note:這個是JSPatch附屬新增的小功能點,想要詳細瞭解JsPatch整體部分的工作及原理戳這個wiki JSPatch實現原理詳解
出發點
一個不小心引發的bad case
工作中遇到了一個case,有一部分程式碼被重構了,一個函式被徹底的廢棄並且.m檔案中的具體函式實現已經被整體註釋掉了,但是.h檔案這個函式還存在.
由於被重構的那部分在客戶端很多處程式碼都有呼叫,沒有及時的替換成最新的函式,導致造成了線上crash,unrecognized selector
.
我最開始想用JsPatch發出一個hotfix,既然是unrecognized selector
,具體的函式實現不存在,那麼我用JSPatch動態補上這個函式實現,就可以封住crash了.
結果操作後發現,無法實現,原因是.h檔案中這個selector裡面有一個非id型別的引數.
JSPatch只能新增引數型別為id的方法
在JsPatch的Wiki中defineClass 有一句說明
可以給一個類隨意新增 OC 未定義的方法,但所有的引數型別都是 id:
為什麼會這樣,探究其原始碼可以發現
if (!overrided) {
NSMutableString *typeDescStr = [@"@@:" mutableCopy];
for (int i = 0; i < numberOfArg; i ++) {
[typeDescStr appendString:@"@"];
}
overrideMethod(currCls, selectorName, jsMethod, !isInstance, [typeDescStr cStringUsingEncoding:NSUTF8StringEncoding]);
}
複製程式碼
當使用defineClass對新方法命名的時候,defineClass能通過_
自動識別引數的位置和個數,但是並沒有能識別引數的型別。
而在通過這段程式碼建立新方法的時候,需要輸入方法的type encode
,由於defineClass只有引數的個數和位置資訊,並未獲得引數的型別,因此JsPatch預設要求新方法所有輸入的引數都是id型別,返回的引數也必須是id型別,通過@@:
+引數數量個@
來生成,只允許id型別的引數及返回的新方法
關於type encode
後面會詳細解釋
當我在嘗試通過JsPatch修復我的case的時候,由於我希望新增的方法是一個含有非id型別引數的方法,而JsPatch最終新增的新方法的引數都是id,所以程式執行的時候依然會crash,因為他還是找不到那個他想要的方法,依然是unrecognized selector
修改思路
知道原因,尋找思路
- defineClass為覆蓋修改方法而設計,對於新增方法,傳入的資訊不足,不能生成正確的
type encode
,所以無法正確的新增任意引數型別的方法,於是統一設定為id型別 - 如果由使用者傳入足夠的資訊,借而生成正確的
type encode
,則我們的目的就可以達成
我們可以考慮修改defineClass的input,專門在新增方法處開新的介面傳入引數,從而使得一切資訊都能到手,正常生成正確的新方法。
但是眼下還有2個問題
- defineClass在設計上,新增方法和覆蓋修改方法走的是同一個輸入口,單獨為新增方法而重新調整輸入介面,會使程式碼邏輯和設計模式變化比較大
- 在使用者已經養成的JsPatch編寫習慣上,新增和覆蓋二者本是統一的,為新增方法而大改defineClass的輸入模式,勢必會讓已經習慣使用的使用者有很大不便
- 尋找一個合適的方案,能不大範圍影響現在的設計模式,又能完成我的想法
defineClass的Protocol
JsPatch的defineClass 中提到的Protocol的作用
可以在定義時讓一個類實現某些 Protocol 介面,寫法跟 OC 一樣:
defineClass("JPViewController: UIViewController<UIScrollViewDelegate, UITextViewDelegate>", {})
這樣做的作用是,當新增 Protocol 裡定義的方法,而類裡沒有實現的方法時,引數型別不再全是 id,而是自動轉為 Protocol 裡定義的型別:
看到原作者bang的說明我們就可以明白,defineClass中的Protocol的作用本是藉助已經存在的Protocol的定義,從已經存在的Protocol中就可以抽取出描述selector的type encode
,進而生成含有非id引數的方法描述,從而能新增出正確的方法。
我們還可以看下原始碼,就一清二楚
if (class_respondsToSelector(currCls, NSSelectorFromString(selectorName))) {
overrideMethod(currCls, selectorName, jsMethod, !isInstance, NULL);
} else {
BOOL overrided = NO;
for (NSString *protocolName in protocols) {
char *types = methodTypesInProtocol(protocolName, selectorName, isInstance, YES);
if (!types) types = methodTypesInProtocol(protocolName, selectorName, isInstance, NO);
if (types) {
overrideMethod(currCls, selectorName, jsMethod, !isInstance, types);
free(types);
overrided = YES;
break;
}
}
if (!overrided) {
NSMutableString *typeDescStr = [@"@@:" mutableCopy];
for (int i = 0; i < numberOfArg; i ++) {
[typeDescStr appendString:@"@"];
}
overrideMethod(currCls, selectorName, jsMethod, !isInstance, [typeDescStr cStringUsingEncoding:NSUTF8StringEncoding]);
}
}
複製程式碼
原始碼中先判斷是否該方法已經存在,存在的情況下進行覆蓋,如果不存在,先判斷defineClass中是否指定了Protocol,指定了的話從Protocol中尋找匹配的Method進行覆蓋和新增,如果在指定Protocol中也找不到,才進行強制id引數型別的方法新增。
所以我選一個比較好的角度,既不破壞原本defineClass的設計邏輯,又能將新的引數傳入其中。
那就是設計一個全新的介面defineProtocol,在這個全新的介面裡面輸入足夠多的引數資訊,進而通過執行時建立全新的Protocol,建立完成的新Protocol就自然可以藉助defineClass裡面的功能,引入正確的新增方法
具體實現
JS介面設計
一開始我是想直接讓使用者輸入type encode
這樣也省了我的事,後來和原作者交流覺得,儘可能的節省使用者的學習成本,畢竟type encode
不知道的人還真不太能很快搞明白這一大堆: # @ v b i
的亂七八糟字元到底該怎麼寫,如果輸入介面這樣,就會比較直觀
defineProtocol('lalalala',{
testProtocol: {
paramsType:"int, id",
returnType:"BOOL"
},
...
}, {
...
});
複製程式碼
使用者直接輸入int,float,id,void等,由程式碼自動識別生成最終的type encode
,而且因為自動識別需程式碼進行逐一的支援和轉換,有些特殊的引數型別,程式碼轉換並不能完全覆蓋,於是還新增了一個可選的引數typeEncode,一旦自動轉換無法支援的引數型別,就可以通過可選引數,需要使用者自己想辦法手寫type encode
了,主要無法支援的引數是使用者自定義的struct
程式碼實現
JS介面這部分實現就不詳細描述了,和JSPatch其他介面完全一致,
看下對比是不是和defineClass一模一樣?^_^
context[@"_OC_defineProtocol"] = ^(NSString *protocolDeclaration, JSValue *instProtocol, JSValue *clsProtocol) {
return defineProtocol(protocolDeclaration, instProtocol,clsProtocol);
};
context[@"_OC_defineClass"] = ^(NSString *classDeclaration, JSValue *instanceMethods, JSValue *classMethods) {
return defineClass(classDeclaration, instanceMethods, classMethods);
};
複製程式碼
通過執行時objc_allocateProtocol
建立新Protocol,通過protocol_addMethodDescription
來為新Protocol增加方法,通過objc_registerProtocol
來註冊新Protocol,這是基本的runtime程式碼,不多描述了,原始碼裡都可以看到
唯一需要注意的是新protocol一經註冊生效objc_registerProtocol
,就不可在更改了,所以defineProtocol不能修改已經存在的Protocol
protocol_addMethodDescription
需要輸入seletorName和type encode,接下來重點說下如何在js返回的字典裡識別這兩個引數
識別selector
如介面設計裡面的樣例testProtocol,是被當做字典中的key,可以直接取出來的,因為我們設計defineProtocol中Js新方法的命名和defineClass一致,都是引數用_
代替,原本的_下劃線用__
代替,所以解析key這個字串的步驟和defineClass也一致
NOTES:原始碼中需要用paramsType的個數來判斷函式名結尾是否存在引數,所以在typeEncode可選引數使用的情況下,paramsType可以隨意輸入任意的字串,但是必須保證數量匹配
識別type encode
如介面設計裡面的樣例,引數會輸入"int, id"這樣的字串,返回值會輸入"void"這樣的字串,前者再通過,
號拆分成字串陣列,就接下來就可以通過程式碼獲取了,我打算構建一個有限字串對映表typeEncodeDic,以type字串為key,對映int
到i
這樣。
typeEncodeDic這個表已經構建好了,這樣從js傳來的type字串當做key,直接從這個表裡就能get到編碼。
人肉去寫這個表太low了,怎麼也得用酷炫一點的方式支援一下,看到原作者bang,在JsPatch裡面風騷的巨集的用法,我也照貓畫虎了一個
NSMutableDictionary* typeEncodeDic = [[NSMutableDictionary alloc]init];
#define JP_DEFINE_TYPE_ENCODE_CASE(_type) \
if ([@#_type length] > 0) {\
char* encode = @encode(_type);\
NSString * encodestr = [NSString stringWithUTF8String:encode];\
[typeEncodeDic setObject:encodestr forKey:@#_type];\
}
JP_DEFINE_TYPE_ENCODE_CASE(id);
複製程式碼
JP_DEFINE_TYPE_ENCODE_CASE
這個巨集就自動的將輸入引數_type
通過語法糖@encode()
寫入字典,這裡面還有一處很nb的地方
巨集裡面用引數生成靜態字串
這是一個很trick的地方,原本我的巨集是這麼設計的JP_DEFINE_TYPE_ENCODE_CASE(@"id",id)
為什麼這麼設計?因為我搞不定怎麼在巨集裡將id轉成@“id”,試了很多種方法都不行╮(╯_╰)╭
後來原作者bang交流,他給瞭解決辦法,@#_type
他在JsPatch裡已經用到了,說他當初也遇到一樣的困擾,然後查到的。
所以最終這個巨集被設計成了這樣。
JP_DEFINE_TYPE_ENCODE_CASE(id);
JP_DEFINE_TYPE_ENCODE_CASE(BOOL);
JP_DEFINE_TYPE_ENCODE_CASE(int);
JP_DEFINE_TYPE_ENCODE_CASE(void);
JP_DEFINE_TYPE_ENCODE_CASE(char);
JP_DEFINE_TYPE_ENCODE_CASE(short);
JP_DEFINE_TYPE_ENCODE_CASE(unsigned short);
JP_DEFINE_TYPE_ENCODE_CASE(unsigned int);
JP_DEFINE_TYPE_ENCODE_CASE(long);
JP_DEFINE_TYPE_ENCODE_CASE(unsigned long);
JP_DEFINE_TYPE_ENCODE_CASE(long long);
JP_DEFINE_TYPE_ENCODE_CASE(float);
JP_DEFINE_TYPE_ENCODE_CASE(double);
JP_DEFINE_TYPE_ENCODE_CASE(CGFloat);
JP_DEFINE_TYPE_ENCODE_CASE(CGSize);
JP_DEFINE_TYPE_ENCODE_CASE(CGRect);
JP_DEFINE_TYPE_ENCODE_CASE(CGPoint);
JP_DEFINE_TYPE_ENCODE_CASE(CGVector);
JP_DEFINE_TYPE_ENCODE_CASE(UIEdgeInsets);
JP_DEFINE_TYPE_ENCODE_CASE(NSInteger);
JP_DEFINE_TYPE_ENCODE_CASE(Class);
JP_DEFINE_TYPE_ENCODE_CASE(SEL);
複製程式碼
從這可以看出來,想要擴充套件支援更多的引數型別?沒問題,在這裡新增就好了(不想修改原始碼,動態新增就走之前說的可選引數typeEncode)
處理id型別引數
看到上面我們知道,如果我的新函式中存在id型別,無論是系統型別NSArray還是使用者自己寫的CustomObject,在使用我們的defineProtocol的時候使用者需要自己記得所有的NSObject都要輸入id
,仔細想想這也挺不方便的對吧?
所以我額外做了一個處理,當從typeEncodeDic表裡面找不到對應的key的時候,就會NSClassFromString
來判斷是否是一個Oc物件,如果是自動轉換為id的型別編碼@
NSString* argencode = [typeEncodeDic objectForKey:argstr];
if (argencode.length <= 0) {
Class cls = NSClassFromString(argstr);
if ([(id)cls isKindOfClass:[NSObject class]]) {
argencode = @"@";
}
}
複製程式碼
這樣無論使用者輸入類名
還是id
,我這邊的處理都是完全一樣,等效的
paramsType:"id"
paramsType:"CustomObject"
複製程式碼
生成SEL的型別編碼
SEL的型別編碼命名方式是這樣的
- (void) setSomething:(id) anObject
複製程式碼
這個函式他的型別編碼是
v@:@
複製程式碼
- 第一個
v
代表返回值是void即void的型別編碼 - 第二個
@
代表self(其實是第一個引數 Self和SEL是任何oc函式的隱藏引數),這個基本是固定的 - 第三個
:
代表SEL(其實是第二個引數 Self和SEL是任何oc函式的隱藏引數),這個基本是固定的 - 第四個
@
代表Oc函式第一個引數的型別即id的型別編碼
通過這些規律,我們可以手寫SEL的型別編碼了,每一種引數型別可以查詢蘋果的定義
程式碼中可選引數typeEncode優先順序最高,如果使用者手寫了可選引數,則不會執行程式碼自動生成,直接使用使用者輸入的typeEncode,生成Protocol。
if (typeEncode) {
addMethodToProtocol(protocol, selectorName, typeEncode, isInstance);
}else
{
//type encode string automatic create
}
複製程式碼
詳探TypeEncode
我們可以手寫typeEncode,其實也可以藉助oc程式碼生成typeEncode
我們先在程式碼中實現- (void) setSomething:(id) anObject
這個方法,然後使用下面的程式碼,就能通過系統取出SEL的typeEncode
Class cls = self.class;
SEL selstr = NSSelectorFromString(@"setSomething:");
Method method = class_getInstanceMethod(cls, selstr);
const char* type = method_getTypeEncoding(method);
複製程式碼
經過系統的讀取,驚訝的發現,系統算出來的type居然是v12@0:4@8
,這他喵的一堆數字是什麼鬼!,剛才不是說v@:@
嘛????????!!!!!!
經過我反覆地測試,發現無論是輸入v12@0:4@8
還是v@:@
,Protocol都能正常的生成,一點區別也沒有,完全不影響使用,但是他喵的為什麼系統就會多出來這麼多數字?
棧溢位的一個回答似乎能解釋 StackOverFlow-What are the digits in an ObjC method type encoding string?
和gitHub上的@DevSonw聊,覺得這可能是一個位元組補齊的過程,並不影響使用