一、背景需求介紹
為什么我們需要一個(gè)熱修復(fù)(hot-fix)技術(shù)?
- 工作中容易犯錯(cuò)、bug難以避免。
- 開(kāi)發(fā)和測(cè)試人力有限。
- 蘋(píng)果Appstore審核周期太長(zhǎng),一旦出現(xiàn)嚴(yán)重bug難以快速上線新版本。
- 作為生產(chǎn)力工具,用戶有對(duì)穩(wěn)定性和可靠性的需求。
二、JSPatch簡(jiǎn)介
JSPatch誕生于2015年5月,最初是騰訊廣研高級(jí)iOS開(kāi)發(fā)@bang的個(gè)人項(xiàng)目。 它能夠使用JavaScript調(diào)用Objective-C的原生接口,從而動(dòng)態(tài)植入代碼來(lái)替換舊代碼,以實(shí)現(xiàn)修復(fù)線上bug。 JSPatch在Github.com上開(kāi)源后獲得了3000多個(gè)star和500多fork,廣受關(guān)注,目前已被應(yīng)用在大量騰訊/阿里/百度的App中。
三、JSPatch與wax對(duì)比
 JSPatch與Wax對(duì)比
最關(guān)鍵的是JSPatch可實(shí)現(xiàn)方法粒度的線上代碼替換,能修復(fù)一切代碼引起的Bug。 而Wax無(wú)法實(shí)現(xiàn)。
四、JSPatch實(shí)現(xiàn)原理
基礎(chǔ)原理
Objective-C是動(dòng)態(tài)語(yǔ)言,具有運(yùn)行時(shí)特性,該特性可通過(guò)類名稱和方法名的字符串獲取該類和該方法,并實(shí)例化和調(diào)用。
Class class = NSClassFromString(“UIViewController");
id viewController = [[class alloc] init];
SEL selector = NSSelectorFromString(“viewDidLoad");
[viewController performSelector:selector];
也可以替換某個(gè)類的方法為新的實(shí)現(xiàn):
static void newViewDidLoad(id slf, SEL sel) {}
class_replaceMethod(class, selector, newViewDidLoad, @"");
還可以新注冊(cè)一個(gè)類,為類添加方法:
Class cls = objc_allocateClassPair(superCls, "JPObject", 0);
objc_registerClassPair(cls);
class_addMethod(cls, selector, implement, typedesc);
Javascript調(diào)用
我們可以用Javascript對(duì)象定義一個(gè)Objective-C類:
{
__isCls: 1,
__clsName: "UIView"
}
在OC執(zhí)行JS腳本前,通過(guò)正則把所有方法調(diào)用都改成調(diào)用 __c() 函數(shù),再執(zhí)行這個(gè)JS腳本,做到了類似OC/Lua/Ruby等的消息轉(zhuǎn)發(fā)機(jī)制:
UIView.alloc().init()
->
UIView.__c('alloc')().__c('init')()
給JS對(duì)象基類 Object 的 prototype 加上 c 成員,這樣所有對(duì)象都可以調(diào)用到 c,根據(jù)當(dāng)前對(duì)象類型判斷進(jìn)行不同操作:
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)
}
}
互傳消息
JS和OC是通過(guò)JavaScriptCore互傳消息的。OC端在啟動(dòng)JSPatch引擎時(shí)會(huì)創(chuàng)建一個(gè) JSContext 實(shí)例,JSContext 是JS代碼的執(zhí)行環(huán)境,可以給 JSContext 添加方法。JS通過(guò)調(diào)用 JSContext 定義的方法把數(shù)據(jù)傳給OC,OC通過(guò)返回值傳會(huì)給JS。調(diào)用這種方法,它的參數(shù)/返回值 JavaScriptCore 都會(huì)自動(dòng)轉(zhuǎn)換,OC里的 NSArray, NSDictionary, NSString, NSNumber, NSBlock 會(huì)分別轉(zhuǎn)為JS端的數(shù)組/對(duì)象/字符串/數(shù)字/函數(shù)類型。 對(duì)于一個(gè)自定義id對(duì)象,JavaScriptCore 會(huì)把這個(gè)自定義對(duì)象的指針傳給JS,這個(gè)對(duì)象在JS無(wú)法使用,但在回傳給OC時(shí)OC可以找到這個(gè)對(duì)象。對(duì)于這個(gè)對(duì)象生命周期的管理,如果JS有變量引用時(shí),這個(gè)OC對(duì)象引用計(jì)數(shù)就加1 ,JS變量的引用釋放了就減1,如果OC上沒(méi)別的持有者,這個(gè)OC對(duì)象的生命周期就跟著JS走了,會(huì)在JS進(jìn)行垃圾回收時(shí)釋放。
方法替換
-
把UIViewController的 -viewWillAppear: 方法通過(guò) class_replaceMethod() 接口指向 _objc_msgForward,這是一個(gè)全局 IMP,OC 調(diào)用方法不存在時(shí)都會(huì)轉(zhuǎn)發(fā)到這個(gè) IMP 上,這里直接把方法替換成這個(gè) IMP,這樣調(diào)用這個(gè)方法時(shí)就會(huì)走到 -forwardInvocation:。
-
為UIViewController添加 -ORIGviewWillAppear: 和 -_JPviewWillAppear: 兩個(gè)方法,前者指向原來(lái)的IMP實(shí)現(xiàn),后者是新的實(shí)現(xiàn),稍后會(huì)在這個(gè)實(shí)現(xiàn)里回調(diào)JS函數(shù)。
-
改寫(xiě)UIViewController的 -forwardInvocation: 方法為自定義實(shí)現(xiàn)。一旦OC里調(diào)用 UIViewController 的 -viewWillAppear: 方法,經(jīng)過(guò)上面的處理會(huì)把這個(gè)調(diào)用轉(zhuǎn)發(fā)到 -forwardInvocation: ,這時(shí)已經(jīng)組裝好了一個(gè) NSInvocation,包含了這個(gè)調(diào)用的參數(shù)。在這里把參數(shù)從 NSInvocation 反解出來(lái),帶著參數(shù)調(diào)用上述新增加的方法 -JPviewWillAppear: ,在這個(gè)新方法里取到參數(shù)傳給JS,調(diào)用JS的實(shí)現(xiàn)函數(shù)。整個(gè)調(diào)用過(guò)程就結(jié)束了,整個(gè)過(guò)程圖示如下:
 JSPatch方法替換
最后一個(gè)問(wèn)題,我們把 UIViewController 的 -forwardInvocation: 方法的實(shí)現(xiàn)給替換掉了,如果程序里真有用到這個(gè)方法對(duì)消息進(jìn)行轉(zhuǎn)發(fā),原來(lái)的邏輯怎么辦?首先我們?cè)谔鎿Q -forwardInvocation: 方法前會(huì)新建一個(gè)方法 -ORIGforwardInvocation:,保存原來(lái)的實(shí)現(xiàn)IMP,在新的 -forwardInvocation: 實(shí)現(xiàn)里做了個(gè)判斷,如果轉(zhuǎn)發(fā)的方法是我們想改寫(xiě)的,就走我們的邏輯,若不是,就調(diào) -ORIGforwardInvocation: 走原來(lái)的流程。
五、JSPatch代碼示例
JSPatch在OC上的調(diào)用十分簡(jiǎn)單
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
[JPEngine startEngine];
NSString *sourcePath = [[NSBundle mainBundle] pathForResource:@"demo" ofType:@"js"];
NSString *script = [NSString stringWithContentsOfFile:sourcePath encoding:NSUTF8StringEncoding error:nil];
[JPEngine evaluateScript:script];
}
一個(gè)Javascript代碼修復(fù)Objective-C的bug的示例:
@implementation JPTableViewController
- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath
{
NSString *content = self.dataSource[[indexPath row]]; //可能會(huì)超出數(shù)組范圍導(dǎo)致crash
JPViewController *ctrl = [[JPViewController alloc] initWithContent:content];
[self.navigationController pushViewController:ctrl];
}
@end
上述代碼中取數(shù)組元素處可能會(huì)超出數(shù)組范圍導(dǎo)致crash。如果在項(xiàng)目里引用了JSPatch,就可以下發(fā)JS腳本修復(fù)這個(gè)bug:
defineClass("JPTableViewController", {
tableView_didSelectRowAtIndexPath: function(tableView, indexPath) {
var row = indexPath.row()
if (self.dataSource().length > row) { //加上判斷越界的邏輯
var content = self.dataArr()[row];
var ctrl = JPViewController.alloc().initWithContent(content);
self.navigationController().pushViewController(ctrl);
}
}
}, {})
六、股單App的Hot-fix解決方案
1.版本更新策略
- 考慮到下一個(gè)提交的App版本已經(jīng)修復(fù)了上一個(gè)版本的bug,所以不同的App版本對(duì)應(yīng)的補(bǔ)丁版本肯定也不同。同一個(gè)App版本下,可以出現(xiàn)遞增的補(bǔ)丁版本。
- 補(bǔ)丁為全量更新,即新版本補(bǔ)丁包括舊版補(bǔ)丁的內(nèi)容,更新后新版補(bǔ)丁覆蓋舊版補(bǔ)丁。
- 補(bǔ)丁分為可選補(bǔ)丁和必選補(bǔ)丁,必選補(bǔ)丁用于重大bug的修復(fù),如果不更新必選補(bǔ)丁則App無(wú)法繼續(xù)使用。如下圖2中,補(bǔ)丁版本v1234對(duì)應(yīng)各自版本的用戶,補(bǔ)丁v3為必須更新,補(bǔ)丁v1,v2,v4為可選補(bǔ)丁,則v1,v2的用戶必須更新到v4才可使用;而v3的用戶可先使用,同時(shí)后臺(tái)靜默更新到v4.
 股單App補(bǔ)丁版本更新策略
2.安全策略
安全問(wèn)題在于JS 腳本可能被中間人攻擊替換代碼??刹扇∫韵氯N方法,股單App目前采用的是第三種:
1.對(duì)稱加密。如zip 的加密壓縮、AES 等加密算法。優(yōu)點(diǎn)是簡(jiǎn)單,缺點(diǎn)是安全性低,易破解。若客戶端被反編譯,密碼字段泄露,則完成破解。 2.HTTPS。優(yōu)點(diǎn)是安全性高,證書(shū)在服務(wù)端未泄露,就不會(huì)被破解。缺點(diǎn)是部署麻煩,如果服務(wù)器本來(lái)就支持 HTTPS,使用這種方案也是一種不錯(cuò)的選擇。 3.RSA校驗(yàn)。安全性高,部署簡(jiǎn)單。
 RSA校驗(yàn)
詳細(xì)校驗(yàn)步驟如下: 1.服務(wù)端計(jì)算出腳本文件的 MD5 值,作為這個(gè)文件的數(shù)字簽名。 2.服務(wù)端通過(guò)私鑰加密第 1 步算出的 MD5 值,得到一個(gè)加密后的 MD5 值。 3.把腳本文件和加密后的 MD5 值一起下發(fā)給客戶端。 4.客戶端拿到加密后的 MD5 值,通過(guò)保存在客戶端的公鑰解密。 5.客戶端計(jì)算腳本文件的 MD5 值。 6.對(duì)比第 4/5 步的兩個(gè) MD5 值(分別是客戶端和服務(wù)端計(jì)算出來(lái)的 MD5 值),若相等則通過(guò)校驗(yàn)。
3.客戶端策略
客戶端具體策略如下圖: 1.用戶打開(kāi)App時(shí),同步進(jìn)行本地補(bǔ)丁的加載。 2.用戶打開(kāi)App時(shí),后臺(tái)進(jìn)程發(fā)起異步網(wǎng)絡(luò)請(qǐng)求,獲取服務(wù)器中當(dāng)前App版本所對(duì)應(yīng)的最新補(bǔ)丁版本和必須的補(bǔ)丁版本。 3.獲取補(bǔ)丁版本的請(qǐng)求回來(lái)后,跟本地的補(bǔ)丁版本進(jìn)行對(duì)比。 4.如果本地補(bǔ)丁版本小于必須版本,則提示用戶,展示下載補(bǔ)丁界面,進(jìn)行進(jìn)程同步的補(bǔ)丁下載。下載完成后重新加載App和最新補(bǔ)丁,再進(jìn)入App。 5.如果本地補(bǔ)丁版本不小于必須版本,但小于最新版本,則進(jìn)入App,不影響用戶操作。同時(shí)進(jìn)行后臺(tái)進(jìn)程異步靜默下載,下載后補(bǔ)丁保存在本地。下次App啟動(dòng)時(shí)再加載最新補(bǔ)丁。 6.如果版本為最新,則進(jìn)入App。
 股單App客戶端hot-fix策略
七、參考資料和文獻(xiàn):
1.https://github.com/bang590/JSPatch 2.https://github.com/mmin18/WaxPatch 3.https://github.com/probablycorey/wax 4.https://github.com/alibaba/AndFix 5.http://blog./tech/2879/ 6.http://blog./works/2767/ 7.http://blog./tech/2808/ 8.http://blog./2015/11/20/HotPatchCompare/
|