JSPatch 實現理詳解 (整改版)

NO IMAGE

JSPatch 是一個 iOS 動態更新框架,只需在專案中引入極小的引擎,就可以使用就可以使用 JavaScript 呼叫任何 Objective-C 原生介面,獲得指令碼語言的優勢:為專案動態新增模組,或替換專案原生程式碼動態修復 bug。

之前在部落格上寫過兩篇 JSPatch 原理解析文章(1 2),但隨著 JSPatch 的改進,有些內容已經跟最新程式碼對不上,這裡重新整理成一篇完整的文章,對原來的兩篇做整合和修改,詳細闡述 JSPatch 的實現原理和一些細節,以幫助使用者更好地瞭解和使用 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')

所以呼叫 require('UIView')

2.JS介面

接下來看看 UIView.alloc()

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

ii.__c()

給 JS 物件基類 Object 的 prototype 加上 __c

_methodFunc()

JS 通過呼叫 JSContext

讓 OC 物件作為這個 NSDictionary 的一個值,這樣在 JS 裡這個物件就變成:

{__obj: [OC Object 物件指標]}

這樣就可以通過判斷物件是否有 __obj

其中 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:

這樣就把 UIViewController 的 -viewDidLoad

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

2.va_list實現(32位)

最初我是用可變引數 va_list

這樣無論方法引數是什麼,有多少個,都可以通過 va_list

若原類沒有實現這個方法,在 JS 裡實現了,會走到新增方法的邏輯,每個引數型別都變成 id,與這個 protocol 方法不匹配,產生錯誤。

這裡就需要在 JS 定義類時給出實現的 protocol,這樣在新增 Protocol 裡已定義的方法時,引數型別會按照 Protocol 裡的定義去實現,Protocol 的定義方式跟 OC 上的寫法一致:

defineClass("JPViewController: UIViewController <UIAlertViewDelegate>", {
alertView_clickedButtonAtIndex: function(alertView, buttonIndex) {
console.log('clicked index '   buttonIndex)
}
})

實現方式比較簡單,先把 Protocol 名解析出來,當 JS 定義的方法在原有類上找不到時,再通過 objc_getProtocol

//JS
self.setSucc(1);
var str = self.data();

若要動態給 OC 物件新增 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:

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

7.super關鍵字

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

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

首先 JS 端需要告訴OC想呼叫的是當前物件的 super 方法,做法是呼叫 self.super()

再用這個返回的物件去呼叫方法時,__c

擴充套件

1.Struct 支援

struct 型別在 JS 與 OC 間傳遞需要做轉換處理,一開始 JSPatch 只處理了原生的 NSRange / CGRect / CGSize / CGPoint 這四個,其他 struct 型別無法在 OC / JS 間傳遞。對於其他型別的 struct 支援,我是採用擴充套件的方式,讓寫擴充套件的人手動處理每個要支援的 struct 進行型別轉換,這種做法沒問題,但需要在 OC 程式碼寫好這些擴充套件,無法動態新增,轉換的實現也比較繁瑣。於是轉為另一種實現:

/*
struct JPDemoStruct {
CGFloat a;
long b;
double c;
BOOL d;
}
*/
require('JPEngine').defineStruct({
"name": "JPDemoStruct",
"types": "FldB",
"keys": ["a", "b", "c", "d"]
})

可以在 JS 動態定義一個新的 struct,只需提供 struct 名,每個欄位的型別以及每個欄位對應的在 JS 的鍵值,就可以支援這個 struct 型別在 JS 和 OC 間傳遞了:

//OC
@implementation JPObject
(void)passStruct:(JPDemoStruct)s;
(JPDemoStruct)returnStruct;
@end

//JS
require('JPObject').passStruct({a:1, b:2, c:4.2, d:1})
var s = require('JPObject').returnStruct();

這裡的實現原理是順序去取 struct 裡每個欄位的值,再根據 key 重新包裝成 NSDictionary 傳給 JS,怎樣順序取 struct 每欄位的值呢?可以根據傳進來的 struct 欄位的變數型別,拿到型別對應的長度,順序拷貝出 struct 對應位置和長度的值,具體實現:

for (int i = 0; i < types.count; i   ) {
size_t size = sizeof(types[i]);  //types[i] 是 float double int 等型別
void *val = malloc(size);
memcpy(val, structData   position, size);
position  = size;
}

struct 從 JS 到 OC 的轉換同理,只是反過來,先生成整個 struct 大小的記憶體地址(通過 struct 所有欄位型別大小累加),再逐漸取出 JS 傳過來的值進行型別轉換拷貝到這端記憶體裡。

這種做法效果很好,JS 端要用一個新的 struct 型別,不需要 OC 事先定義好,可以直接動態新增新的 struct 型別支援,但這種方法依賴 struct 各個欄位在記憶體空間上的嚴格排列,如果某些機器在底層實現上對 struct 的欄位進行一些位元組對齊之類的處理,這種方式沒法用了,不過目前在 iOS 上還沒碰到這樣的問題。

2.C 函式支援

C 函式沒法通過反射去呼叫,所以只能通過手動轉接的方式讓 JS 調 C 方法,具體就是通過 JavaScriptCore 的方法在 JS 當前作用域上定義一個 C 函式同名方法,在這個方法實現裡呼叫 C 函式,以支援 memcpy()

這樣就可以在 JS 呼叫 memcpy()

main

同時 JSPatch 提供了 addExtensions:

實際上還有另一種方法新增 C 函式的支援,就是定義 OC 方法轉接:

@implementation JPCFunctions
(void)memcpy:(void *)des src:(void *)src n:(size_t)n {
memcpy(des, src, n);
}
@end

然後直接在 JS 上這樣調:

require('JPFunctions').memcpy_src_n(des, src, n);

這樣的做法不需要擴充套件機制,也不需要在實現時進行引數轉換,但因為它走的是 OC runtime 那一套,相比擴充套件直接呼叫的方式,速度慢了一倍,為了更好的效能,還是提供一套擴充套件介面。

細節

整個 JSPatch 的基礎原理上面大致闡述完了,接下來在看看一些實現上碰到的坑和的細節問題。

1.Special Struct

上文提到會把要覆蓋的方法指向_objc_msgForward

普通的返回值(int/pointer)很小,放在暫存器上沒問題,但有些 struct 是很大的,暫存器放不下,所以要用另一種方式,在一開始申請一段記憶體,把指標儲存在暫存器上,返回值往這個指標指向的記憶體寫資料,所以暫存器要騰出一個位置放這個指標,self / _cmd 在暫存器的位置就變了:

 -(struct st) method:(id)arg;
r3 = &struct_var (in caller's stack frame)
r4 = self
r5 = _cmd, @selector(method:)
r6 = arg
(on exit) return value written into struct_var

objc_msgSend

NSMethodSignature

但這樣的寫法會導致 crash,這是因為 id arg

但我們這裡不是顯式對 arg 進行賦值,而是傳入 -getArgument:atIndex:

還可以通過 __bridge

ii.記憶體洩露

Double Release 的問題解決了,又碰到記憶體洩露的坑。某天 github issue 上有人提物件生成後沒有釋放,幾經排查,定位到還是這裡 NSInvocation getReturnValue

這是因為 ARC 對方法名有約定,當方法名開頭是 alloc / new / copy / mutableCopy 時,返回的物件是 retainCount = 1 的,除此之外,方法返回的物件都是 autorelease 的,按上一節的說法,對於普通方法返回值,ARC 會在賦給 strong 變數時自動插入 retain 語句,但對於 alloc 等這些方法,不會再自動插入 retain 語句:

id obj = [SomeObject alloc];
//alloc 方法返回的物件 retainCount 已  1,這裡不需要retain
id obj2 = [SomeObj someMethod];
//方法返回的物件是 autorelease,ARC 會再這裡自動插入 [obj2 retain] 語句

而 ARC 並沒有處理非顯示呼叫時的情況,這裡動態呼叫這些方法時,ARC 都不會自動插入 retain,這種情況下,alloc / new 等這類方法返回值的 retainCount 是會比其他方法返回值多1的,所以需要特殊處理這類方法。

3.‘_’的處理

JSPatch 用下劃線’_’連線OC方法多個引數間的間隔:

- (void)setObject:(id)anObject forKey:(id)aKey;
<==>
setObject_forKey()

那如果OC方法名裡含有’_’,那就出現歧義了:

- (void)set_object:(id)anObject forKey:(id)aKey;
<==>
set_object_forKey()

沒法知道 set_object_forKey

於是嘗試另一種方法,用兩個下劃線 __

但用兩個下劃線代替有個問題,OC 方法名引數後面加下劃線會匹配不到

- (void)setObject_:(id)anObject forKey:(id)aKey;
<==>
setObject___forKey()

實際上 setObject___forKey()

NSMutableArray

這樣做有個小坑,就是顯示使用 NSNull.null()

這個只需注意下用 nsnull

原因是在 JS 裡 null

這樣的使用方式難以接受,繼續尋找解決方案,發現 true

如果 OC 方法的引數型別是 BOOL

總結

JSPatch 的原理以及一些實現細節就闡述到這裡,希望這篇文章對大家瞭解和使用 JSPatch 有幫助。接入 JSPatch 可以使用 JSPatch 平臺: jspatch.com