JSPatch 實現原理詳解(一)

NO IMAGE

JSPatch以小巧的體積做到了讓JS呼叫/替換任意OC方法,讓iOS APP具備熱更新的能力,在實現 JSPatch 過程中遇到過很多困難也踩過很多坑,有些還是挺值得分享的。本篇文章從基礎原理、方法呼叫和方法替換三塊內容介紹整個 JSPatch 的實現原理,並把實現過程中的想法和碰到的坑也儘可能記錄下來。

基礎原理

能做到通過JS呼叫和改寫OC方法最根本的原因是 Objective-C 是動態語言,OC上所有方法的呼叫/類的生成都通過 Objective-C Runtime 在執行時進行,我們可以通過類名/方法名反射得到相應的類和方法:

Class class = NSClassFromString("UIViewController");
id viewController = [[class alloc] init];
SEL selector = NSSelectorFromString("viewDidLoad");
[viewController performSelector:selector];

也可以替換某個類的方法為新的實現:

static void newViewDidLoad(id slf, SEL sel) {}
class_replaceMethod(class, selector, newViewDidLoad, @"");

還可以新註冊一個類,為類新增方法:

Class cls = objc_allocateClassPair(superCls, "JPObject", 0);
objc_registerClassPair(cls);
class_addMethod(cls, selector, implement, typedesc);

對於 Objective-C 物件模型和動態訊息傳送的原理已有很多文章闡述得很詳細,例如這篇,這裡就不詳細闡述了。理論上你可以在執行時通過類名/方法名呼叫到任何OC方法,替換任何類的實現以及新增任意類。所以 JSPatch 的原理就是:JS傳遞字串給OC,OC通過 Runtime 介面呼叫和替換OC方法。這是最基礎的原理,實際實現過程還有很多怪要打,接下來看看具體是怎樣實現的。

方法呼叫

require('UIView')
var view = UIView.alloc().init()
view.setBackgroundColor(require('UIColor').grayColor())
view.setAlpha(0.5)

引入JSPatch後,可以通過以上JS程式碼建立了一個 UIView 例項,並設定背景顏色和透明度,涵蓋了require引入類,JS呼叫介面,訊息傳遞,物件持有和轉換,引數轉換這五個方面,接下來逐一看看具體實現。

1.require

呼叫 require('UIView') 後,就可以直接使用 UIView 這個變數去呼叫相應的類方法了,require 做的事很簡單,就是在JS全域性作用域上建立一個同名變數,變數指向一個物件,物件屬性__isCls表明這是一個 Class,__clsName儲存類名,在呼叫方法時會用到這兩個屬性。

var _require = function(clsName) {
if (!global[clsName]) {
global[clsName] = {
__isCls: 1,
__clsName: clsName
}
}
return global[clsName]
}

所以呼叫require(‘UIView’)後,就在全域性作用域生成了 UIView 這個變數,指向一個這樣一個物件:

{
__isCls: 1,
__clsName: "UIView"
}

2.JS介面

接下來看看 UIView.alloc() 是怎樣呼叫的。

舊實現

對於這個呼叫的實現,一開始我的想法是,根據JS特性,若要讓 UIView.alloc() 這句呼叫不出錯,唯一的方法就是給 UIView 這個物件新增 alloc 方法,不然是不可能呼叫成功的,JS對於呼叫沒定義的屬性/變數,只會馬上丟擲異常,而不像OC/Lua/ruby那樣會有轉發機制。所以做了一個複雜的事,就是在require生成類物件時,把類名傳入OC,OC通過 Runtime 方法找出這個類所有的方法返回給JS,JS類物件為每個方法名都生成一個函式,函式內容就是拿著方法名去OC呼叫相應方法。生成的 UIView 物件大致是這樣的:

{
__isCls: 1,
__clsName: "UIView",
alloc: function() {…},
beginAnimations_context: function() {…},
setAnimationsEnabled: function(){…},
...
}

實際上不僅要遍歷當前類的所有方法,還要迴圈找父類的方法直到頂層,整個繼承鏈上的所有方法都要加到JS物件上,一個類就有幾百個方法,這樣把方法全部加到JS物件上,碰到了挺嚴重的問題,引入幾個類就記憶體暴漲,無法使用。後來為了優化記憶體問題還在JS搞了繼承關係,不把繼承鏈上所有方法都新增到一個JS物件,避免像基類 NSObject 的幾百個方法反覆新增在每個JS物件上,每個方法只存在一份,JS物件複製了OC物件的繼承關係,找方法時沿著繼承鏈往上找,結果記憶體消耗是小了一些,但還是大到難以接受。

新實現

當時繼續苦苦尋找解決方案,若按JS語法,這是唯一的方法,但若不按JS語法呢?突然腦洞開了下,CoffieScript/JSX都可以用JS實現一個直譯器實現自己的語法,我也可以通過類似的方式做到,再進一步想到其實我想要的效果很簡單,就是呼叫一個不存在方法時,能轉發到一個指定函式去執行,就能解決一切問題了,這其實可以用簡單的字串替換,把JS指令碼里的方法呼叫都替換掉。最後的解決方案是,在OC執行JS指令碼前,通過正則把所有方法呼叫都改成呼叫 __c() 函式,再執行這個JS指令碼,做到了類似OC/Lua/Ruby等的訊息轉發機制:

UIView.alloc().init()
->
UIView.__c('alloc')().__c('init')()

給JS物件基類 Object 的 prototype 加上 __c 成員,這樣所有物件都可以呼叫到 __c,根據當前物件型別判斷進行不同操作:

Object.prototype.__c = function(methodName) {
if (!this.__obj && !this.__clsName) return this[methodName].bind(this);
var self = this
return function(){
var args = Array.prototype.slice.call(arguments)
return _methodFunc(self.__obj, self.__clsName, methodName, args, self.__isSuper)
}
}

_methodFunc() 就是把相關資訊傳給OC,OC用 Runtime 介面呼叫相應方法,返回結果值,這個呼叫就結束了。

這樣做不用去OC遍歷物件方法,不用在JS物件儲存這些方法,記憶體消耗直降99%,這一步是做這個專案最爽的時候,用一個非常簡單的方法解決了嚴重的問題,替換之前又複雜效果又差的實現。

3.訊息傳遞

解決了JS介面問題,接下來看看JS和OC是怎樣互傳訊息的。這裡用到了 JavaScriptCore 的介面,OC端在啟動JSPatch引擎時會建立一個 JSContext 例項,JSContext 是JS程式碼的執行環境,可以給 JSContext 新增方法,JS就可以直接呼叫這個方法:

JSContext *context = [[JSContext alloc] init];
context[@"hello"] = ^(NSString *msg) {
NSLog(@"hello %@", msg);
};
[_context evaluateScript:@"hello('word')"];     //output hello word

JS通過呼叫 JSContext 定義的方法把資料傳給OC,OC通過返回值傳會給JS。呼叫這種方法,它的引數/返回值 JavaScriptCore 都會自動轉換,OC裡的 NSArray, NSDictionary, NSString, NSNumber, NSBlock 會分別轉為JS端的陣列/物件/字串/數字/函式型別。上述 _methodFunc() 方法就是這樣把要呼叫的類名和方法名傳遞給OC的。

4.物件持有/轉換

UIView.alloc() 通過上述訊息傳遞後會到OC執行 [UIView alloc],並返回一個UIView例項物件給JS,這個OC例項物件在JS是怎樣表示的呢?怎樣可以在JS拿到這個例項物件後可以直接呼叫它的例項方法 (UIView.alloc().init())

對於一個自定義id物件,JavaScriptCore 會把這個自定義物件的指標傳給JS,這個物件在JS無法使用,但在回傳給OC時OC可以找到這個物件。對於這個物件生命週期的管理,按我的理解如果JS有變數引用時,這個OC物件引用計數就加1 ,JS變數的引用釋放了就減1,如果OC上沒別的持有者,這個OC物件的生命週期就跟著JS走了,會在JS進行垃圾回收時釋放。

傳回給JS的變數是這個OC物件的指標,如果不經過任何處理,是無法通過這個變數去呼叫例項方法的。所以在返回物件時,JSPatch 會對這個物件進行封裝。

首先,告訴JS這是一個OC物件:

static NSDictionary *toJSObj(id obj)
{
if (!obj) return nil;
return @{@"__isObj": @(YES), @"cls": NSStringFromClass([obj class]), @"obj": obj};
}

用__isObj表示這是一個OC物件,物件指標也一起返回。接著在JS端會把這個物件轉為一個 JSClass 例項:

var JSClass
var _getJSClass = function(className) {
if (!JSClass) {
JSClass = function(obj, className, isSuper) {
this.__obj = obj
this.__isSuper = isSuper
this.__clsName = className
}
}
return JSClass
}
var _toJSObj = function(meta) {
var JSClass = _getJSClass()
return new JSClass(meta["obj"], meta["cls"])
}

JS端如果發現返回是一個OC物件,會傳入 _toJSObj(),生成一個 JSClass 例項,這個例項儲存著OC物件指標,類名等。這個例項就是OC物件在 JSPatch 對應的JS物件,生命週期是一樣的。

回到我們第二點說的 JS介面, 這個 JSClass 例項物件同樣有 __c 函式,呼叫這個物件的方法時,同樣走到 __c 函式, __c 函式會把JSClass例項物件裡的OC物件指標以及要呼叫的方法名和引數回傳給OC,這樣OC就可以呼叫這個物件的例項方法了。

接著看看物件是怎樣回傳給OC的。上述例子中,view.setBackgroundColor(require('UIColor').grayColor()),這裡生成了一個 UIColor 例項物件,並作為引數回傳給OC。根據上面說的,這個 UIColor 例項在JS中的表示是一個 JSClass 例項,所以不能直接回傳給OC,這裡的引數實際上會在 __c 函式進行處理,會把物件的 .__obj 原指標回傳給OC。

最後一點,OC物件可能會存在於 NSDictionary / NSArray 等容器裡,所以需要遍歷容器挑出OC物件進行格式化,OC需要把物件都替換成JS認得的格式,JS要把物件轉成 JSClass 例項,JS例項回傳給OC時需要把例項轉為OC物件指標。所以OC流出資料時都會經過 formatOCObj() 方法處理,JS從OC得到資料時都會經過 _formatOCToJS() 處理,JS傳引數給OC時會經過 _formatJSToOC() 處理,圖示:

JSPatch1.png

5.型別轉換

JS把要呼叫的類名/方法名/物件傳給OC後,OC呼叫類/物件相應的方法是通過 NSInvocation 實現,要能順利呼叫到方法並取得返回值,要做兩件事:

1.取得要呼叫的OC方法各引數型別,把JS傳來的物件轉為要求的型別進行呼叫。
2.根據返回值型別取出返回值,包裝為物件傳回給JS。

例如開頭例子的 view.setAlpha(0.5), JS傳遞給OC的是一個 NSNumber,OC需要通過要呼叫OC方法的 NSMethodSignature 得知這裡引數要的是一個 float 型別值,於是把NSNumber轉為float值再作為引數進行OC方法呼叫。這裡主要處理了 int/float/bool 等數值型別,並對 CGRect/CGRange 等型別進行了特殊轉換處理,剩下的就是實現細節了。

方法替換

JSPatch 可以用 defineClass 介面任意替換一個類的方法,方法替換的實現過程也是頗為曲折,一開始是用 va_list 的方式獲取引數,結果發現 arm64 下不可用,只能轉而用另一種hack方式繞道實現。另外在給類新增方法、實現property、支援self/super關鍵字上也費了些功夫,下面逐個說明。

基礎原理

OC上,每個類都是這樣一個結構體:

struct objc_class {
struct objc_class * isa;
const char *name;
….
struct objc_method_list **methodLists; /*方法連結串列*/
};

其中 methodList 方法連結串列裡儲存的是Method型別:

typedef struct objc_method *Method;
typedef struct objc_ method {
SEL method_name;
char *method_types;
IMP method_imp;
};

Method 儲存了一個方法的全部資訊,包括SEL方法名,type各引數和返回值型別,IMP該方法具體實現的函式指標。

通過 Selector 呼叫方法時,會從 methodList 連結串列裡找到對應Method進行呼叫,這個 methodList 上的的元素是可以動態替換的,可以把某個 Selector 對應的函式指標IMP替換成新的,也可以拿到已有的某個 Selector 對應的函式指標IMP,讓另一個 Selector 跟它對應,Runtime 提供了一些介面做這些事,以替換 UIViewController 的 -viewDidLoad: 方法為例:

static void viewDidLoadIMP (id slf, SEL sel) {
JSValue *jsFunction = …;
[jsFunction callWithArguments:nil];
}
Class cls = NSClassFromString(@"UIViewController");
SEL selector = @selector(viewDidLoad);
Method method = class_getInstanceMethod(cls, selector);
//獲得viewDidLoad方法的函式指標
IMP imp = method_getImplementation(method)
//獲得viewDidLoad方法的引數型別
char *typeDescription = (char *)method_getTypeEncoding(method);
//新增一個ORIGViewDidLoad方法,指向原來的viewDidLoad實現
class_addMethod(cls, @selector(ORIGViewDidLoad), imp, typeDescription);
//把viewDidLoad IMP指向自定義新的實現
class_replaceMethod(cls, selector, viewDidLoadIMP, typeDescription);

這樣就把 UIViewController 的 -viewDidLoad 方法給替換成我們自定義的方法,APP裡呼叫 UIViewController 的 viewDidLoad 方法都會去到上述 viewDidLoadIMP 函式裡,在這個新的IMP函式裡呼叫JS傳進來的方法,就實現了替換 -viewDidLoad 方法為JS程式碼裡的實現,同時為 UIViewController 新增了個方法 -ORIGViewDidLoad 指向原來 viewDidLoad 的IMP,JS可以通過這個方法呼叫到原來的實現。

方法替換就這樣很簡單的實現了,但這麼簡單的前提是,這個方法沒有引數。如果這個方法有引數,怎樣把引數值傳給我們新的IMP函式呢?例如 UIViewController 的 -viewDidAppear: 方法,呼叫者會傳一個Bool值,我們需要在自己實現的IMP(上述的viewDidLoadIMP)上拿到這個值,怎樣能拿到?如果只是針對一個方法寫IMP,是可以直接拿到這個引數值的:

static void viewDidAppear (id slf, SEL sel, BOOL animated) {
[function callWithArguments:@(animated)];
}

但我們要的是實現一個通用的IMP,任意方法任意引數都可以通過這個IMP中轉,拿到方法的所有引數回撥JS的實現。

va_list實現(32位)

最初我是用可變引數va_list實現:

static void commonIMP(id slf, ...)
va_list args;
va_start(args, slf);
NSMutableArray *list = [[NSMutableArray alloc] init];
NSMethodSignature *methodSignature = [cls instanceMethodSignatureForSelector:selector];
NSUInteger numberOfArguments = methodSignature.numberOfArguments;
id obj;
for (NSUInteger i = 2; i < numberOfArguments; i  ) {
const char *argumentType = [methodSignature getArgumentTypeAtIndex:i];
switch(argumentType[0]) {
case 'i':
obj = @(va_arg(args, int));
break;
case 'B':
obj = @(va_arg(args, BOOL));
break;
case 'f':
case 'd':
obj = @(va_arg(args, double));
break;
…… //其他數值型別
default: {
obj = va_arg(args, id);
break;
}
}
; } va_end(args); [function callWithArguments:list]; }

這樣無論方法引數是什麼,有多少個,都可以通過 va_list 的一組方法一個個取出來,組成NSArray在呼叫JS方法時傳回。很完美地解決了引數的問題,一直執行正常,直到我跑在arm64的機子上測試,一呼叫就crash。查了資料,才發現arm64下 va_list 的結構改變了,導致無法上述這樣取引數。詳見這篇文章

ForwardInvocation實現(64位)

後來找到另一種非常hack的方法解決引數獲取的問題,利用了OC訊息轉發機制。

當呼叫一個 NSObject 物件不存在的方法時,並不會馬上丟擲異常,而是會經過多層轉發,層層呼叫物件的 -resolveInstanceMethod:, -forwardingTargetForSelector:, -methodSignatureForSelector:, -forwardInvocation: 等方法,這篇文章說得比較清楚,其中最後 -forwardInvocation: 是會有一個 NSInvocation 物件,這個 NSInvocation 物件儲存了這個方法呼叫的所有資訊,包括 Selector 名,引數和返回值型別,最重要的是有所有引數值,可以從這個 NSInvocation 物件裡拿到呼叫的所有引數值。我們可以想辦法讓每個需要被JS替換的方法呼叫最後都調到 -forwardInvocation:,就可以解決無法拿到引數值的問題了。

具體實現,以替換 UIViewController 的 -viewWillAppear: 方法為例:

  1. 把UIViewController的 -viewWillAppear: 方法通過 class_replaceMethod() 介面指向一個不存在的IMP: class_getMethodImplementation(cls, @selector(__JPNONImplementSelector)),這樣呼叫這個方法時就會走到 -forwardInvocation:

  2. 為 UIViewController 新增 -ORIGviewWillAppear:-_JPviewWillAppear: 兩個方法,前者指向原來的IMP實現,後者是新的實現,稍後會在這個實現裡回撥JS函式。

  3. 改寫 UIViewController 的 -forwardInvocation: 方法為自定義實現。一旦OC裡呼叫 UIViewController 的 -viewWillAppear: 方法,經過上面的處理會把這個呼叫轉發到 -forwardInvocation: ,這時已經組裝好了一個 NSInvocation,包含了這個呼叫的引數。在這裡把引數從 NSInvocation 反解出來,帶著引數呼叫上述新增加的方法 -JPviewWillAppear: ,在這個新方法裡取到引數傳給JS,呼叫JS的實現函式。整個呼叫過程就結束了,整個過程圖示如下:

JSPatch2.png

最後一個問題,我們把 UIViewController 的 -forwardInvocation: 方法的實現給替換掉了,如果程式裡真有用到這個方法對訊息進行轉發,原來的邏輯怎麼辦?首先我們在替換 -forwardInvocation: 方法前會新建一個方法 -ORIGforwardInvocation:,儲存原來的實現IMP,在新的 -forwardInvocation: 實現裡做了個判斷,如果轉發的方法是我們想改寫的,就走我們的邏輯,若不是,就調 -ORIGforwardInvocation: 走原來的流程。

實現過程中還碰到一個坑,就是從 -forwardInvocation: 裡的 NSInvocation 物件取引數值時,若引數值是id型別,我們會這樣取:

id arg;
[invocation getArgument:&arg atIndex:i];

但這樣取某些時候會導致莫名其妙的crash,而且不是crash在這個地方,似乎這裡的指標取錯導致後續的記憶體錯亂,crash在各種地方,這個bug查了我半天才定位到這裡,至今不知為什麼。後來以這樣的方式解決了:

void *arg;
[invocation getArgument:&arg atIndex:i];
id a = (__bridge id)arg;

其他就是實現上的細節了,例如需要根據不同的返回值型別生成不同的IMP,要在各處處理引數轉換等。

新增方法

在 JSPatch 剛開源時,是不支援為一個類新增方法的,因為覺得能替換原生方法就夠了,新的方法純粹新增在JS物件上,只在JS端跑就行了。另外OC為類新增方法需要知道各個引數和返回值的型別,需要在JS定一種方式把這些型別傳給OC才能完成新增方法,比較麻煩。後來挺多人比較關注這個問題,不能新增方法導致 action-target 模式無法用,我也開始想有沒有更好的方法實現新增方法。一開始想到,反正新增的方法都是JS在用,不如新增的方法返回值和引數全統一成id型別,這樣就不用傳型別了,但還是需要知道引數個數,後來跟Lancy聊天時找到了解決方案,JS可以獲得函式引數個數,直接封裝一下把引數個數一併傳給OC就行了。

現在 defineClass 定義的方法會經過JS包裝,變成一個包含引數個數和方法實體的陣列傳給OC,OC會判斷如果方法已存在,就執行替換的操作,若不存在,就呼叫 class_addMethod() 新增一個方法,通過傳過來的引數個數和方法實體生成新的 Method,把 Method 的引數和返回值型別都設為id。

這裡有個問題,若某個類實現了某protocol,protocol方法裡有可選的方法,它的引數不全是id型別,例如 UITableViewDataSource 的一個方法:

- (NSInteger)tableView:(UITableView *)tableView sectionForSectionIndexTitle:(NSString *)title atIndex:(NSInteger)index;

若原類沒有實現這個方法,在JS裡實現了,會走到新增方法的邏輯,每個引數型別都變成id,與這個 protocol 方法不匹配,產生錯誤。後續會處理 protocol 的問題,若新增的方法是 protocol 實現的方法,會取這個方法的 NSMethodSignature 獲得正確的引數型別進行新增。

Property實現

defineClass('JPTableViewController : UITableViewController', {
dataSource: function() {
var data = self.getProp('data')
if (data) return data;
data = [1,2,3]
self.setProp_forKey(data, 'data')
return data;
}
}

JSPatch 可以通過 -getProp:-setProp:forKey: 這兩個方法給物件新增成員變數。實現上用了執行時關聯介面 objc_getAssociatedObject()objc_setAssociatedObject() 模擬,相當於把一個物件跟當前物件 self 關聯起來,以後可以通過當前物件 self 找到這個物件,跟成員的效果一樣,只是一定得是id物件型別。

本來OC有 class_addIvar() 可以為類新增成員,但必須在類註冊之前新增完,註冊完成後無法新增,這意味著可以為在JS新增的類新增成員,但不能為OC上已存在的類新增,所以只能用上述方法模擬。

self關鍵字

defineClass("JPViewController: UIViewController", {
viewDidLoad: function() {
var view = self.view()
...
},
}

JSPatch支援直接在 defineClass 裡的例項方法裡直接使用 self 關鍵字,跟OC一樣 self 是指當前物件,這個 self 關鍵字是怎樣實現的呢?實際上這個self是個全域性變數,在 defineClass 裡對例項方法 方法進行了包裝,在呼叫例項方法之前,會把全域性變數 self 設為當前物件,呼叫完後設回空,就可以在執行例項方法的過程中使用 self 變數了。這是一個小小的trick。

super關鍵字

defineClass("JPViewController: UIViewController", {
viewDidLoad: function() {
self.super.viewDidLoad()
},
}

OC裡 super 是一個關鍵字,無法通過動態方法拿到 super,那麼 JSPatch 的super是怎麼實現的?實際上呼叫 super 的方法,OC做的事是呼叫父類的某個方法,並把當前物件當成 self 傳入父類方法,我們只要模擬它這個過程就行了。

首先JS端需要告訴OC想呼叫的是當前物件的 super 方法,做法是呼叫 self.super時,會返回一個新的 JSClass 例項,這個例項同樣儲存了OC物件的引用,同時標識 __isSuper=1

JSClass = function(obj, className, isSuper) {
this.__obj = obj
this.__isSuper = isSuper
this.__clsName = className
}
JSClass.prototype.__defineGetter__('super', function(){
if (!this.__super) {
this.__super = new JSClass(this.__obj, this.__clsName, 1)
}
return this.__super
})

呼叫方法時,__isSuper 會傳給OC,告訴OC要調 super 的方法。OC做的事情是,如果是呼叫 super 方法,找到 superClass 這個方法的IMP實現,為當前類新增一個方法指向 super 的IMP實現,那麼呼叫這個類的新方法就相當於呼叫 super 方法。把要呼叫的方法替換成這個新方法,就完成 super 方法的呼叫了。

static id callSelector(NSString *className, NSString *selectorName, NSArray *arguments, id instance, BOOL isSuper) {
...
if (isSuper) {
NSString *superSelectorName = [NSString stringWithFormat:@"SUPER_%@", selectorName];
SEL superSelector = NSSelectorFromString(superSelectorName);
Class superCls = [cls superclass];
Method superMethod = class_getInstanceMethod(superCls, selector);
IMP superIMP = method_getImplementation(superMethod);
class_addMethod(cls, superSelector, superIMP, method_getTypeEncoding(superMethod));
selector = superSelector;
}
...
}

總結

整個 JSPatch 實現原理就大致描述完了,剩下的一些小點,例如GCD介面,block實現,方法名下劃線處理等就不細說了,可以直接看程式碼。JSPatch 還在持續改進中,希望能成為iOS平臺動態更新的最佳解決方案,歡迎大家一起建設這個專案,github地址: https://github.com/bang590/JSPatch