上一篇介紹了移動端開發的相關技術,這一篇主要是從 Hybrid 開發的 JS Bridge 通訊講起。javascript
顧名思義,JS Bridge 的意思就是橋,這是一個鏈接 JS 和 Native 的橋接,也是 Hybrid App 裏面的核心。通常分爲 JS 調用 Native 和 Native 主動調用 JS 兩種形式。前端
URL Scheme 是一種特殊的 URL,通常用於在 Web 端喚醒 App,甚至跳轉到 App 的某個頁面,好比在某個手機網站上付款的時候,能夠直接拉起支付寶支付頁面。java
這裏有個小例子,你能夠在瀏覽器裏面直接輸入 weixin://
,系統就會提示你是否要打開微信。輸入 mqq://
就會幫你喚起手機 QQ。android
這裏有個經常使用 App URL Scheme 彙總:URL Schemes 收集整理ios
在手機裏面打開這個頁面後點擊這裏,就會提示你是否要打開微信。git
咱們常說的 Deeplink 通常也是基於 URL Scheme 來實現的。一個 URI 的組成結構以下:github
URI = scheme:[//authority]path[?query][#fragment]
// scheme = http
// authority = www.baidu.com
// path = /link
// query = url=xxxxx
authority = [userinfo@]host[:port]
複製代碼
除了 http/https 這兩個常見的協議,還能夠自定義協議。借用維基百科的一張圖:web
一般狀況下,App 安裝後會在手機系統上註冊一個 Scheme,好比 weixin://
這種,因此咱們在手機瀏覽器裏面訪問這個 scheme 地址,系統就會喚起咱們的 App。npm
通常在 Android 裏面須要到 AndroidManifest.xml 文件中去註冊 Scheme:axios
<activity
android:name=".login.dispatch.DispatchActivity"
android:launchMode="singleTask"
android:theme="@style/AppDispatchTheme">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="taobao" />
<data android:host="xxx" />
<data android:path="/goods" />
</intent-filter>
</activity>
複製代碼
在 iOS 中須要在 Xcode 裏面註冊,有一些已是系統使用的不該該使用,好比 Maps、YouTube、Music。具體能夠參考蘋果開發者官網文檔:Defining a Custom URL Scheme for Your App
在 iOS 裏面又須要區分 UIWebView 和 WKWebView 兩種 WebView:
WKWebView 是 iOS8 以後出現的,目的是取代笨重的 UIWebView,它佔用內存更少,大概是 UIWebView 的 1/3,支持更好的 HTML5 特性,性能更增強大。 但也有一些缺點,好比不支持緩存,須要本身注入 Cookie,發送 POST 請求的時候帶不了參數,攔截 POST 請求的時候沒法解析參數等等。
JS 調用 Native 通訊大體有三種方法:
這三種方式整體上各有利弊,下面會一一介紹。
仔細思考一下,若是是 JS 和 Java 之間傳遞數據,咱們該怎麼作呢? 對於前端開發來講,調 Ajax 請求接口是最多見的需求了。無論對方是 Java 仍是 Python,咱們均可以經過 http/https 接口來獲取數據。實際上這個流程和 JSONP 更加相似。
已知客戶端是能夠攔截請求的,那麼可不能夠在這個上面作文章呢?
若是咱們請求一個不存在的地址,上面帶了一些參數,經過參數告訴客戶端咱們須要調用的功能呢?
好比我要調用掃碼功能:
axios.get('http://xxxx?func=scan&callback_id=yyyy')
複製代碼
客戶端能夠攔截這個請求,去解析參數上面的 func
來判斷當前須要調起哪一個功能。客戶端調起掃碼功能以後,會獲取 WebView 上面的 callbacks 對象,根據 callback_id 回調它。
因此基於上面的例子,咱們能夠把域名和路徑當作通訊標識,參數裏面的 func 當作指令,callback_id 當作回調函數,其餘參數當作數據傳遞。對於不知足條件的 http 請求不該該攔截。
固然了,如今主流的方式是前面咱們看到的自定義 Scheme 協議,以這個爲通訊標識,域名和路徑當作指令。
這種方式的好處就是 iOS6 之前只支持這種方式,兼容性比較好。
咱們有不少種方法能夠發起請求,目前使用最普遍的是 iframe 跳轉:
<a href="taobao://">點擊我打開淘寶</a>
複製代碼
location.href = "taobao://"
複製代碼
const iframe = document.createElement("iframe");
iframe.src = "taobao://"
iframe.style.display = "none"
document.body.appendChild(iframe)
複製代碼
在 Android 側能夠用 shouldOverrideUrlLoading
來攔截 url 請求。
@Override
public boolean shouldOverrideUrlLoading(WebView view, String url) {
if (url.startsWith("taobao")) {
// 拿到調用路徑後解析調用的指令和參數,根據這些去調用 Native 方法
return true;
}
}
複製代碼
在 iOS 側須要區分 UIWebView 和 WKWebView 兩種方式。 在 UIWebView 中:
- (BOOL)shouldStartLoadWithRequest:(NSURLRequest *)request
navigationType:(BPWebViewNavigationType)navigationType
{
if (xxx) {
// 拿到調用路徑後解析調用的指令和參數,根據這些去調用 Native 方法
return NO;
}
return [super shouldStartLoadWithRequest:request navigationType:navigationType];
}
複製代碼
在 WKWebView 中:
- (void)webView:(WKWebView *)webView decidePolicyForNavigationAction:(nonnull WKNavigationAction *)navigationAction decisionHandler:(nonnull void (^)(WKNavigationActionPolicy))decisionHandler
{
if(xxx) {
// 拿到調用路徑後解析調用的指令和參數,根據這些去調用 Native 方法
BLOCK_EXEC(decisionHandler, WKNavigationActionPolicyCancel);
} else {
BLOCK_EXEC(decisionHandler, WKNavigationActionPolicyAllow);
}
[self.webView.URLLoader webView:webView decidedPolicy:policy forNavigationAction:navigationAction];
}
複製代碼
目前不建議只使用攔截 URL Scheme 解析參數的形式,主要存在幾個問題。
location.href
會出現消息丟失,由於 WebView 限制了連續跳轉,會過濾掉後續的請求。所以,相似 WebViewJavaScriptBridge 這類庫,就結合了注入 API 的形式一塊兒使用,這也是咱們這邊目前使用的方式,後面會介紹一下。
這種方式是利用彈窗會觸發 WebView 相應事件來攔截的。通常是在 setWebChromeClient
裏面的 onJsAlert
、onJsConfirm
、onJsPrompt
方法攔截並解析他們傳來的消息。
// 攔截 Prompt
@Override
public boolean onJsPrompt(WebView view, String url, String message, String defaultValue, JsPromptResult result) {
if (xxx) {
// 解析 message 的值,調用對應方法
}
return super.onJsPrompt(view, url, message, defaultValue, result);
}
// 攔截 Confirm
@Override
public boolean onJsConfirm(WebView view, String url, String message, JsResult result) {
return super.onJsConfirm(view, url, message, result);
}
// 攔截 Alert
@Override
public boolean onJsAlert(WebView view, String url, String message, JsResult result) {
return super.onJsAlert(view, url, message, result);
}
複製代碼
咱們以 WKWebView 爲例:
+ (void)webViewRunJavaScriptTextInputPanelWithPrompt:(NSString *)prompt
defaultText:(NSString *)defaultText
completionHandler:(void (^)(NSString * _Nullable))completionHandler
{
/** Triggered by JS:
var person = prompt("Please enter your name", "Harry Potter");
if (person == null || person == "") {
txt = "User cancelled the prompt.";
} else {
txt = "Hello " + person + "! How are you today?";
}
*/
if (xxx) {
BLOCK_EXEC(completionHandler, text);
} else {
BLOCK_EXEC(completionHandler, nil);
}
}
複製代碼
這種方式的缺點就是在 iOS 上面 UIWebView 不支持,可是 WKWebView 又有更好的 scriptMessageHandler
,比較尷尬。
前面咱們有講過在 iOS 中內置了 JavaScriptCore 這個框架,能夠實現執行 JS 以及注入 Native 對象等功能。
這種方式不依賴攔截,主要是經過 WebView 向 JS 的上下文注入對象和方法,可讓 JS 直接調用原生。
PS:iOS 中的 Block 是 OC 對於閉包的實現,它本質上是個對象,定義 JS 裏面的函數。
iOS 側代碼:
// 獲取 JS 上下文
JSContext *context = [webview valueForKeyPath:@"documentView.webView.mainFrame.javaScriptContext"];
// 注入 Block
context[@"callHandler"] = ^(JSValue * data) {
// 處理調用方法和參數
// 調用 Native 功能
// 回調 JS Callback
}
複製代碼
JS 代碼:
window.callHandler({
type: "scan",
data: "",
callback: function(data) {
}
});
複製代碼
這種方式的牛逼之處在於,JS 調用是同步的,能夠立馬拿到返回值。
咱們也再也不須要像攔截方式同樣,每次傳值都要把對象作 JSON.stringify
,能夠直接傳 JSON 過去,也支持直接傳一個函數過去。
WKWebView 裏面經過 addScriptMessageHandler
來注入對象到 JS 上下文,能夠在 WebView 銷燬的時候調用 removeScriptMessageHandler
來銷燬這個對象。 前端調用注入的原生方法以後,能夠經過 didReceiveScriptMessage
來接收前端傳過來的參數。
WKWebView *wkWebView = [[WKWebView alloc] init];
WKWebViewConfiguration *configuration = wkWebView.configuration;
WKUserContentController *userCC = configuration.userContentController;
// 注入對象
[userCC addScriptMessageHandler:self name:@"nativeObj"];
// 清除對象
[userCC removeScriptMessageHandler:self name:@"nativeObj"];
// 客戶端處理前端調用
- (void)userContentController:(WKUserContentController *)userContentController didReceiveScriptMessage:(WKScriptMessage *)message
{
// 獲取前端傳來的參數
NSDictionary *msgBody = message.body;
// 若是是 nativeObj 就進行相應處理
if (![message.name isEqualToString:@"nativeObj"]) {
//
return;
}
}
複製代碼
使用 addScriptMessageHandler
注入的對象實際上只有一個 postMessage
方法,沒法調用更多自定義方法。前端的調用方式以下:
window.webkit.messageHandlers.nativeObj.postMessage(data);
複製代碼
須要注意的是,這種方式要求 iOS8 及以上,並且返回不是同步的。和 UIWebView 同樣的是,也支持直接傳 JSON 對象,不須要 stringify。
安卓4.2以前注入 JS 通常是使用 addJavascriptInterface
,和前面的 addScriptMessageHandler
有一些相似,但又沒有它的限制。
public void addJavascriptInterface() {
mWebView.addJavascriptInterface(new DatePickerJSBridge(), "DatePickerBridge");
}
private class PickerJSBridge {
public void _pick(...) {
}
}
複製代碼
在 JS 裏面調用:
window.DatePickerBridge._pick(...)
複製代碼
但這種方案有必定風險,能夠參考這篇文章:WebView中接口隱患與手機掛馬利用 在 Android4.2 以後提供了 @JavascriptInterface
註解,暴露給 JS 的方法必需要帶上這個。 因此前面的 _pick
方法須要帶上這個註解。
private class PickerJSBridge {
@JavascriptInterface
public void _pick(...) {
}
}
複製代碼
Native 調用 JS 通常就是直接 JS 代碼字符串,有些相似咱們調用 JS 中的 eval
去執行一串代碼。通常有 loadUrl
、evaluateJavascript
等幾種方法,這裏逐一介紹。 可是無論哪一種方式,客戶端都只能拿到掛載到 window
對象上面的屬性和方法。
在 Android 裏面須要區分版本,在安卓4.4以前的版本支持 loadUrl,使用方式相似咱們在 a 標籤的 href
裏面寫 JS 腳本同樣,都是javascript:xxx
的形式。這種方式沒法直接獲取返回值。
webView.loadUrl("javascript:foo()")
複製代碼
在安卓4.4以上的版本通常使用 evaluateJavascript
這個 API 來調用。這裏須要判斷一下版本。
if (Build.VERSION.SDK_INT > 19) //see what wrapper we have
{
webView.evaluateJavascript("javascript:foo()", null);
} else {
webView.loadUrl("javascript:foo()");
}
複製代碼
在 iOS 的 UIWebView 裏面使用 stringByEvaluatingJavaScriptFromString
來調用 JS 代碼。這種方式是同步的,會阻塞線程。
results = [self.webView stringByEvaluatingJavaScriptFromString:"foo()"];
複製代碼
WKWebView 可使用 evaluateJavaScript
方法來調用 JS 代碼。
[self.webView evaluateJavaScript:@"document.body.offsetHeight;" completionHandler:^(id _Nullable response, NSError * _Nullable error) {
// 獲取返回值 response
}];
複製代碼
前面講完了 JS 和 Native 互調的全部方法,這裏來介紹一下咱們這邊 JS Bridge 的設計吧。 咱們這邊的 JS Bridge 通訊是基於 WebViewJavascriptBridge 這個庫來實現的。主要是結合 Scheme 協議+上下文注入來作。考慮到 Android 和 iOS 不同的通訊方式,這裏進行了封裝,保證提供給外部的 API 一致。 具體功能的調用咱們封裝成了 npm 包,下面的是幾個基礎 API:
pageDidBack
這種場景。那麼這幾個 API 又是如何實現的呢?這裏 Android 和 iOS 封裝不一致,應當分開來講。
前面咱們有說過安卓能夠經過 @JavascriptInterface
註解來將對象和方法暴露給 JS。因此這裏的幾個方法都是經過註解暴露給 JS 來調用的,在 JS 層面作了一些兼容處理。
首先最簡單的是這個 hasHandler
,就是在客戶端裏面維護一張表(其實咱們是寫死的),裏面有支持的 Bridge 模塊信息,只須要用 switch...case
判斷一下就好了。
@JavascriptInterface
public boolean hasHandler(String cmd) {
switch (cmd) {
case xxx:
case yyy:
case zzz:
return true;
}
return false;
}
複製代碼
而後咱們來看 callHandler
這個方法,它是提供 JS 調用 Native 功能的方法。在調用這個方法以前,咱們通常須要先判斷一下 Native 是否支持這個功能。
function callHandler(name, params, callback) {
if (!window.WebViewJavascriptBridge.hasHandler(name)) {
}
}
複製代碼
若是 Native 沒有支持這個 Bridge,咱們就須要對回調進行兼容性處理。這個兼容性處理包括兩個方面,一個是功能方面,一個是 callback 的默認回參。
好比咱們調用 Native 的彈窗功能,若是客戶端沒支持這個 Bridge,或者咱們是在瀏覽器裏面打開的這個頁面,此時應該退出到使用 Web 的 alert
彈窗。對於 callback,咱們能夠默認給傳個 0,表示當前不支持這個功能。
假設這個 alert
的 bridge 接收兩個參數,分別是 title
和 content
,那麼此時就應該使用瀏覽器自帶的 alert
展現出來。
function fallback(params, callback) {
let content = `${params.title}\n{params.content}`
window.alert(content);
callback && callback(0)
}
複製代碼
這個 fallback
函數咱們但願可以更加通用,每一個調用方法都應該有本身的 fallback
函數,因此前面的 callHandler
應該設計成這樣:
function callHandler(name, params, fallback) {
return function(...rest, callback) {
const paramsList = {};
for (let i = 0; i < params.length; i++) {
paramsList[params] = rest[i];
}
if (!callback) {
callback = function(result) {};
}
if (fallback && !window.WebViewJavascriptBridge.hasHandler(name))) {
fallback(paramsList, callback);
} else {
window.WebViewJavascriptBridge.callHandler(name, params, callback);
}
}
}
複製代碼
咱們能夠基於這個函數封裝一些功能方法,好比前面的 alert:
function fallback(params, callback) {
let content = `${params.title}\n{params.content}`
window.alert(content);
callback && callback(0)
}
function alert( title, content, cb: any ) {
return callHandler(
'alert',
['title', 'content'],
fallback
)(title, content, cb);
}
alert(`this is title`, `hahaha`, function() {
console.log('success')
})
複製代碼
具體效果相似下面這種,這是從 Google 上隨便找的一張圖(侵刪):
那麼客戶端又如何實現回調 callback 函數的呢?前面說過,客戶端想調用 JS 方法,只能調用掛載到 window
對象上面的。
所以,這裏使用了一種很巧妙的方法,實際上 callback 函數依然是 JS 執行的。在調用 Native 以前,咱們能夠先將 callback 函數和一個 uniqueId 映射起來,而後存在 JS 本地。咱們只須要將 callbackId 傳給 Native 就好了。
function callHandler(name, data, callback) {
const id = `cb_${uniqueId++}_${new Date().getTime()}`;
callbacks[id] = callback;
window.bridge.send(name, JSON.stringify(data), id)
}
複製代碼
在客戶端這裏,當 send 方法接收到參數以後,會執行相應功能,而後使用 webView.loadUrl
主動調用前端的一個接收函數。
@JavascriptInterface
public void send(final String cmd, String data, final String callbackId) {
// 獲取數據,根據 cmd 來調用對應功能
// 調用結束後,回調前端 callback
String js = String.format("javascript: window.bridge.onReceive(\'%1$s\', \'%2$s\');", callbackId, result.toDataString());
webView.loadUrl(js);
}
複製代碼
因此 JS 須要事前定義好這個 onReceive
方法,它接收一個 callbackId 和一個 result。
window.bridge.onReceive = function(callbackId, result) {
let params = {};
try {
params = JSON.parse(result)
} catch (err) {
//
}
if (callbackId) {
const callback = callbacks[callbackId];
callback(params)
delete callbacks[callbackId];
}
}
複製代碼
大體流程以下:
註冊的流程比較簡單,也是咱們把 callback 函數事先存到一個 messageHandler
對象裏面,不過此次的 key 再也不是一個隨機的 id,而是 name
。
function registerHandler(handlerName, callback) {
if (!messageHandlers[handlerName]) {
messageHandlers[handlerName] = [handler];
} else {
// 支持註冊多個 handler
messageHandlers[handlerName].push(handler);
}
}
// 檢查是否有這個註冊能夠直接檢查 messageHandlers 裏面是否有
function hasRegisteredHandler(handlerName) {
let has = false;
try {
has = !!messageHandlers[handlerName];
} catch (exception) {}
return has;
}
複製代碼
這裏不像 callHandler
須要主動調用 window.bridge.send
去通知客戶端,只須要等客戶端到了相應的時機來調用 window.bridge.onReceive
就好了。 因此這裏還須要改造一下 onReceive
方法。因爲再也不會有 callbackId 了,因此客戶端能夠傳個空值,而後將 handlerName
放到 result 裏面。
window.bridge.onReceive = function(callbackId, result) {
let params = {};
try {
params = JSON.parse(result)
} catch (err) {
//
}
if (callbackId) {
const callback = callbacks[callbackId];
callback(params)
delete callbacks[callbackId];
} else if (params.handlerName)(
// 可能註冊了多個
const handlers = messageHandlers[params.handlerName];
for (let i = 0; i < handlers.length; i++) {
try {
delete params.handlerName;
handlers[i](params);
} catch (exception) {
}
}
)
}
複製代碼
這種狀況下的流程以下,能夠發現徹底不須要 JS 調用 Native:
講完了 Android,咱們再來說講 iOS,本來 iOS 能夠和 Android 設計一致,但是因爲種種緣由致使有很多差別。
iOS 和 Android 中最顯著的差別就在於這個 window.bridge.send
方法的實現,Android 裏面是直接調用 Native 的方法,iOS 中是經過 URL Scheme 的形式調用。
協議依然是 WebViewJavaScriptBridge 裏面的協議,URL Scheme 自己不會傳遞數據,只是告訴 Native 有新的調用。
而後 Native 會去調用 JS 的方法,獲取隊列裏面全部須要執行的方法。
因此咱們須要事先建立好一個 iframe,插入到 DOM 裏面,方便後續使用。
const CUSTOM_PROTOCOL_SCHEME = 'wvjbscheme';
const QUEUE_HAS_MESSAGE = '__WVJB_QUEUE_MESSAGE__';
function _createQueueReadyIframe(doc) {
messagingIframe = doc.createElement('iframe');
messagingIframe.style.display = 'none';
messagingIframe.src = CUSTOM_PROTOCOL_SCHEME + '://' + QUEUE_HAS_MESSAGE;
doc.documentElement.appendChild(messagingIframe);
}
複製代碼
每次調用的時候只須要複用這個 iframe 就好了。這裏是處理 callback 並通知 Native 的代碼:
function callHandler(handlerName, data, responseCallback) {
_doSend({ handlerName: handlerName, data: data }, responseCallback);
}
function _doSend( message, callback ) {
if (responseCallback) {
const callbackId = `cb_${uniqueId++}_${new Date().getTime()}`;
callbacks[callbackId] = callback;
message['callbackId'] = callbackId;
}
sendMessageQueue.push(message);
messagingIframe.src = CUSTOM_PROTOCOL_SCHEME + '://' + QUEUE_HAS_MESSAGE;
}
複製代碼
通知 Native 以後,它怎麼拿到咱們的 handlerName
和 data
呢?咱們能夠實現一個 fetchQueue
的方法。
function _fetchQueue() {
const messageQueueString = JSON.stringify(sendMessageQueue);
sendMessageQueue = [];
return messageQueueString;
}
複製代碼
而後將其掛載到 window.WebViewJavascriptBridge
對象上面。
window.WebViewJavascriptBridge = {
_fetchQueue: _fetchQueue
};
複製代碼
這樣 iOS 就可使用 evaluateJavaScript
輕鬆拿到這個 messageQueue
。
- (void)flushMessageQueue
{
[_webView evaluateJavaScript:@"WebViewJavascriptBridge._fetchQueue();" completionHandler:^(id _Nullable result, NSError * _Nullable error) {
[self _flushMessageQueue:result];
}];
}
- (void)_flushMessageQueue:(id)messageQueueObj
{
// 解析 messageQueueString
// 根據傳入的 handlerName 執行對應操做
}
複製代碼
那麼 iOS 又是如何回調 JS 的 callback 函數呢?這個其實和 Android 的 onReceive
是一樣的原理。這裏能夠實現一個 _handleMessageFromObjC
方法,一樣掛載到 window.WebViewJavascriptBridge
對象上面,等待 iOS 回調。
function _dispatchMessageFromObjC(messageJSON) {
const message = JSON.parse(messageJSON);
if (message.responseId) {
var responseCallback = callbacks[message.responseId];
if (!responseCallback) {
return;
}
responseCallback(message.responseData);
delete callbacks[message.responseId];
}
}
複製代碼
流程以下:
registerHandler 和 Android 原理是如出一轍的,都是提早註冊一個事件,等待 iOS 調用,具體就很少講了,這裏直接放代碼:
// 註冊
function registerHandler(handlerName, handler) {
if (typeof messageHandlers[handlerName] === 'undefined') {
messageHandlers[handlerName] = [handler];
} else {
messageHandlers[handlerName].push(handler);
}
}
// 回調
function _dispatchMessageFromObjC(messageJSON) {
const message = JSON.parse(messageJSON);
if (message.responseId) {
var responseCallback = callbacks[message.responseId];
if (!responseCallback) {
return;
}
responseCallback(message.responseData);
delete callbacks[message.responseId];
} else if (message.handlerName){
handlers = messageHandlers[message.handlerName];
for (let i = 0; i < handlers.length; i++) {
try {
handlers[i](message.data, responseCallback);
} catch (exception) {
}
}
}
}
複製代碼
這些就是 Hybrid 裏面 JS 和 Native 交互的大體原理,忽略了很多細節,好比初始化 WebViewJavascriptBridge
對象等等,感興趣的也能夠參考一下這個庫:JsBridge