JSPatch近期新特性解析

bang590發表於2016-03-15

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

performSelectorInOC

JavaScript 語言是單執行緒的,在 OC 使用 JavaScriptCore 引擎執行 JS 程式碼時,會對 JS 程式碼塊加鎖,保證同個 JSContext 下的 JS 程式碼都是順序執行。所以呼叫 JSPatch 替換的方法,以及在 JSPatch 裡呼叫 OC 方法,都會在這個鎖裡執行,這導致三個問題:

  • JSPatch替換的方法無法並行執行,如果如果主執行緒和子執行緒同時執行了 JSPatch 替換的方法,這些方法的執行都會順序排隊,主執行緒會等待子執行緒的方法執行完後再執行,如果子執行緒方法耗時長,主執行緒會等很久,卡住主執行緒。

  • 某種情況下,JavaScriptCore 的鎖與 OC 程式碼上的鎖混合時,會產生死鎖。

  • UIWebView 的初始化會與 JavaScriptCore 衝突。若在 JavaScriptCore 的鎖裡(第一次)初始化 UIWebView 會導致 webview 無法解析頁面。

為解決這些問題,JSPatch 新增了 .performSelectorInOC(selector, arguments, callback) 介面,可以在執行 OC 方法時脫離 JavaScriptCore 的鎖,同時又保證程式順序執行。

舉個例子:

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 的才解鎖,結束本次呼叫。

第二份程式碼對應右邊的流程圖,前面是一樣的,呼叫 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 文件

相關文章