背景
之前一段時(shí)間在對(duì)項(xiàng)目里的 JSBridge 進(jìn)行整理和優(yōu)化效五,突然想到想整理一下 JSBridge 在 iOS 系統(tǒng)版本衍化過(guò)程中所出現(xiàn)過(guò)的主要技術(shù)手段韭畸。
絕大多數(shù) APP 都逃不開(kāi) H5 開(kāi)發(fā)或加載網(wǎng)頁(yè)的需求。JSBridge 應(yīng)用于 Web 和 Native 兩端的交互灌旧,所以也是 Hybrid APP (混合移動(dòng)端應(yīng)用)運(yùn)作的核心層級(jí)步绸。
可以說(shuō) JSBridge 是 iOS 開(kāi)發(fā)者的必修課蔚约。
JSBridge
JSBridge 即利用 JavaScript 語(yǔ)言,令 Web 和 Native 兩端可以進(jìn)行交互的橋接層烙如。一個(gè)完整的 JSBridge 方案需要對(duì)所有兩端交互的技術(shù)手段進(jìn)行選型么抗、優(yōu)化和整合。這里我們只討論 iOS 和 Web 端的交互手段亚铁。
JavaScriptCore
任何一個(gè)移動(dòng)端系統(tǒng)的 WebKit 都會(huì)默認(rèn)內(nèi)嵌各自的 JS 引擎蝇刀,它們的工作就是對(duì) JS 腳本進(jìn)行編譯與運(yùn)行( JS 虛擬機(jī),用來(lái)分析詞匯與語(yǔ)法生成 ByteCode (指令字節(jié)碼)并運(yùn)行徘溢,和負(fù)責(zé)運(yùn)行時(shí)的內(nèi)存空間開(kāi)辟吞琐、管理等等)。
JS 引擎是實(shí)現(xiàn) JSBridge 核心然爆,而 iOS/OS 端對(duì)應(yīng)的 JS 引擎是 JavaScriptCore 顽分。
在 iOS 7 以后,蘋(píng)果對(duì) WebKit 中的 JavaScriptCore 框架進(jìn)行 Objective-C 的封裝并提供給開(kāi)發(fā)者施蜜。所以在 iOS 端中卒蘸, JSBridge 使用的技術(shù)手段也以 iOS 7 為臨界點(diǎn)分為兩個(gè)階段。
Before iOS 7
iOS call Web
UIWebView 提供了 stringByEvaluatingJavaScriptFromString:
// NOTE: Returns the result of running a JavaScript script
- (NSString *)stringByEvaluatingJavaScriptFromString:(NSString *)script;
用來(lái)在當(dāng)前網(wǎng)頁(yè)執(zhí)行一段 JS 腳本并以字符串的形式返回調(diào)用結(jié)果:
// NOTE: 獲取當(dāng)前url
NSString *currentURL = [webView stringByEvaluatingJavaScriptFromString:@"document.location.href"];
// NOTE: 獲取當(dāng)前網(wǎng)頁(yè)標(biāo)題
NSString *title = [webview stringByEvaluatingJavaScriptFromString:@"document.title"];
// NOTE: 注入自定義方法jsFunction()
NSString *jsFunction = @";(function() {function jsFunction(){return 'Hello World';})();";
[webView stringByEvaluatingJavaScriptFromString:js];//注入js方法
// NOTE: 調(diào)用自定義方法jsFunction并獲取回調(diào)結(jié)果
NSString *resultString = [webView stringByEvaluatingJavaScriptFromString:@"jsFunction()"];
Web call iOS
攔截 URL
iOS 7 之前翻默,基本上都是使用攔截 URL 的方案來(lái)實(shí)現(xiàn) Web call iOS缸沃。
1 . iframe.src (重定向)
HTML內(nèi)聯(lián)框架元素 <iframe>,將另一個(gè)HTML頁(yè)面嵌入到當(dāng)前頁(yè)面中修械。
function doSend(message, responseCallback) {
messagingIframe.src = 'mizhua://';
}
- (BOOL)webView:(UIWebView *)webView
shouldStartLoadWithRequest:(NSURLRequest *)request
navigationType:(UIWebViewNavigationType)navigationType
{
NSURL *url = request.URL;
if ([url.scheme containsString:@"mizhua"]) {
//do something here
return NO;
}
return YES;
}
核心思路就是在 WebView 攔截 Web 端發(fā)起的請(qǐng)求趾牧。雙方提前約定好協(xié)議(例如:mizhua://host?dyaction=home&tab=0,約定了 Scheme肯污、URL 和入?yún)ⅲ┣痰ィ琲OS 端在對(duì)應(yīng)的 WebView delegate 方法過(guò)濾對(duì)應(yīng)協(xié)議的請(qǐng)求吨枉,拿到url參數(shù)后做對(duì)應(yīng)的處理。
缺陷
- 發(fā)起 url request 有命中 url 長(zhǎng)度限制的隱患(如 browser 端的IE限定url長(zhǎng)度為2083字節(jié)哄芜,opera 是4050貌亭,或者服務(wù)端的限制),特別是帶參多的情況认臊;
- WebView對(duì)調(diào)用 url request 有頻率限制圃庭,快速調(diào)用多次 url request 有令前一部分丟失攔截的風(fēng)險(xiǎn);
- 比起用 JavaScriptCore 注入 api 并直接調(diào)用的方式失晴,url request 創(chuàng)建請(qǐng)求有一定的耗時(shí)剧腻;
- 比起用 JavaScriptCore 注入 api 的方式,url request 無(wú)法直接 callback 處理結(jié)果涂屁。
優(yōu)化
- 針對(duì) url 長(zhǎng)度限制的問(wèn)題书在,可以轉(zhuǎn) push 為 pull ,讓 iOS 端主動(dòng)拉取要 web 端所要調(diào)用方法的信息拆又,步驟如下:
① Web 端把要調(diào)用的方法和參數(shù)先轉(zhuǎn)換為 json 字符串(例如:{"funcName":"login","prama":{"username":"xiaoming"}}
)保存起來(lái)蕊温;
② 并預(yù)寫(xiě)好一個(gè)用來(lái)獲取該 json 字符串的方法(例如:function fetchMessage()
);
③ 然后發(fā)起一個(gè)簡(jiǎn)單的 url request (例如: mizhua://fetch_message )遏乔;
④ WebView 在攔截到 mizhua://fetch_message 請(qǐng)求后义矛,主動(dòng)調(diào)用 Web 端的fetchMessage()
方法,拉取到 json 字符串并反序列化盟萨,得到要調(diào)用的方法的信息凉翻。
相應(yīng)的偽代碼如下:
// ---------------------------- Web ----------------------------
var sendMessage;
callOC({ funcName:'login', prama:{'username': 'xiaoming'} })
function callOC(message) {
sendMessage = message;
messagingIframe.src = 'mizhua://fetch_message';
// messagingIframe.src = 'mizhua://fetch_message?funcName=login&username=xiaoming...';
}
function fetchMessage() {
var messageString = JSON.stringify(sendMessage);
sendMessage = null;
return messageString;
}
// ---------------------------- iOS ----------------------------
- (BOOL)webView:(UIWebView *)webView
shouldStartLoadWithRequest:(NSURLRequest *)request
navigationType:(UIWebViewNavigationType)navigationType
{
NSURL *url = request.URL;
if ([url.scheme containsString:@"mizhua"] && [url.host containsString:@"fetch_message"]) {
NSString *messageString = [self stringByEvaluatingJavaScriptFromString:@"fetchMessage();"];
Message *obj = [self deserializationMessage:messageString];
!obj.handleBlock?:obj.handleBlock();
return NO;
}
return YES;
}
- 針對(duì)丟失攔截的問(wèn)題,我們?cè)谇懊娣桨傅幕A(chǔ)上捻激,把要調(diào)用的函數(shù)信息保存到一個(gè)消息隊(duì)列
sendMessageQueue
中制轰,Native 調(diào)用fetchMessage()
的時(shí)候,Web 端把sendMessageQueue
里的所有方法取出來(lái)返回給客戶端胞谭,并清空隊(duì)列垃杖。
這樣做的好處是當(dāng)發(fā)生丟失攔截時(shí),消息還會(huì)緩存在消息隊(duì)列中丈屹,等到下一次 web 端發(fā)起 mizhua://fetch_message 要調(diào)用 iOS 端的時(shí)候调俘,未執(zhí)行的 message 就會(huì)跟著其他消息一起被 native 獲取到。 - 針對(duì) url request 無(wú)法直接 callback 的問(wèn)題旺垒,解決方案也是讓 iOS 端主動(dòng)調(diào)用 web 端預(yù)先注冊(cè)好的監(jiān)聽(tīng)回調(diào)函數(shù)彩库,不過(guò)需要讓這個(gè)回調(diào)函數(shù)和調(diào)用函數(shù)建立起聯(lián)系。
我們可以在 web 端維護(hù)一個(gè)回調(diào)函數(shù)字典responseCallbacks
先蒋,用來(lái)存儲(chǔ)所有注冊(cè)的回調(diào)函數(shù)骇钦,并以 web 端發(fā)起請(qǐng)求的時(shí)間戳為 key (callbackId
),并將它寫(xiě)入到 message 中({"funcName":"login","prama":{"username":"xiaoming"}, "callbackId":"1555064744266"}
)竞漾,iOS 端檢測(cè)到 message 中帶有callbackId
時(shí)眯搭,再將callbackId
和返回值拼接起來(lái)窥翩,調(diào)用 web 端預(yù)寫(xiě)的處理函數(shù)handleCallback()
,由 web 端進(jìn)行回調(diào)分發(fā)鳞仙。
結(jié)合以上所有的優(yōu)化手段寇蚊,我們可以得到一個(gè)針對(duì) url 重定向的 JSBridge 整體技術(shù)方案:
// ---------------------------- Web ----------------------------
// call native 方法隊(duì)列
var sendMessageQueue = [];
// 回調(diào)函數(shù)字典,key 為時(shí)間戳生成的 callbackId
var responseCallbacks = {};
// 調(diào)用原生方法
// message 為消息內(nèi)容繁扎,一般包括funcName(原生方法名)幔荒、prama(入?yún)?糊闽、callbackId(回調(diào)標(biāo)記)等字段
// responseCallback 為回調(diào)函數(shù)梳玫,
function callOC(message, responseCallback) {
// step 1:設(shè)置回調(diào)函數(shù)的情況下,根據(jù)時(shí)間戳生成 callbackId 右犹,并將它們緩存到內(nèi)存
if (responseCallback) {
var callbackId = new Date().getTime();
responseCallbacks[callbackId] = responseCallback;
message['callbackId'] = callbackId;
}
// step 2:消息入隊(duì)
sendMessageQueue.push(message);
// step 3:發(fā)起讓 native 拉取消息列表的 url 請(qǐng)求
messagingIframe.src = 'mizhua://fetch_message';
}
callOC({ funcName:'login', prama:{'username': 'xiaoming'} }, function(response) {
log(response)
})
// 序列化消息隊(duì)列并返回給 native
function fetchMessageQueue() {
// step 5:序列化消息隊(duì)列 `sendMessageQueue`提澎,并清空隊(duì)列
var messageQueueString = JSON.stringify(sendMessageQueue);
sendMessageQueue = [];
// step 6:返回消息隊(duì)列的json string
return messageQueueString;
}
// 監(jiān)聽(tīng) native 的回調(diào)
function handleCallback(messageJSON) {
var message = JSON.parse(messageJSON);
var responseCallback;
// step 9:處理回調(diào)并將對(duì)應(yīng) callbackId 的回調(diào)函數(shù)移出緩存
if (message.responseId) {
responseCallback = responseCallbacks[message.responseId];
if (!responseCallback) {
return;
}
responseCallback(message.responseData);
delete responseCallbacks[message.responseId];
}
}
// ---------------------------- iOS ----------------------------
- (BOOL)webView:(UIWebView *)webView
shouldStartLoadWithRequest:(NSURLRequest *)request
navigationType:(UIWebViewNavigationType)navigationType
{
NSURL *url = request.URL;
if ([url.scheme containsString:@"mizhua"] && [url.host containsString:@"fetch_message"]) {
// step 4:native 識(shí)別到 fetch_message 請(qǐng)求后,從 web 端拉取消息列表
NSString *messageQueueString = [self stringByEvaluatingJavaScriptFromString:@"fetchMessageQueue();"];
NSArray <Message *>*messageList = [self deserializationMessageQueue:messageQueueString];
// step 7:反序列化出所有的 message 并依次執(zhí)行
[messageList enumerateObjectsUsingBlock:^(Message * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
!obj.handleBlock?:obj.handleBlock();
// step 8:message 帶 callbackId 的情況下念链,需要調(diào)用回調(diào)方法
if (obj.callbackId) {
[self stringByEvaluatingJavaScriptFromString:@"handleCallback('%@')",@{@"callbackId":obj.callbackId,@"responseData":responseData}];
}
}];
return NO;
}
return YES;
}
https://upload-images.jianshu.io/upload_images/1835011-6c02d5c848b2629b.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240
2 . Ajax & NSURLProtocol
AJAX = Asynchronous JavaScript and XML
AJAX 可以在不重定向當(dāng)前頁(yè)面的情況下盼忌,與服務(wù)器交換數(shù)據(jù)并更新部分網(wǎng)頁(yè)內(nèi)容。
官方文檔對(duì)于 NSURLProtocol 的描述如下:
An abstract class that handles the loading of protocol-specific URL data.
它是一個(gè)描述 URL 加載過(guò)程的抽象類(lèi)掂墓,你可以通過(guò)子類(lèi)化它來(lái)重新定義新的或已經(jīng)存在的URL加載行為谦纱。
我們可以利用這個(gè)方案來(lái)攔截到 APP 的 URL請(qǐng)求,面向切面編程地應(yīng)用到網(wǎng)絡(luò)緩存君编、網(wǎng)絡(luò)請(qǐng)求監(jiān)控跨嘉、防止DNS劫持、重定向網(wǎng)絡(luò)請(qǐng)求等場(chǎng)景吃嘿。不過(guò)這里我們只討論攔截 URL 來(lái)實(shí)現(xiàn) JSBridge 的部分祠乃。
前端使用XMLHttpRequest
發(fā)起請(qǐng)求,原生注冊(cè)自定義NSURLProtocol
進(jìn)行攔截:
// 1.新建類(lèi)繼承自`NSURLProtocol`兑燥,并注冊(cè)
[NSURLProtocol registerClass:[DYURLProtocol class]];
// 2.前端調(diào)用原生
function callNative(action, data) {
var xhr = new window.XMLHttpRequest(),
url = 'mizhua://fetch_message';
xhr.open('POST', url, false);
xhr.send(JSON.stringify({
action: action,
data: data
}));
return xhr.responseText;
}
// 3.在`startLoading`代理方法攔截請(qǐng)求
@implementation DYURLProtocol
- (void)startLoading {
NSURL *url = [[self request] URL];
if (![url.host isEqualToString:@"__jsbridge__"]) return;
// 4.處理JS調(diào)用Native
NSDictionary *dic = [NSJSONSerialization JSONObjectWithData:self.request.HTTPBody options:NSJSONReadingAllowFragments error:nil];
NSString *action = dic[@"action"];
NSString *data = dic[@"data"];
// 5. 處理完成亮瓷,將結(jié)果返回給js
NSHTTPURLResponse* response = [[NSHTTPURLResponse alloc] initWithURL:[[self request] URL] statusCode:statusCode HTTPVersion:@"HTTP/1.1" headerFields:@{@"Content-Type" : mimeType}];
[[self client] URLProtocol:self didReceiveResponse:response cacheStoragePolicy:NSURLCacheStorageNotAllowed];
if (data != nil) {
[[self client] URLProtocol:self didLoadData:data];
}
[[self client] URLProtocolDidFinishLoading:self];
}
缺陷
WKWebView 維護(hù)著自己的 NSURLProtocol ,并不在以上方案的 hook 范圍中降瞳。因此嘱支,在 WKWebView 上無(wú)法直接使用注冊(cè) NSURLProtocol 的方式攔截請(qǐng)求。
優(yōu)化
蘋(píng)果開(kāi)源的 WebKit2 源碼暴露了以下的私有API:
+ [WKBrowsingContextController registerSchemeForCustomProtocol:]
通過(guò)注冊(cè) http(s) scheme 可以攔截到對(duì)應(yīng)的http或https請(qǐng)求
Class cls = NSClassFromString(@"WKBrowsingContextController");
SEL sel = NSSelectorFromString(@"registerSchemeForCustomProtocol:");
if ([(id)cls respondsToSelector:sel]) {
// 注冊(cè)http(s) scheme, 把 http和https請(qǐng)求交給 NSURLProtocol處理
[(id)cls performSelector:sel withObject:@"http"];
[(id)cls performSelector:sel withObject:@"https"];
}
After iOS 7
iOS 7 以后挣饥,蘋(píng)果對(duì) WebKit 中的 JavaScriptCore 框架進(jìn)行 Objective-C 的封裝并提供給開(kāi)發(fā)者斗塘。
簡(jiǎn)單說(shuō)一下 JavaScriptCore 對(duì) Objective-C 的封裝相關(guān)的幾個(gè)概念
JSValue
JSValue 是一個(gè)指向 JS 變量(var)的引用指針。使用 JSValue亮靴,可以讓數(shù)據(jù)類(lèi)型在 OC 和 JS 之間相互轉(zhuǎn)換馍盟。
Objective-C type | JavaScript type
--------------------+---------------------
nil | undefined
NSNull | null
NSString | string
NSNumber | number, boolean
NSDictionary | Object object
NSArray | Array object
NSDate | Date object
NSBlock | Function object
id | Wrapper object
Class | Constructor object
JSContext
“Context” 一般理解為上下文。 JSContext 是 JS 語(yǔ)言的執(zhí)行環(huán)境茧吊。我們可以通過(guò) KVC 的方式獲取當(dāng)前 WebView 的 JSContext:
JSContext *context = [webView valueForKeyPath:@"documentView.webView.mainFrame.javaScriptContext"];
JSContext 中有一個(gè) JSValue 類(lèi)型的屬性贞岭,名字是 GlobalObject八毯。它是當(dāng)前所執(zhí)行的 JSContext 的全局對(duì)象,所有的 JS 變量(var)與函數(shù)(function)都在全局對(duì)象里瞄桨。例如在 WebKit 中话速, GlobalObject 就相當(dāng)于 web 端的 Window 對(duì)象。我們獲取到瀏覽器的 JSContext 芯侥,對(duì)它注入代碼泊交,其實(shí)就是在操作 Window。
一個(gè) JSContext 可以擁有多個(gè) JSValue 柱查,同時(shí) JSValue 和它對(duì)應(yīng)的 var 以及它其所屬的 JSContext 對(duì)象都是強(qiáng)引用的關(guān)系:
GC 機(jī)制
JS 不需要我們?nèi)ナ謩?dòng)管理內(nèi)存廓俭。JS 的內(nèi)存管理使用的是 GC 機(jī)制(Tracing Garbage Collection)。不同于 OC 的引用計(jì)數(shù)唉工,Tracing Garbage Collection 是由 GCRoot(Context)開(kāi)始維護(hù)的一條引用鏈研乒,一旦引用鏈無(wú)法觸達(dá)某對(duì)象節(jié)點(diǎn),這個(gè)對(duì)象就會(huì)被回收掉淋硝。如下圖所示:
單線程
JS 引擎是單線程雹熬,以消息隊(duì)列(TaskQueue)與事件循環(huán)(EventLoop)機(jī)制進(jìn)行 task 的分發(fā),原理上可聯(lián)想到 OperationQueue 和 RunLoop谣膳。
iOS call Web
// NOTE: UIWebView 中竿报,我們可以通過(guò)KVC的方式獲取當(dāng)前 WebView 的 JSContext
JSContext *context = [uiWebView valueForKeyPath:@"documentView.webView.mainFrame.javaScriptContext"];
JSValue *value = [context evaluateScript:@"document.title"];
NSString *title = value.toString;
[context evaluateScript:@"fetchMessages();"];
//or
JSValue *fetchMessagesFunction = context[@"fetchMessages"];
[fetchMessagesFunction callWithArguments:nil];
// NOTE: 異步調(diào)用
[context[@"setTimeout"] callWithArguments:@[fetchMessagesFunction, @0]];
// NOTE: WKWebView 中,直接提供了evaluateJavaScript函數(shù)
[wkWebView evaluateJavaScript:@"document.title" completionHandler:^(NSString* title, NSError *error) {
}];
Web call iOS
UIWebView
向 JSContext 中注入 Block:
JSContext *context = [uiWebView valueForKeyPath:@"documentView.webView.mainFrame.javaScriptContext"];
context[@"fetchMessages"] = ^(NSArray<NSArray *> *calls) {
// Native 邏輯
};
//Web 端直接調(diào)用 fetchMessages()
JavaScriptCore 會(huì)在 Window 中生成對(duì)應(yīng)的 fetchMessages()
继谚。
踩坑
- 不要在 Block 中直接使用相應(yīng)的 JSValue 或 JSContext 烈菌。
前面說(shuō)到 JSValue 會(huì)強(qiáng)引用它所屬的 JSContext ,而 Block 會(huì)強(qiáng)引用它使用到的外部變量犬庇。
針對(duì) JSValue 僧界,可以把 JSValue 當(dāng)做參數(shù)傳到 Block 中,來(lái)規(guī)避外部引用臭挽。
針對(duì) JSContext捂襟,可以在 Block 中使用[JSContext currentContext]
方法來(lái)獲取當(dāng)前的 JSContext 。 - 需要在 Block 中 call Web (
callWithArguments:
欢峰、evaluateScript:
) 的情況下葬荷,不要切換線程。
Block 中的運(yùn)行線程就是 JS 引擎所使用的單線程纽帖,切換它有幾率會(huì)引發(fā)其他問(wèn)題宠漩。
缺陷
- 在
UIWebViewDelegate
提供的有限的代理方法中,唯一能有效獲取頁(yè)面 javaScriptContext 加載完成時(shí)機(jī)的方法是webViewDidFinishLoad:
懊直。意味著在整個(gè)頁(yè)面加載完成前扒吁,前端調(diào)用原生的方法不會(huì)生效。
優(yōu)化
在WebKit中室囊,蘋(píng)果提供了 WebFrameLoadDelegate
的 didCreateJavaScriptContext: 代理方法來(lái)定位javaScriptContext
加載完成的時(shí)機(jī)雕崩,但只曝露給 OS 系統(tǒng)魁索。我們可以通過(guò)給NSObject
加分類(lèi)實(shí)現(xiàn)該代理方法,不過(guò)這個(gè)方案也觸及了私有api
WKWebView (iOS 8)
@interface WKWebVIewVC ()<WKScriptMessageHandler>
@implementation WKWebVIewVC
- (void)viewDidLoad {
[super viewDidLoad];
WKWebViewConfiguration* configuration = [[WKWebViewConfiguration alloc] init];
configuration.userContentController = [[WKUserContentController alloc] init];
WKUserContentController *userCC = configuration.userContentController;
[userCC addScriptMessageHandler:self name:@"fetchMessages"];
WKWebView wkWebView = [[WKWebView alloc] initWithFrame:self.view.frame configuration:configuration];
}
- (void)userContentController:(WKUserContentController *)userContentController didReceiveScriptMessage:(WKScriptMessage *)message {
if ([message.name isEqualToString:@"fetchMessages"]) {
NSLog(@"前端傳遞的數(shù)據(jù) %@: ",message.body);
}
}
項(xiàng)目中的 JSBridge 方案
目前項(xiàng)目里和前端配合盼铁,使用了 Kerkee 跨平臺(tái) hybrid 框架粗蔚,僅使用到里面的 JSBridge 模塊。
框架內(nèi)判斷 iOS 8 以上使用 WKWebView饶火,并使用向 JavaScriptContext 注入 API 的方案來(lái)實(shí)現(xiàn)鹏控;iOS 8 以前使用攔截 URL 重定向的方案來(lái)實(shí)現(xiàn),并有進(jìn)行 message queue 和 callback function handler 的優(yōu)化肤寝。
參考與拓展
WKWebView 彈窗攔截
NSURLProtocol
JavaScriptCore
JavaScriptCore 踩坑(內(nèi)存管理当辐、線程安全等)
WebViewJavascriptBridge
UIWebView-TS_JavaScriptContext
WKWebView 踩坑(Cookie、NSURLProtocol 等)
JavaScriptCore 美團(tuán)
JavaScriptCore nshipster
JavaScriptCore 加載時(shí)機(jī)
JavaScriptCore 線程安全