JSPatch簡(jiǎn)介
JSPatch 是一個(gè)開(kāi)源項(xiàng)目(Github鏈接),只需要在項(xiàng)目里引入極小的引擎文件,就可以使用 JavaScript 調(diào)用任何 Objective-C 的原生接口,替換任意 Objective-C 原生方法。目前主要用于下發(fā) JS 腳本替換原生 Objective-C 代碼,實(shí)時(shí)修復(fù)線(xiàn)上 bug。已超過(guò) 3500 個(gè) App 在使用,成為 App 標(biāo)配功能。
基礎(chǔ)原理
1、Objective-C 方面
Objective-C語(yǔ)言是一門(mén)動(dòng)態(tài)語(yǔ)言,它將很多靜態(tài)語(yǔ)言在編譯和鏈接時(shí)期做的事放到了運(yùn)行時(shí)來(lái)處理。這種動(dòng)態(tài)語(yǔ)言的優(yōu)勢(shì)在于:代碼時(shí)更具靈活性,我們可以把消息轉(zhuǎn)發(fā)給我們想要的對(duì)象,或者隨意交換一個(gè)方法的實(shí)現(xiàn)等。這種特性意味著需要一個(gè)運(yùn)行時(shí)系統(tǒng)來(lái)執(zhí)行編譯的代碼,它讓所有的工作可以正常的運(yùn)行。這個(gè)運(yùn)行時(shí)系統(tǒng)即 Objc Runtime。Objc Runtime 其實(shí)是一個(gè)Runtime庫(kù),它基本上是用C和匯編寫(xiě)的,這個(gè)庫(kù)使得C語(yǔ)言有了面向?qū)ο蟮哪芰Α?br>
要理解 Runtime 庫(kù),首先要了解 Objective-C 類(lèi)與對(duì)象基礎(chǔ)數(shù)據(jù)結(jié)構(gòu)。類(lèi)是由 Class 類(lèi)型來(lái)表示的,它實(shí)際上是一個(gè)指向 objc_class 結(jié)構(gòu)體的指針,定義可在 objc/runtime.h 中看到:

在這個(gè)定義中,這里只關(guān)注3個(gè)字段
1、ivars :存放屬性鏈表,記錄類(lèi)實(shí)例的所有屬性定義。
2、methodLists :存放法樹(shù)鏈表,記錄類(lèi)實(shí)例的所有方法實(shí)現(xiàn)指針。
3、cache:用于緩存最近使用的方法。一個(gè)接收者對(duì)象接收到一個(gè)消息時(shí),它會(huì)根據(jù) isa 指針去查找能夠響應(yīng)這個(gè)消息的對(duì)象。在實(shí)際使用中,這個(gè)對(duì)象只有一部分方法是常用的,很多方法其實(shí)很少用或者根本用不上。這種情況下,如果每次消息來(lái)時(shí),我們都是 methodLists 中遍歷一遍,性能勢(shì)必很差。這時(shí) cache 就派上用場(chǎng)了。在我們每次調(diào)用過(guò)一個(gè)方法后,這個(gè)方法就會(huì)被緩存到 cache 列表中,下次調(diào)用的時(shí)候 Runtime 就會(huì)優(yōu)先去 cache 中查找,如果 cache 沒(méi)有命中,才去 methodLists 中查找方法。這樣大大提高了調(diào)用的效率。
同時(shí) objc/runtime.h 中還提供了大量的 API 來(lái)操作類(lèi)與對(duì)象。類(lèi)的操作方法大部分是以 class 為前綴的,而對(duì)象的操作方法大部分是以 objc 或 object_ 為前綴。這里我們只關(guān)注方法操作函數(shù),如下:

1、class_addMethod:如果本類(lèi)中包含一個(gè)同名的實(shí)現(xiàn),則函數(shù)會(huì)返回 NO。如果要修改已存在實(shí)現(xiàn),可以使用 method_setImplementation。
2、class_replaceMethod:該函數(shù)的行為可以分為兩種:如果類(lèi)中不存在 name 指定的方法,則類(lèi)似于 class_addMethod 函數(shù)一樣會(huì)添加方法;如果類(lèi)中已存在 name 指定的方法,則類(lèi)似于 method_setImplementation 一樣替代原方法的實(shí)現(xiàn)。
3、method_setImplementation:重置方法實(shí)現(xiàn)。
4、method_exchangeImplementations:交換方法實(shí)現(xiàn)。
在 Objective-C 中調(diào)用一個(gè)方法,其實(shí)是向一個(gè)對(duì)象發(fā)送消息,查找消息的唯一依據(jù)是 SEL 的名字。每個(gè)類(lèi)都有一個(gè)方法列表 methodLists ,存放著 SEL 的名字和 IMP 方法實(shí)現(xiàn)的映射關(guān)系如圖示。IMP 類(lèi)似函數(shù)指針,指向具體的 Method 實(shí)現(xiàn)。

利用 Runtime 可以實(shí)現(xiàn)在運(yùn)行時(shí)偷換 SEL 對(duì)應(yīng)的方法實(shí)現(xiàn) Method 或重置 IMP 方法實(shí)現(xiàn),達(dá)到 hook 的目的。

以上也就是大名鼎鼎的黑魔法原理(Method Swizzling),常見(jiàn)的用法

當(dāng)然 Object-C 還支持動(dòng)態(tài)創(chuàng)建對(duì)象,動(dòng)態(tài)添加方法,動(dòng)態(tài)添加屬性(Object-C 中當(dāng)類(lèi)注冊(cè)完成后無(wú)法動(dòng)態(tài)添加屬性,但可以用關(guān)聯(lián)方法來(lái)模擬屬性功能,屬性的本質(zhì)是 get 和 set 方法)

2、JavaScriptCore 方面
前端開(kāi)發(fā)的同學(xué)應(yīng)該知道,瀏覽器核心模塊主要是渲染引擎和 JavaScript 引擎兩部分組成。前者用于處理頁(yè)面布局,渲染及 DOM 結(jié)構(gòu)等,后者用于 JavaScript 的解析、執(zhí)行及 DOM 交互等。JavaScriptCore 是一種 JavaScript 引擎,主要為 webkit 提供腳本處理能力(其主要以 safari 瀏覽器為代表)。除此之外,還有著名的 Jscript(IE), SpiderMonkey(firefox)和V8(chrome)。它提供了以下主要功能:
1、Objective-C –> JavaScript (即在 Objective-C 語(yǔ)言環(huán)境里執(zhí)行 JavaScript 代碼段、方法,創(chuàng)建 JavaScript 變量及變量操作等等)執(zhí)行 JavaScript 代碼的方法:首先引入 JavaScriptCore.h,然后通過(guò) JSContext 創(chuàng)建 JS 運(yùn)行環(huán)境,再通過(guò) evaluateScript 來(lái)執(zhí)行結(jié)果

需要注意 Objective-C 和 JS 數(shù)據(jù)類(lèi)型之間的轉(zhuǎn)換表:

2、JavaScript –> Objective-C(即在 JavaScript 語(yǔ)言環(huán)境里調(diào)用 Objective-C 公開(kāi)給 JavaScript 的方法)。有 JSExport 協(xié)議和 Block 兩種方式

3、內(nèi)存管理和線(xiàn)程封裝(主要是需要注意引用和線(xiàn)程使用沖突)
當(dāng) JS 對(duì)象引用到 Object-C 對(duì)象(繼承了 JSExport 協(xié)議),而 Object-C 對(duì)象又引用到 JS 對(duì)象 時(shí)就會(huì)發(fā)生循環(huán)用(很少見(jiàn)的場(chǎng)景,即使真存在,也可以通過(guò)架構(gòu)設(shè)計(jì)的方式來(lái)避免)

這個(gè)時(shí)候就需要使用到 JSManagerValue 包裝一下

實(shí)際代碼如下:

至于線(xiàn)程沖突就涉及到 JSVirtualMachine 的理解:其實(shí)每一個(gè) JSVirtualMachine 都管理著一個(gè) JavaScript 虛擬機(jī)(JSContext 的載體),它運(yùn)行在 Object-C 中的一個(gè)獨(dú)立線(xiàn)程隊(duì)列,相同的 JSVirtualMachine 共用同一個(gè)線(xiàn)程隊(duì)列,不同的 JSVirtualMachine 當(dāng)然也就處于不同的線(xiàn)程隊(duì)列,它們之間無(wú)法進(jìn)行數(shù)據(jù)通訊,只能通過(guò) Object-C 來(lái)做通訊中轉(zhuǎn),所以會(huì)出現(xiàn)線(xiàn)程沖突問(wèn)題。理解了這個(gè)關(guān)鍵點(diǎn),解決沖突問(wèn)題就容易了。
通常初始化 JSContext 環(huán)境都會(huì)加載在一個(gè) JSVirtualMachine 虛擬機(jī),即使不指定 JSVirtualMachine 對(duì)象,也會(huì)默認(rèn)加載一個(gè),如下圖示:

3、Object-C 和 JavaScript 之間的橋接
JSPatch 中兩者之間交互依托前面的數(shù)據(jù)類(lèi)型轉(zhuǎn)換表,使用最簡(jiǎn)單的字符串傳遞方式交互信息達(dá)到動(dòng)態(tài)化的目的。一句話(huà)總結(jié):JS 傳遞字符串給 OC,OC 通過(guò) Runtime 接口調(diào)用和替換 OC 方法,這是最基礎(chǔ)的原理。如下圖示:

詳細(xì)的打怪漲經(jīng)驗(yàn)方式,還是去參考 bang 神的文檔,芝麻開(kāi)門(mén):
https://github.com/bang590/JSPatch/wiki/JSPatch-%E5%AE%9E%E7%8E%B0%E5%8E%9F%E7%90%86%E8%AF%A6%E8%A7%A3
服務(wù)端原理
JSPatch 需要使用者有一個(gè)后臺(tái)可以下發(fā)和管理腳本,并且需要處理傳輸安全等部署工作,JSPatch 平臺(tái)幫你做了這些事,提供了腳本后臺(tái)托管,版本管理,保證傳輸安全等功能,讓你無(wú)需搭建一個(gè)后臺(tái),無(wú)需關(guān)心部署操作。但還是需要了解一些服務(wù)端原理的。下載 JS 腳本只是簡(jiǎn)單的 get 請(qǐng)求,這里要研究的是其中傳輸安全,灰度下發(fā)和回滾機(jī)制。
1、安全機(jī)制
這里就直接引用 bang 神的原文,如下圖示:

1、服務(wù)端計(jì)算出腳本文件的 MD5 值,作為這個(gè)文件的數(shù)字簽名。
2、服務(wù)端通過(guò)私鑰加密第 1 步算出的 MD5 值,得到一個(gè)加密后的 MD5 值。
3、把腳本文件和加密后的 MD5 值一起下發(fā)給客戶(hù)端。
4、客戶(hù)端拿到加密后的 MD5 值,通過(guò)保存在客戶(hù)端的公鑰解密。
5、客戶(hù)端計(jì)算腳本文件的 MD5 值。
6、對(duì)比第 4/5 步的兩個(gè) MD5 值(分別是客戶(hù)端和服務(wù)端計(jì)算出來(lái)的 MD5 值),若相等則通過(guò)校驗(yàn)。
只要通過(guò)校驗(yàn),就能確保腳本在傳輸?shù)倪^(guò)程中沒(méi)有被篡改,因?yàn)榈谌饺粢鄹哪_本文件,必須計(jì)算出新的腳本文件 MD5 并用私鑰加密,客戶(hù)端公鑰才能解密出這個(gè) MD5 值,而在服務(wù)端未泄露的情況下第三方是拿不到私鑰的。
JSPatch 平臺(tái)是用 PHP 實(shí)現(xiàn)的,這里筆者用 Node.js 仿照流程來(lái)模擬基礎(chǔ)實(shí)現(xiàn)原理。

運(yùn)行效果如下:

這里為了看效果并沒(méi)有對(duì) data 進(jìn)行加密,實(shí)際生產(chǎn)環(huán)境中使用時(shí),腳本需要進(jìn)行版本管理,提交 PR-Review,通過(guò)以后才能通過(guò)服務(wù)獲取,而腳本內(nèi)容也是需要進(jìn)行 RSA 加密的,安全第一嘛。再配合上蘋(píng)果的 ATS 要求所有APP域名都支持HTTPS傳輸(此要求 delay 了),至此已經(jīng)實(shí)現(xiàn)了安全傳輸機(jī)制。
2、灰度下發(fā)機(jī)制
灰度下發(fā)涉及到數(shù)據(jù)上傳,分析等,甚至有的公司都已經(jīng)做到了大數(shù)據(jù)挖掘的程度,JSPatch 平臺(tái)支持按用戶(hù)數(shù)量、按條件(常被用來(lái)做新功能發(fā)布或線(xiàn)上調(diào)試)灰度下發(fā),其中按條件還支持后臺(tái)動(dòng)態(tài)配置條件,功能很強(qiáng)大。詳細(xì)的可以參考 http://www.jspatch.com/Docs/rule
3、回滾機(jī)制
這部分 JSPatch 平臺(tái)并沒(méi)有詳細(xì)說(shuō)明,但目前實(shí)踐中大部分都是簡(jiǎn)單粗暴地重傳:即已下發(fā)腳本出 bug 了,就再出一個(gè) fixed patch,重新下發(fā)。但這種方式對(duì)于日活千萬(wàn)上億的 APP,是不能容忍的。這里可以提供一種使用基于 git 版本開(kāi)源項(xiàng)目管理的方式:每一次腳本下發(fā)都提交 PR-Review,Review 通過(guò)以后 merger 再下發(fā),一旦出錯(cuò)直接 git revert。Native 端緩存最新版本和上一個(gè)版本的 patch 補(bǔ)丁,共兩份,當(dāng)檢測(cè)到回滾發(fā)生(服務(wù)端下發(fā)的版本標(biāo)識(shí)小于 Native 端版本)時(shí),把上一個(gè)版本的 patch 標(biāo)識(shí)為最新,出錯(cuò)的 patch 標(biāo)識(shí)為歷史版本,完成回滾操作。
后記
使用 JSPatch 已有半年多時(shí)間了,從中收獲到很多,也踩過(guò)不少坑,比如:無(wú)法替換 main 函數(shù)之前執(zhí)行的類(lèi)方法 +(void)load; +(void)initialize; 等,無(wú)法調(diào)用被 hook 住的源方法,但都一一趟過(guò)了,總的來(lái)說(shuō)還是一個(gè)非常強(qiáng)大商業(yè)化工具。有感興趣的小伙伴還是強(qiáng)烈推薦多多閱讀 bang 神的 Wiki,傳送門(mén):https://github.com/bang590/JSPatch/wiki
希望本文能對(duì)準(zhǔn)備接入或者學(xué)習(xí) JSPatch 的開(kāi)發(fā)人員有所幫助。