最近主要在研究 iOS 中的 JS 這一塊內(nèi)容坦袍,本文打算從 為什么不能單純地搞前端十厢、JSCore 的原理和通信機制、OC 底層 Runtime 原理捂齐、如何通過 JS 任意修改 iOS 的運行結(jié)果 這 4 部分來闡述蛮放,旨在讓前端和 iOS 開發(fā)同學(xué)更加了解跨端開發(fā)的原理,同時了解他倆結(jié)合起來做哪些意想不到的事情奠宜。
為什么不能單純地搞前端包颁?
畢業(yè)工作以來,經(jīng)歷過移動端 H5 React 到 RN 開發(fā)压真,到去年的 Weex 開發(fā)和最近的 iOS 開發(fā)娩嚼,越來越發(fā)現(xiàn):
僅靠前端技術(shù)難以滿足移動端的用戶需求或體驗要求
為什么只弄前端會效果不好?
可能 H5 同學(xué)很有感觸滴肿,比如需要做一個端上 H5 照片上傳功能岳悟,通過 JS 去實現(xiàn)往往效果會大打折扣,也很難達到業(yè)務(wù)方需要的順滑體驗嘴高,要是此時 Native 同學(xué)說我寫好了一個 Bridge竿音,只需在客戶端里用 JS 調(diào)用Bridge.uploadImg()
這個方法就可直接用 Native 的上傳功能,聽到這句話你肯定會長舒一口氣輕松地去寫代碼了拴驮。
還有將端上 Webview 由 UIWebView 更換成 WKWebView,起到的效果也會比自己優(yōu)化很久的 H5 順滑滾動來得快和好柴信。
那么潮流前端同學(xué)一般怎么弄移動端需求呢套啤?
他們一般會借 Native 端的能力,譬如用 Weex 或者 RN 來開發(fā)頁面,讓其也有 Native 效果潜沦,假如有頁面在微信或者支付寶萄涯,還可以升級成小程序變成內(nèi)置程序一樣,客戶端中還可以借助端上容器優(yōu)化這一塊唆鸡,讓其可以離線我們的 H5 頁面涝影,并通過橋提供很多 Native 功能來拓展能力。
這里借力的的橋梁其實就是 Bridge争占,讓兩者不在是一個孤島燃逻,而是相互助力,我理解它可以做這些事情:
<figure></figure>
接下來通過JSCore 的原理和通信機制這一小節(jié)給出上述的解決方案臂痕。
JSCore 的原理和通信機制
JSCore 是什么伯襟?
大家都知道瀏覽器內(nèi)核的模塊主要是由渲染引擎和 JS 引擎組成,其中 JSCore 就是一種 JS 引擎
Apple 通過將 WebKit 的 JS 引擎用 OC 封裝握童,提供了一套 JS 運行環(huán)境以及 Native 與 JS 數(shù)據(jù)類型之間的轉(zhuǎn)換橋梁姆怪,常用于 OC 和 JS 代碼之間的相互調(diào)用,這也意味著他可以脫離渲染單獨去執(zhí)行 JS澡绩。
JSCore 主要包括如下這些 classes稽揭、協(xié)議、類結(jié)構(gòu):
<figure></figure>
JSCore 如何運行呢肥卡?
可以通過如下這張 JSCore 的框架結(jié)構(gòu)圖和上述描述來看懂各個模塊是怎么運行的淀衣。
<figure></figure>
從上圖我們可以看到一個這樣的過程:
在 Native 應(yīng)用中我們可以開啟多個線程來異步執(zhí)行我們不同的需求,也就意味著我們可創(chuàng)建多個 JSVirtualMachine 虛擬機(運行資源提供者)召调,同時相互隔離不影響膨桥,這樣我們就可以并行地執(zhí)行不同 JS 任務(wù)。
在一個 JSVirtualMachine 中還可以關(guān)聯(lián)多個 JSContext (JS 執(zhí)行環(huán)境上下文)唠叛,并通過 JSValue(值對象) 來和 Native 進行數(shù)據(jù)傳遞通信只嚣,同時可以通過 JSExport (協(xié)議) ,將 Native 中遵守此解析的類的方法和屬性轉(zhuǎn)換為 JS 的接口供其調(diào)用艺沼。
JS 和 OC 數(shù)據(jù)類型互換
從上小節(jié)册舞,可以知道 JSValue 可以用來讓 JS 和 OC 之間無障礙的數(shù)據(jù)轉(zhuǎn)換,主要原理是 JSValue 上面提供了如下方法障般,便于雙方各種類型進行轉(zhuǎn)換调鲸。
<figure></figure>
在 iOS 里面執(zhí)行 JS 代碼
我們可以通過evaluateScript
在 JSCore 中執(zhí)行一段 JS 腳本,利用這個特性我們可以來做一些多端邏輯統(tǒng)一的事情挽荡。
// 執(zhí)行一段 JavaScript 腳本
- (JSValue *)evaluateScript:(NSString *)script;
比如業(yè)務(wù)中 3 端(iOS藐石、Android、H5)有一段相當(dāng)復(fù)雜的但原理一樣算價邏輯定拟,一般做法是 3 端用各自語言自己寫一套于微,這樣做不但麻煩、效率低而且邏輯不一定統(tǒng)一,同時用 OC 去實現(xiàn)復(fù)雜計算邏輯也沒有 JS 這么靈活高效株依。
這里就可以利用執(zhí)行 JS 代碼這個特性驱证,將這個邏輯抽成一個 JS 方法,只需要傳入特定的入?yún)⒘低螅苯臃祷貎r格抹锄,這樣的話,3 端可以同時使用這個邏輯荠藤,還可以放到遠(yuǎn)端進行動態(tài)更新維護伙单。
大概這樣實現(xiàn):
// 在 iOS 里面執(zhí)行 JS
JSContext *jsContext = [[JSContext alloc] init];
[jsContext evaluateScript:@"var num = 500"];
[jsContext evaluateScript:@"var computePrice = function(value)
{ return value * 2 }"];
JSValue *value = [jsContext evaluateScript:@"computePrice(num)"];
int intVal = [value toInt32];
NSLog(@"計算結(jié)果為 %d", intVal);
運算結(jié)果為:
2018-03-16 20:20:28.006282+0800 JSCoreDemo[4858:196086]
========在 iOS 里面執(zhí)行 JS 代碼========
2018-03-16 20:20:28.006517+0800 JSCoreDemo[4858:196086]
計算結(jié)果為 1000
我認(rèn)為還可以在正則校驗、動畫函數(shù)商源、3D 渲染建模等這些數(shù)據(jù)計算方面來使用它车份。
在 iOS 里面調(diào)用 JS 中方法
說完在 iOS 中執(zhí)行 JS 代碼,接下來給大家介紹下牡彻,如何在 iOS 中調(diào)用 H5 中的 JS 方法扫沼。
比如我們 H5 中有一個全局方法叫做 nativeCallJS,我們可以通過執(zhí)行環(huán)境的上下文 jsContext[@"nativeCallJS"]
獲取該方法并進行調(diào)用庄吼,類似這樣:
// Html 中有一個 JS 全局方法
<script type="text/javascript">
var nativeCallJS = function (parameter) {
alert(parameter);
};
</script>
// 在 iOS 運行 JS 方法
JSContext *jsContext = [webView valueForKeyPath:@“documentView.webView.mainFrame.javaScriptContext”];
JSValue *jsMethod = jsContext[@"nativeCallJS"];
jsMethod callWithArguments:@[ @"Hello JS, I am iOS" ]];
最終我們的運行結(jié)果就可以看到 Native 執(zhí)行到了 H5 的 Alter 彈層:
<figure></figure>
利用這個特性我們可以讓 iOS 獲取到一些 H5 的信息來處理一些他想處理的東西缎除,譬如先將信息在全局中暴露出來,通過調(diào)用方法獲取到 使用的版本號总寻、運行的環(huán)境信息器罐、端主動處理邏輯(清除緩存、控制運行)等這些事情渐行。
在 JS 里面調(diào)用 iOS 中方法
其實對于前端同學(xué)使用得最多的應(yīng)該是這個轰坊,通過 JS 調(diào)用端上能力來彌補 H5 上的不足。
這里需要和@"documentView.webView.mainFrame.javaScriptContext"
這個 webview 相關(guān)特性結(jié)合起來祟印,將 H5 調(diào)用的方法用 Block 以jsCallNative(調(diào)用方法名)
為名傳遞給 JSCore 上下文肴沫。
比如我們 H5 中有一個按鈕的點擊回調(diào)是去調(diào)用客戶端的一個方法,并在方法中輸出傳入?yún)?shù)蕴忆,大致是這樣實現(xiàn):
// Html中按鈕點擊調(diào)用一個OC方法
<button type="button"
onclick="jsCallNative('Hello iOS', 'I am JS');">調(diào)用OC代碼</button>
//Block 以”jsCallNative"為名傳遞給JavaScript上下文
JSContext *jsContext = [webView valueForKeyPath:
@"documentView.webView.mainFrame.javaScriptContext"];
jsContext[@"jsCallNative"] = ^() {
NSArray *args = [JSContext currentArguments];
for (JSValue *obj in args) {
NSLog(@"%@", obj);
}
};
最終輸出是這樣:
2018-03-16 20:51:25.590749+0800 JSCoreDemo[4970:219245] ========在 JS 里面調(diào)用 iOS 中方法========
2018-03-16 20:51:25.591155+0800 JSCoreDemo[4970:219245] Hello iOS
2018-03-16 20:51:25.591370+0800 JSCoreDemo[4970:219245] I am JS
這個特性真正讓 H5 可以享受到很多端上的特性颤芬,比如Native 方式的跳轉(zhuǎn)、Native 底層能力(震動套鹅、錄音站蝠、拍照)、掃碼卓鹿、獲取設(shè)備信息菱魔、分享、設(shè)置導(dǎo)航欄减牺、調(diào)用 Native 封裝組件等這些功能豌习,此處大家可以聯(lián)系 Hybrid 開發(fā)模式存谎。
通過 JSExport 暴露 iOS 方法屬性給 JS
這個特性可能 H5 的同學(xué)不是很清楚拔疚,但是對于 Native 同學(xué)肥隆,我認(rèn)為非常有用。
通過 JSExport 可以很方便地將 iOS 對象的屬性方法暴露給 JS 環(huán)境稚失,讓其使用起來像 JS 對象一樣方便栋艳。
比如我們 OC 中有一個 Person 的類,包含兩個屬性和一個方法句各,此處通過讓fullName
方法使用 JSExport 協(xié)議暴露出去吸占,這樣在 JS 中是可以直接去調(diào)用的。
@protocol PersonProtocol <JSExport>
- (NSString *)fullName;
@end
@interface Person : NSObject <PersonProtocol>
@property (nonatomic, copy) NSString *firstName;
@property (nonatomic, copy) NSString *lastName;
@end
@implementation Person
@synthesize firstName, lastName;
(NSString *)fullName {
return [NSString stringWithFormat:@"%@ %@",self.firstName, self.lastName];
}
@end
// 通過 JSExport 暴露 iOS 方法屬性給 JS
Person *person = [[Person alloc] init];
jsContext[@"p"] = person;
person.firstName = @"Fei";
person.lastName = @"Zhu";
NSLog(@"========通過 JSExport 暴露 iOS 方法屬性給 JS========");
[jsContext evaluateScript:@"log(p.fullName());"];
[jsContext evaluateScript:@"log(p.firstName);"];
最終運行結(jié)果為:
2018-03-16 20:51:17.437688+0800 JSCoreDemo[4970:219193] ========通過 JSExport 暴露 iOS 方法屬性給 JS========
2018-03-16 20:51:17.438100+0800 JSCoreDemo[4970:219193] Fei Zhu
2018-03-16 20:51:17.438388+0800 JSCoreDemo[4970:219193] undefined
為什么p.firstName
運行后是undefined
呢凿宾? 因為在這里沒有將其暴露到 Native 環(huán)境中矾屯,所以就獲取不到了。
這里我們可以利用的更多的是在編程便捷性上面初厚,讓 OC 和 JS 直接可以相互調(diào)用件蚕。
在 iOS 里面處理 JS 異常
稍微成熟一點的公司的前端頁面都會有運行異常監(jiān)控系統(tǒng),發(fā)現(xiàn) JS 執(zhí)行異巢蹋可以直接通知開發(fā)以防止線上事故的發(fā)生排作。
通過 JSCore 中的 exceptionHandler
可以很好的解決這個問題,當(dāng) JS 運行異常時候亚情,會回調(diào) JSContext 的 exceptionHandler 中設(shè)置的 Block妄痪,這樣我們可以在 Block 回調(diào)里面將我們的錯誤上傳到監(jiān)控平臺。
比如這個例子楞件,我運行一個返回a+1
的函數(shù)衫生,平時我們在 Chrome console 可以看到報錯Can't find variable: a
,這里運行也會一樣:
// 當(dāng)JavaScript運行時出現(xiàn)異常
// 會回調(diào)JSContext的exceptionHandler中設(shè)置的Block
JSContext *jsContext = [[JSContext alloc] init];
jsContext.exceptionHandler = ^(JSContext *context, JSValue *exception) {
NSLog(@"JS Error: %@", exception);
};
[jsContext evaluateScript:@"(function errTest(){ return a+1; })();"];
最后輸出報錯為:
2018-03-17 11:28:07.248778+0800 JSCoreDemo[15007:632219]
========在iOS里面處理 JS 異常========
2018-03-17 11:28:07.252255+0800 JSCoreDemo[15007:632219]
JS Error: ReferenceError: Can't find variable: a
JS 和端相互通信
最近給 Weex 提交了一個《More enhanced about <web> component》 的 PR土浸,大概就是利用上述思路罪针,通過實現(xiàn) W3C 的 MessageEvent 規(guī)范來讓組件和 Weex 之間可以進行互相通信,同時通過 loadHTMLString 直接來渲染傳入的 html 源碼功能栅迄。
具體實現(xiàn)為:
<figure></figure>
具體思路和 Demo 可見 [WEEX-233][iOS]
JSPatch
讓我們更深一步來思考上述思路可否再次進行擴展站故,能否通過 JS 直接來干預(yù) iOS 代碼的運行呢?答案是可以的毅舆,下面我想整理一下我對 JSPatch 的理解西篓。
假如 iOS 想不發(fā)版改 bug
假如線上 APP 有一段代碼出現(xiàn) bug 導(dǎo)致 crash,可能 Native crash 會比 H5 問題嚴(yán)重很多憋活,前者可以立馬發(fā)布岂津,后者可能需要修改好提交 Apple 商店數(shù)日才上線,然后可能更新率還上不去悦即,很麻煩的吮成。
這里就可以通過 JSPatch 這種類似的方案橱乱,下發(fā)一段代碼覆蓋掉原來有問題的方法,這樣可以很快修復(fù)這個 bug粱甫。
可以通過一個簡單的例子來看上述過程泳叠。
用 OC 寫了一個藍色的Hello World
, 我們可以通過下發(fā)一段 JS 代碼將原來藍色的字體修改成紅色并修改文字,將 JS 代碼下發(fā)代碼刪除后茶宵,又可以恢復(fù)原來的藍色Hello World
</figure>
主要代碼大致如下:
// 一段顯示藍色 Hello World 的 OC 代碼
@implementation ViewController
- (void)viewDidLoad {
[super viewDidLoad];
[self simpleTest];
}
- (void)simpleTest {
self.label.text = @"Hello World";
self.label.textColor = [UIColor blueColor];
}
@end
// 一段符合 JSPatch 規(guī)則的JS覆蓋代碼
require('UIColor');
defineClass('ViewController', { simpleTest : function() {
self.label().setText("你的藍色 Hello World 被我改成紅色了");
var red = UIColor.redColor();
self.label().setTextColor(red);
},
})
這里是如何做到的呢危纫?首先需要介紹下 JSPatch:
JSPatch 是一個 iOS 動態(tài)更新框架,通過引入 JSCore乌庶,就可以使用 JS 調(diào)用任何原生接口种蝶,可以為項目動態(tài)更新模塊、替換原生代碼動態(tài)修復(fù) Bug瞒大。
也即 JS 傳遞字符串給 OC螃征,OC 通過 Runtime 接口調(diào)用和替換 OC 方法。
為什么可以通過 JS 調(diào)用任何原生接口呢透敌? 首先可以了解下 OC 底層 Runtime 的原理盯滚。
Runtime
OC 語言中大概 95% 都是 C 相關(guān)的寫法,為何當(dāng)時蘋果不直接使用 C 來寫 iOS 呢拙泽?其中一個很大的原因就是 OC 的動態(tài)性淌山,有一個很強大的 Runtime (一套 C 語言的 API,底層基于它來實現(xiàn))顾瞻,核心是消息分發(fā)泼疑,Runtime 會根據(jù)消息接收者是否能響應(yīng)該消息而做出不同的反應(yīng)。
也許上述會比較生澀荷荤,簡單說就是OC 方法的實現(xiàn)和調(diào)用指針的關(guān)系是在運行時才決定的退渗,而非編譯期,這樣的話蕴纳,我們可以在運行期做些事情更改原來的實現(xiàn)会油,達到熱修復(fù)的目的。
OC 方法的調(diào)用不像 JS 這種語言古毛,直接array.push(foo)
函數(shù)調(diào)用即可翻翩,他是通過消息機制來進行調(diào)用的,比如如下這個將foo
插入到數(shù)組中的第 5 位:
[array insertObject:foo atIndex:5];
在底層比這個實現(xiàn)更加生澀稻薇,他通過objc_msgSend
這個方法將消息搭配選擇器進行發(fā)送出去:
objc_msgSend(array, @selector(insertObject:atIndex:), foo, 5);
運行時發(fā)消息給對象嫂冻,消息是如何映射到方法的 ?
簡單來說就是塞椎,一個對象的 class 保存了方法列表桨仿,是一個字典,key 為 selectors案狠,IMPs 為 value服傍,一個 IMP 是指向方法在內(nèi)存中的實現(xiàn)钱雷,selector 和 IMP 之間的關(guān)系是在運行時才決定的,非編譯時吹零。
- (id)doSomethingWithInt:(int)aInt{}
id doSomethingWithInt(id self, SEL _cmd, int aInt){}
通過看了下 Runtime 的源碼罩抗,發(fā)現(xiàn)有如下這些常用的方法:
<figure></figure>
通過上述這些方法就可以做很多意想不到的事情,比如動態(tài)的變量控制瘪校、動態(tài)給一個對象增加方法澄暮、可以把消息轉(zhuǎn)發(fā)給想要的對象名段、甚至可以動態(tài)交換兩個方法的實現(xiàn)阱扬。
JSPatch && Runtime
正是由于 OC 語言的動態(tài)性,上所有方法的調(diào)用/類的生成都通過 OC Runtime 在運行時進行伸辟,可通過類名稱和方法名的字符串獲取該類和該方法麻惶,并實例化和調(diào)用:
Class class = NSClassFromString("UIViewController");
id viewController = [[class alloc] init];
SEL selector = NSSelectorFromString("viewDidLoad");
[viewController performSelector:selector];
也可以替換某個類的方法為新的實現(xiàn):
static void newViewDidLoad(id slf, SEL sel) {}
class_replaceMethod(class, selector, newViewDidLoad, @"");
還可以新注冊一個類,為類添加方法:
Class cls = objc_allocateClassPair(superCls, "JPObject", 0);
objc_registerClassPair(cls);
class_addMethod(cls, selector, implement, typedesc);
JSPatch 正是利用如上這些好的特性來實現(xiàn)他的熱修復(fù)功能信夫。
JSPatch 中 JS 如何調(diào)用 OC
此處 JSPatch 中的 JS 是如何和任意修改 OC 代碼聯(lián)系起來的呢窃蹋?大概原理如下:
1.JSPatch 在實現(xiàn)中是通過 Require 調(diào)用,在 JS 全局作用域上創(chuàng)建一個同名變量静稻,變量指向一個對象警没,對象屬性 __clsName 保存類名,同時表明這個對象是一個 OC Class振湾,通過調(diào)用require(“UIView")
杀迹,我們就可以使用UIView
去調(diào)用他上面對應(yīng)方法了。
UIView = require(“UIView");
var _require = function(clsName) {
if (!global[clsName]) {
global[clsName] = {__clsName: clsName}
}
return global[clsName]
}
2.在 JSCore 執(zhí)行前押搪,OC 方法的調(diào)用均通過新增 Object(JS) 原型方法__c(methodName)完成調(diào)用树酪,假如直接調(diào)用的話,需要 JS 遍歷當(dāng)前類的所有方法大州,還要循環(huán)找父類的方法直到頂層续语,無疑是很耗時的,通過__c()
元函數(shù)的唯一性厦画,可以每次調(diào)用它時候疮茄,轉(zhuǎn)發(fā)給一個指定函數(shù)去執(zhí)行,就很優(yōu)雅了根暑。
</figure>
Object.prototype.__c = function(methodName) {
return function(){
var args = Array.prototype.slice.call(arguments)
return _methodFunc(self.__obj, self.__clsName, methodName,
args, self.__isSuper)
}
}
3.處理好 JS 接口問題后力试,接下來只需要借助前面 JSCore 的知識就可以做到 JS 和 OC 之間的消息互傳了,也即在在_c 函數(shù)中通過 JSContex 建立的橋接函數(shù)购裙,用 Runtime 接口調(diào)用相應(yīng)方法完成調(diào)用:
- JS 中的轉(zhuǎn)發(fā)
var _methodFunc = function(instance, clsName, methodName, args, isSuper) {
....
var ret = _OC_callC(clsName, selectorName, args)
return _formatOCToJS(ret)
}
- OC 中的處理
context[@"_OC_callC"] = ^id(NSString *className, NSString *selectorName, JSValue *arguments) {
return callSelector(className, selectorName, arguments, nil, NO);
};
感觸
上面大概就是如何通過 JS 任意修改 OC 運行結(jié)果的一個原理懂版,雖然 JSPatch 大部分功能被蘋果禁用了,但是其中 JS 操作 OC 的思路真的很棒躏率。
《iOS 中的 JS》 其實是在上周團隊象聲匯的一個分享躯畴,將其進行整理成文章民鼓,分享給對跨端感興趣的同學(xué),同時盡量用接地氣的方式蓬抄,希望只有客戶端基礎(chǔ)或者前端基礎(chǔ)的同學(xué)也可以看懂丰嘉,同時也可以 Clone 上文中 Demo 源碼 來進行測試。
由于我還處在 iOS 補基礎(chǔ)階段嚷缭,可能有些地方理解不到位饮亏,歡迎一起討論。
轉(zhuǎn)載:
https://zhuanlan.zhihu.com/p/34646281