IOS熱更新-JSPatch實現原理+Patch現場恢復


關於HotfixPatch

在IOS開發領域,由於Apple嚴格的審核標准和低效率,IOS應用的發版速度極慢,稍微大型的app發版基本上都在一個月以上,所以代碼熱更新(HotfixPatch)對於IOS應用來說就顯得尤其重要。

現在業內基本上都在使用WaxPatch方案,由於Wax框架已經停止維護四五年了,所以waxPatch在使用過程中還是存在不少坑(比如參數轉化過程中的問題,如果繼承類沒有實例化修改繼承類的方法無效, wax_gc中對oc中instance的持有延遲釋放...)。另外蘋果對於Wax使用的態度也處於模糊狀態,這也是一個潛在的使用風險。

隨着FaceBook開源React Native框架,利用JavaScriptCore.framework直接建立JavaScript(JS)和Objective-C(OC)之間的bridge成為可能,JSPatch也在這個時候應運而生。最開始是從唐巧的微信公眾號推送上了解到,開始還以為是在React Native的基礎上進行的封裝,不過最近仔細研究了源代碼,跟React Native半毛錢關系都沒有,這里先對JSPatch的作者(不是唐巧,是Bang,博客地址)贊一個。

深入了解JSPatch之后,第一感覺是這個方案小巧,易懂,維護成本低,直接通過OC代碼去調用runtime的API,作為一個IOS開發者,很快就能看明白,不用花大精力去了解學習lua。另外在建立JS和OC的Bridge時,作者很巧妙的利用JS和OC兩種語言的消息轉發機制做了很優雅的實現,稍顯不足的是JSPatch只能支持ios7及以上。

由於現在公司的部分應用還在支持ios6,完全取代Wax也不現實,但是一些新上應用已經直接開始支持ios7。個人覺得ios6和ios7的界面風格差別較大,相信應用最低支持版本會很快升級到ios7. 還考慮到JSPatch的成熟度不夠,所以決定把JSPatch和WaxPatch結合在一起,相互補充進行使用。下面給大家說一些學習使用體會。

JSPatch和WaxPatch對比

關於JSPatch對比WaxPatch的優勢,下面摘抄一下JSPatch作者的話:

方案對比

目前已經有一些方案可以實現動態打補丁,例如WaxPatch,可以用Lua調用OC方法,相對於WaxPatch,JSPatch的優勢:

  • 1.JS語言: JS比Lua在應用開發領域有更廣泛的應用,目前前端開發和終端開發有融合的趨勢,作為擴展的腳本語言,JS是不二之選。

  • 2.符合Apple規則: JSPatch更符合Apple的規則。iOS Developer Program License Agreement里3.3.2提到不可動態下發可執行代碼,但通過蘋果JavaScriptCore.framework或WebKit執行的代碼除外,JS正是通過JavaScriptCore.framework執行的。

  • 3.小巧: 使用系統內置的JavaScriptCore.framework,無需內嵌腳本引擎,體積小巧。

  • 4.支持block: wax在幾年前就停止了開發和維護,不支持Objective-C里block跟Lua程序的互傳,雖然一些第三方已經實現block,但使用時參數上也有比較多的限制。

JSPatch的劣勢:

  • 相對於WaxPatch,JSPatch劣勢在於不支持iOS6,因為需要引入JavaScriptCore.framework。另外目前內存的使用上會高於wax,持續改進中。

JSPatch的實現原理理解

JSPatch的實現原理作者的博文已經很詳細的介紹了,我這里就不多說了,貼一下學習之處:

看實現原理詳解的時候對照着源碼看,比較好理解,我在這里說一下我對JSPatch的學習和理解:

(1)OC的動態語言特性

不管是WaxPatch框架還是JSPatch的方案,其根本原理都是利用OC的動態語言特性去動態修改類的方法實現。
OC的動態語言特性是在runtime system(全部用C實現,Apple維護了一份開源代碼)上實現的,面向對象的Class和instance機制都是基於消息機制。我們平時認為的[object method],正確的理解應該是[receiver sendMsg], 所有的消息發送會在編譯階段編譯為runtime c函數的調用:_obj_sendMsg(id, SEL).

詳細介紹參考博文:

runtime提供了一些運行時的API

  • 反射類和選擇器
    Class class = NSClassFromString("UIViewController"); SEL selector = NSSelectorFromString("viewDidLoad");
  • 為某個類新增或者替換方法選擇器(SEL)的實現(IMP)
    BOOL class_addMethod(Class cls, SEL name, IMP imp, const char *types); IMP class_replaceMethod(Class cls, SEL name, IMP imp, const char *types);
  • 在runtime中動態注冊類
    Class superCls = NSClassFromString(superClassName); cls = objc_allocateClassPair(superCls, className.UTF8String, 0); objc_registerClassPair(cls);

(2)JS如何調用OC

在JS運行環境中,需要解決兩個問題,一個是OC類對象(objc_class)的獲取,另一個就是使用對象提供的接口方法。

對於第一個問題,JSPatch在實現中是通過Require調用在JS環境下創建一個class同名對象(js形式),當向OC發送alloc接收消息之后,會將OC環境中創建的對象地址保存到這個這個js同名對象中,js本身並不完成任何對象的初始化。關於JS持有OC對象的引用,其回收的解釋在JSPatch作者的博文中有介紹,沒有具體測試。詳見JSPatch.js代碼:

    //請求OC類對象 UIView = require("UIView"); //緩存JS class同名對象 var _require = function(clsName) { if (!global[clsName]) { global[clsName] = { __isCls: 1, __clsName: clsName } } return global[clsName] } //調用class方法,返回OC實例化對象進行封裝 var ret = instance ? _OC_callI(instance, selectorName, args, isSuper): _OC_callC(clsName, selectorName, args) //OC創建后返回對象 return@{@"__clsName": NSStringFromClass([obj class]), @"__obj": obj}; //JS中解析OC對象 return _formatOCToJS(ret) //_formatOCToJS if (obj instanceof Object) { var ret = {} for (var key in obj) { ret[key] = _formatOCToJS(obj[key]) } return ret }

對於第二個問題,JSPatch在JS環境中通過中心轉發方式,所有OC方法的調用均是通過新增Object(js)原型方法_c(methodName)完成調用,在通過JavaScriptCore執行JS腳本之前,先將所有的方法調用字符替換
_c('method')的方式; 在_c函數中通過JSContex建立的橋接函數傳入參數和返回參數即完成了調用;

    //字符替換 static NSString *_regexStr = @"\\.\\s*(\\w+)\\s*\\("; static NSString *_replaceStr = @".__c(\"$1\")("; NSString *formatedScript = [NSString stringWithFormat:@"try{@}catch(e){_OC_catch(e.message, e.stack)}", [_regex stringByReplacingMatchesInString:script options:0 range:NSMakeRange(0, script.length) withTemplate:_replaceStr]]; //__c()向OC轉發調用參數 Object.prototype.__c = function(methodName) { ... return function(){ var args = Array.prototype.slice.call(arguments) return _methodFunc(self.__obj, self.__clsName, methodName, args, self.__isSuper) } } //_methodFunc調用橋接函數 var _methodFunc = function(instance, clsName, methodName, args, isSuper) { ... var ret = instance ? _OC_callI(instance, selectorName, args, isSuper): _OC_callC(clsName, selectorName, args) return _formatOCToJS(ret) } //OC中的橋接函數,JS和OC的橋接函數都是通過這樣定義 context[@"_OC_callI"] = ^id(JSValue *obj, NSString *selectorName, JSValue *arguments, BOOL isSuper) { return callSelector(nil, selectorName, arguments, obj, isSuper); }; context[@"_OC_callC"] = ^id(NSString *className, NSString *selectorName, JSValue *arguments) { return callSelector(className, selectorName, arguments, nil, NO); };

(3)JS如何替換OC方法

JSPatch的主要作用還是通過腳本修復一些線上bug,希望能夠達到替換OC方法的目標。JSPatch的實現巧妙之處在於:利用了OC的消息轉發機制

  • 1:替換原有selector的IMP實現為一個空的IMP實現,這樣當objc_class接受到消息之后,就會進行消息轉發, 另外需要將selector的初始實現進行保存;
    //selector指向空實現 IMP msgForwardIMP = getEmptyMsgForwardIMP(typeDescription, methodSignature); class_replaceMethod(cls, selector, msgForwardIMP, typeDescription); //保存原有實現,這里進行了修改,增加了恢復現場的支持 NSString *originalSelectorName = [NSString stringWithFormat:@"ORIG@", selectorName]; SEL originalSelector = NSSelectorFromString(originalSelectorName); if(class_respondsToSelector(cls, selector)) { if(!class_respondsToSelector(cls, originalSelector)){ class_addMethod(cls, originalSelector, originalImp, typeDescription); } else { class_replaceMethod(cls, originalSelector, originalImp, typeDescription); } }
  • 2:將替換的JS方法構造一個JPSelector及其IMP實現(根據返回參數構造),添加到當前class中,並通過cls+selecotr全局緩存JS方法(全局緩存並沒有多大用途,但是對於后面恢復現場比較有用);
    if (!_JSOverideMethods[clsName][JPSelectorName]) { _initJPOverideMethods(clsName); _JSOverideMethods[clsName][JPSelectorName] = function; const char *returnType = [methodSignature methodReturnType]; IMP JPImplementation = NULL; //根據返回類型構造 switch (returnType[0]){ ... } if(!class_respondsToSelector(cls, JPSelector)){ class_addMethod(cls, JPSelector, JPImplementation, typeDescription); } else { class_replaceMethod(cls, JPSelector, JPImplementation,typeDescription); } }
  • 3:然后改寫每個替換方法類的forwadInvocation的實現進行攔截,如果攔截到的Invocation的selctor轉化成JPSelector能夠響應,說明是一個替換方法,則從Invocation中取參數后調用JPSelector的IMP;
    static void JPForwardInvocation(id slf, SEL selector, NSInvocation *invocation) { NSMethodSignature *methodSignature = [invocation methodSignature]; NSInteger numberOfArguments = [methodSignature numberOfArguments]; NSString *selectorName = NSStringFromSelector(invocation.selector); NSString *JPSelectorName = [NSString stringWithFormat:@"_JP@", selectorName]; SEL JPSelector = NSSelectorFromString(JPSelectorName); if (!class_respondsToSelector(object_getClass(slf), JPSelector)) { ... } NSMutableArray *argList = [[NSMutableArray alloc] init]; [argList addObject:slf]; for (NSUInteger i = 2; i < numberOfArguments; i++) { ... } //獲取參數之后invoke JPSector調用JSFunction的實現 @synchronized(_context) { _TMPInvocationArguments = formatOCToJSList(argList); [invocation setSelector:JPSelector]; [invocation invoke]; _TMPInvocationArguments = nil; } }

Patch現場復原的補充

Patch現場恢復的功能主要用於連續更新腳本的應用場景。由於IOS的App應用按Home鍵或者被電話中斷的時候,應用實際上是首先進入到后台運行階段(applicationWillResignActive),當我們下次再次使用App的時候,如果后台應用沒有被終止(applicationWillTerminate),那么App不會走appliation:didFinishLaunchingWithOptions方法,而是會走(applicationWillEnterForeground)。 對於這種場景如果我們連續更新線上腳本,那么第二次腳本更新則無法保留最開始的方法實現,另外恢復現場功能也有助於我們撤銷線上腳本能夠恢復應用的本身代碼功能。

JSPatch的現場恢復

本文在JSPatch基礎上添加了現場恢復功能;源碼地址參考:

說明如下:

(1)在JPEngine.h 中添加了兩個啟動和結束的調用函數如下:

    void js_start(NSString* initScript); void js_end();

(2) JPEngine.m 中調用函數的實現以及恢復現場對部分代碼的修改:主要是利用了替換方法和新增方法的cache(_JSOverideMethods, 主要是這個)

    //處理替換方法,selector指回最初的IMP,JPSelector和ORIGSelector都指向未實現IMP if([JPSelectorName hasPrefix:@"_JP"]){ if (class_getMethodImplementation(cls, @selector(forwardInvocation:)) == (IMP)JPForwardInvocation) { SEL ORIGforwardSelector = @selector(ORIGforwardInvocation:); IMP ORIGforwardImp = class_getMethodImplementation(cls, ORIGforwardSelector); class_replaceMethod(cls, @selector(forwardInvocation:), ORIGforwardImp, "v@:@"); class_replaceMethod(cls, ORIGforwardSelector, _objc_msgForward, "v@:@"); } NSString *selectorName = [JPSelectorName stringByReplacingOccurrencesOfString:@"_JP" withString:@""]; NSString *ORIGSelectorName = [JPSelectorName stringByReplacingOccurrencesOfString:@"_JP" withString:@"ORIG"]; SEL JPSelector = NSSelectorFromString(JPSelectorName); SEL selector = NSSelectorFromString(selectorName); SEL ORIGSelector = NSSelectorFromString(ORIGSelectorName); if(class_respondsToSelector(cls, ORIGSelector) && class_respondsToSelector(cls, selector) && class_respondsToSelector(cls, JPSelector)){ NSMethodSignature *methodSignature = [cls instanceMethodSignatureForSelector:ORIGSelector]; Method method = class_getInstanceMethod(cls, ORIGSelector); char *typeDescription = (char *)method_getTypeEncoding(method); IMP forwardEmptyIMP = getEmptyMsgForwardIMP(typeDescription, methodSignature); IMP ORIGSelectorImp = class_getMethodImplementation(cls, ORIGSelector); class_replaceMethod(cls, selector, ORIGSelectorImp, typeDescription); class_replaceMethod(cls, JPSelector, forwardEmptyIMP, typeDescription); class_replaceMethod(cls, ORIGSelector, forwardEmptyIMP, typeDescription); } } //處理添加的新方法 else { isClsNew = YES; SEL JPSelector = NSSelectorFromString(JPSelectorName); if(class_respondsToSelector(cls, JPSelector)){ NSMethodSignature *methodSignature = [cls instanceMethodSignatureForSelector:JPSelector]; Method method = class_getInstanceMethod(cls, JPSelector); char *typeDescription = (char *)method_getTypeEncoding(method); IMP forwardEmptyIMP = getEmptyMsgForwardIMP(typeDescription, methodSignature); class_replaceMethod(cls, JPSelector, forwardEmptyIMP, typeDescription); } }

HotfixPatch的那些坑

WaxPatch之前被一些同事抱怨有不少坑,JSPatch在使用過程中也會遇到不少坑,所以雖然這兩個框架現在雖然都能夠做到新增可執行代碼,但是將其應用到開發功能組件還不太可取。

比如說我在第一次使用JSPatch遇到了一個坑:(后面想單寫一個博客收集一下我們團隊使用Patch遇到的坑~~)

  • 在JS腳本改寫派生類中未實現的繼承類的 optional protocol方法時,tableView reload的時候不會調用JS的補丁方法,但是在tableView中顯式調用可以調用替換的selector方法;另外如果在派生類中重寫這個protocol方法,則可以調起;

  • ...

先寫這么多了,本來想寫一下我們的patch管理方案,覺得沒有什么可說了,就不寫了~

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM