JSPatch近期新特性解析

JSPatch在社區的推進下不斷在優化改善,這篇文章總結下這幾個月以來 JSPatch 的一些新特性,以及它們的實現原理。php

performSelectorInOC

JavaScript 語言是單線程的,在 OC 使用 JavaScriptCore 引擎執行 JS 代碼時,會對 JS 代碼塊加鎖,保證同個 JSContext 下的 JS 代碼都是順序執行。因此調用 JSPatch 替換的方法,以及在 JSPatch 裏調用 OC 方法,都會在這個鎖裏執行,這致使三個問題:html

  • JSPatch替換的方法沒法並行執行,若是若是主線程和子線程同時運行了 JSPatch 替換的方法,這些方法的執行都會順序排隊,主線程會等待子線程的方法執行完後再執行,若是子線程方法耗時長,主線程會等好久,卡住主線程。ios

  • 某種狀況下,JavaScriptCore 的鎖與 OC 代碼上的鎖混合時,會產生死鎖。git

  • UIWebView 的初始化會與 JavaScriptCore 衝突。若在 JavaScriptCore 的鎖裏(第一次)初始化 UIWebView 會致使 webview 沒法解析頁面。github

爲解決這些問題,JSPatch 新增了 .performSelectorInOC(selector, arguments, callback) 接口,能夠在執行 OC 方法時脫離 JavaScriptCore 的鎖,同時又保證程序順序執行。web

舉個例子:數組

defineClass('JPClassA', {
  methodA: function() {
    //run in mainThread
  },
  methodB: function() {
      //run in childThread
      var limit = 20;
      var data = self.readData(limit);
      var count = data.count();
      return {data: data, count: count};
  }
})

上述例子中若在主線程和子線程同時調用 -methodA-methodB,而 -methodBself.readData(limit) 這句調用耗時較長,就會卡住主線程方法 -methodA 的執行,對此可讓這個調用改用 .performSelectorInOC() 接口,讓它在 JavaScriptCore 鎖釋放後再執行,不卡住其餘線程的 JS 方法執行:安全

defineClass('JPClassA', {
  methodA: function() {
    //run in mainThread
  },
  methodB: function() {
      //run in childThread
      var limit = 20;
      return self.performSelectorInOC('readData', [limit], function(ret) {
          var count = ret.count();
          return {data: ret, count: count};
      });
  }
})

這兩份代碼在調用順序上的區別以下圖:服務器

第一份代碼對應左邊的流程圖,-methodB 方法被替換,當 OC 調用到 -methodB 時會去到 JSPatch 核心的 JPForwardInvocation 方法裏,在這裏面調用 JS 函數 -methodB,調用時 JavascriptCore 加鎖,接着在 JS 函數裏作這種處理,調用 reloadData() 函數,進而去到 OC 調用 -reloadData 方法,這時 -reloadData 方法是在 JavaScriptCore 的鎖裏調用的。直到 JS 函數執行完畢 return 後,JavaScriptCore 的才解鎖,結束本次調用。app

第二份代碼對應右邊的流程圖,前面是同樣的,調用 JS 函數 -methodB,JavaScriptCore 加鎖,但 -methodB 函數在調用某個 OC 方法時(這裏是reloadData()),不直接去調用,而是直接 return 返回一個對象 {obj},這個{obj}的結構以下:

{
__isPerformInOC:1,
obj:self.__obj,
clsName:self.__clsName,
sel: args[0],
args: args[1],
cb: args[2]
}

JS 函數返回這個對象,JS 的調用就結束了,JavaScriptCore 的鎖也就釋放了。在 OC 能夠拿到 JS 函數的返回值,也就拿到了這個對象,而後判斷它是否 __isPerformInOC=1 對象,如果就根據對象裏的 selector / 參數等信息調用對應的 OC 方法,這時這個 OC 方法的調用是在 JavaScriptCore 的鎖以外調用的,咱們的目的就達到了。

執行 OC 方法後,會去調 {obj} 裏的的 cb 函數,把 OC 方法的返回值傳給 cb 函數,從新回到 JS 去執行代碼。這裏會循環判斷這些回調函數是否還返回 __isPerformInOC=1 的對象,如果則重複上述流程執行,不是則結束。

整個原理就是這樣,相關代碼在 這裏這裏,實現起來其實挺簡單,也不會對其餘流程和邏輯形成影響,就是理解起來會有點費勁。

performSelectorInOC 文檔裏還有關於死鎖的例子,有興趣能夠看看。

可變參數方法調用

一直以來這樣參數個數可變的方法是不能在 JSPatch 動態調用的:

- (instancetype)initWithTitle:(nullable NSString *)title message:(nullable NSString *)message delegate:(nullable id)delegate cancelButtonTitle:(nullable NSString *)cancelButtonTitle otherButtonTitles:(nullable NSString *)otherButtonTitles, ...

緣由是 JSPatch 調用 OC 方法時,是根據 JS 傳入的方法名和參數組裝成 NSInvocation 動態調用,而 NSInvocation 不支持調用參數個數可變的方法。

後來 @wjacker 換了種方式,用 objc_msgSend 的方式支持了可變參數方法的調用。以前一直想不到使用 objc_msgSend 是由於它不適用於動態調用,在方法定義和調用上都是固定的:

1.定義

須要事先定義好調用方法的參數類型和個數,例如想經過 objc_msgSend 調用方法

- (int)methodWithFloat:(float)num1 withObj:(id)obj withBool:(BOOL)flag

那就須要定義一個這樣的c函數:

int (*new_msgSend)(id, SEL, float, id, BOOL) = (int (*)(id, SEL, float, id, BOOL)) objc_msgSend;

才能經過 new_msgSend 調用這個方法。而這個過程是沒法動態化的,須要編譯時肯定,而各類方法的參數/返回值類型不一樣,參數個數不一樣,是沒辦法在編譯時窮舉寫完的,因此不能用於全部方法的調用。

而對於可變參數方法,只支持參數類型和返回值類型都是 id 類型的方法,已經能夠知足大部分需求,因此讓使用它變得可能:

id (*new_msgSend1)(id, SEL, id,...) = (id (*)(id, SEL, id,...)) objc_msgSend;

這樣就能夠用 new_msgSend1 調用固定參數一個,後續是可變參數的方法了。實際上在模擬器這個方法也能夠支持固定參數是N個id的方法,也就是已經知足咱們調用可變參數方法的需求了,但根據@wjacker 和 @Awhisper 的測試,在真機上不行,不一樣的固定參數都須要給它定義好對應的函數才行,官網文檔對這點略有說明。因而,多了一大堆這樣的定義,以應付1-10個固定參數的狀況:

id (*new_msgSend2)(id, SEL, id,id,...) = (id (*)(id, SEL, id,id,...)) objc_msgSend;
id (*new_msgSend3)(id, SEL, id,id,id,...) = (id (*)(id, SEL, id,id,id,...)) objc_msgSend;
id (*new_msgSend4)(id, SEL, id,id,id,id,...) = (id (*)(id, SEL, id,id,id,id,...)) objc_msgSend;
...

2.調用

解決上述參數類型和個數定義問題後,還有調用的問題,objc_msgSend 不像 NSInvocation 能夠在運行時動態添加組裝傳入的參數個數,objc_msgSend 則須要在編譯時肯定傳入多少個參數。這對於1-10個參數的調用,不得不用 if else 寫10遍調用語句,另外根據方法定義的固定參數個數不同,還須要調用不一樣的 new_msgSend 函數,因此須要寫10!條調用,因而有了這樣的大長篇(gist代碼)。後來用宏格式化了一下,會好看一點。

defineProtocol

JSPatch 爲一個類新增本來 OC 不存在的方法時,全部的參數類型都會定義爲 id 類型,這樣實現是由於這種在 JS 裏新增的方法通常不會在 OC 上調用,而是在 JS 上用,JS 能夠認爲一切變量都是對象,沒有類型之分,因此所有定義爲 id 類型。

但在實際使用 JSPatch 過程當中,出現了這樣的需求:在 OC 裏 .h 文件定義了一個方法,這個方法裏的參數和返回值不都是 id 類型,可是在 .m 文件中因爲疏忽沒有實現這個方法,致使其餘地方調用這個方法時找不到這個方法形成 crash,要用 JSPatch 修復這樣的 bug,就須要 JSPatch 能夠動態添加指定參數類型的方法。

實際上若是在 JS 用 defineClass() 給類添加新方法時,經過某些接口把方法的各參數和返回值類型名傳進去,內部再作些處理就能夠解決上述問題,但這樣會把 defineClass 接口搞得很複雜,不但願這樣作。最終 @Awhisper 想出了個很好的方法,用動態新增 protocol 的方式支持。

首先 defineClass 是支持 protocol 的:

defineClass("JPViewController: UIViewController<UIScrollViewDelegate, UITextViewDelegate>", {})

這樣作的做用是,當添加 Protocol 裏定義的方法,而類裏沒有實現的方法時,參數類型再也不全是 id,而是會根據 Protocol 裏定義的參數類型去添加。

因而若想添加一些指定參數類型的方法,只需動態新增一個 protocol,定義新增的方法名和對應的參數類型,再在 defineClass 定義里加上這個 protocol 就能夠了。這樣的不污染 defineClass() 的接口,也沒有更多概念,十分簡潔地解決了這問題。範例:

defineProtocol('JPDemoProtocol',{
   stringWithRect_withNum_withArray: {
       paramsType:"CGRect, float, NSArray*",
       returnType:"id",
   },
}

defineClass('JPTestObject : NSObject <JPDemoProtocol>', {
    stringWithRect_withNum_withArray:function(rect, num, arr){
        //use rect/num/arr params here
        return @"success";
    },
}

具體實現原理原做者已寫得挺清楚,參見這裏

支持重寫dealloc方法

以前 JSPatch 不能替換 -dealloc 方法,緣由:

1.按以前的流程,JS 替換 -dealloc 方法後,調用到 -dealloc 時會把 self 包裝成 weakObject 傳給 JS,在包裝的時候就會出現如下 crash:

Cannot form weak reference to instance (0x7fb74ac26270) of class JPTestObject. It is possible that this object was over-released, or is in the process of deallocation.

意思是在 dealloc 過程當中對象不能賦給一個 weak 變量,沒法包裝成一個 weakObject 給 JS。

2.若在這裏不包裝當前調用對象,或不傳任何對象給 JS,就能夠成功執行到 JS 上替換的 dealloc 方法。但這時沒有調用原生 dealloc 方法,此對象不會釋放成功,會形成內存泄露。

-dealloc 被替換後,原 -dealloc 方法 IMP 對應的 selector 已經變成 ORIGdealloc,若在執行完 JS 的 dealloc 方法後再強制調用一遍原 OC 的 ORIGdealloc ,會crash。猜想緣由是 ARC 對 -dealloc 有特殊處理,執行它的 IMP(也就是真實函數)時傳進去的 selectorName 必須是 dealloc,runtime 才能夠調用它的 [super dealloc],作一些其餘處理。

到這裏我就沒什麼辦法了,後來 @ipinka 來了一招欺騙 ARC 的實現,解決了這個問題:

1.首先對與第一個問題,調用 -dealloc 時 self 不包裝成 weakObject,而是包裝成 assignObject 傳給 JS,解決了這個問題。

2.對於第二個問題,調用 ORIGdealloc 時由於 selectorName 改變,ARC 不認這是 dealloc 方法,因而用下面的方式調用:

Class instClass = object_getClass(assignSlf);
Method deallocMethod = class_getInstanceMethod(instClass, NSSelectorFromString(@"ORIGdealloc"));
void (*originalDealloc)(__unsafe_unretained id, SEL) = (__typeof__(originalDealloc))method_getImplementation(deallocMethod);
        originalDealloc(assignSlf, NSSelectorFromString(@"dealloc"));

作的事情就是,拿出 ORIGdealloc 的 IMP,也就是原 OC 上的 dealloc 實現,而後調用它時 selectorName 傳入 dealloc,這樣 ARC 就能認得這個方法是 dealloc,作相應處理了。

擴展

JPCleaner即時回退

有些 JSPatch 使用者有這樣的需求:腳本執行後但願能夠回退到沒有替換的狀態。以前個人建議使用者本身控制下次啓動時不要執行,就算回退了,但仍是有不重啓 APP 即時回退的需求。但這個需求並非核心功能,因此想辦法把它抽離,放到擴展裏了。

只需引入 JPCleaner.h,調用 +cleanAll 接口就能夠把當前全部被 JSPatch 替換的方法恢復原樣。另外還有 +cleanClass: 接口支持只回退某個類。這些接口能夠在 OC 調用,也能夠在 JS 腳本動態調用:

[JPCleaner cleanAll]
[JPCleaner cleanClass:@「JPViewController」];

實現原理也很簡單,在 JSPatch 核內心全部替換的方法都會保存在內部一個靜態變量 _JSOverideMethods 裏,它的結構是 _JSOverideMethods[cls][selectorName] = jsFunction。我給 JPExtension 添加了個接口,把這個靜態變量暴露給外部,遍歷這個變量裏保存的 class 和 selectorName,把 selector 對應的 IMP 從新指向原生 IMP 就能夠了。詳見源碼。

JPLoader

JSPatch 腳本須要後臺下發,客戶端須要一套打包下載/執行的流程,還須要考慮傳輸過程當中安全問題,JPLoader 就是幫你作了這些事情。

下載執行腳本很簡單,這裏主要作的事是保證傳輸過程的安全,JPLoader 包含了一個打包工具 packer.php,用這個工具對腳本文件進行打包,得出打包文件的 MD5,再對這個MD5 值用私鑰進行 RSA 加密,把加密後的數據跟腳本文件一塊兒大包發給客戶端。JPLoader 裏的程序對這個加密數據用私鑰進行解密,再計算一遍下發的腳本文件 MD5 值,看解密出來的值跟這邊計算出來的值是否一致,一致說明腳本文件從服務器到客戶端之間沒被第三方篡改過,保證腳本的安全。對這一過程的具體描述詳見舊文 JSPatch部署安全策略。對 JPLoader 的使用方式能夠參照 wiki 文檔

相關文章
相關標籤/搜索