最近一年一直在公司忙混合開發,咱們主要是 h5 實現業務,native 提供能力,好比下載文件,同步會議信息到手機本地日曆等等。中間不免會涉及到 h5 和 native 之間的通訊,開始是本身實現的,用起來很是難用,好比iOS和安卓兩端接受參數的方式不一樣,不支持傳入js回調等等。因此後來找了一個很好用的開源的 bridge,連接在下面。javascript
同時燃起了 jsBridge 內部實現的興趣,因而有了這個項目。react
本項目以 js 與 android 通訊爲例,講解 JSBridge 實現原理,下面提到的方法在 iOS(UIWebview 或 WKWebview)均有對應方法。android
項目地址 github.com/mcuking/JSB…git
兩種 native 調用 js 方法,注意被調用的方法須要在 JS 全局上下文上github
mWebview.loadUrl("javascript: func()");
複製代碼
mWebview.evaluateJavascript("javascript: func()", new ValueCallback<String>() {
@Override
public void onReceiveValue(String value) {
return;
}
});
複製代碼
方式 | 優勢 | 缺點 |
---|---|---|
loadUrl | 兼容性好 | 1. 會刷新頁面 2. 沒法獲取 js 方法執行結果 |
evaluateJavascript | 1. 性能好 2. 可獲取 js 執行後的返回值 | 僅在安卓 4.4 以上可用 |
三種 js 調用 native 方法web
即由 h5 發出一條新的跳轉請求,native 經過攔截 URL 獲取 h5 傳過來的數據。json
跳轉的目的地是一個非法不存在的 URL 地址,例如:安全
"jsbridge://methodName?{"data": arg, "cbName": cbName}"
複製代碼
具體示例以下:
"jsbridge://openScan?{"data": {"scanType": "qrCode"}, "cbName": "handleScanResult"}"
複製代碼
h5 和 native 約定一個通訊協議,例如 jsbridge, 同時約定調用 native 的方法名 methodName 做爲域名,以及後面帶上調用該方法的參數 arg,和接收該方法執行結果的 js 方法名 cbName。
具體能夠在 js 端封裝相關方法,供業務端統一調用,代碼以下:
window.callbackId = 0;
function callNative(methodName, arg, cb) {
const args = {
data: arg === undefined ? null : JSON.stringify(arg),
};
if (typeof cb === 'function') {
const cbName = 'CALLBACK' + window.callbackId++;
window[cbName] = cb;
args['cbName'] = cbName;
}
const url = 'jsbridge://' + methodName + '?' + JSON.stringify(args);
...
}
複製代碼
以上封裝中較爲巧妙的是將用於接收 native 執行結果的 js 回調方法 cb 掛載到 window 上,併爲防止命名衝突,經過全局的 callbackId 來區分,而後將該回調函數在 window 上的名字放在參數中傳給 native 端。native 拿到 cbName 後,執行完方法後,將執行結果經過 native 調用 js 的方式(上面提到的兩種方法),調用 cb 傳給 h5 端(例如將掃描結果傳給 h5)。
至於如何在 h5 中發起請求,能夠設置 window.location.href 或者建立一個新的 iframe 進行跳轉。
function callNative(methodName, arg, cb) {
...
const url = 'jsbridge://' + method + '?' + JSON.stringify(args);
// 經過 location.href 跳轉
window.location.href = url;
// 經過建立新的 iframe 跳轉
const iframe = document.createElement('iframe');
iframe.src = url;
iframe.style.width = 0;
iframe.style.height = 0;
document.body.appendChild(iframe);
window.setTimeout(function() {
document.body.removeChild(iframe);
}, 800);
}
複製代碼
native 會攔截 h5 發出的請求,當檢測到協議爲 jsbridge 而非普通的 http/https/file 等協議時,會攔截該請求,解析出 URL 中的 methodName、arg、 cbName,執行該方法並調用 js 回調函數。
下面以安卓爲例,經過覆蓋 WebViewClient 類的 shouldOverrideUrlLoading 方法進行攔截,android 端具體封裝會在下面單獨的板塊進行說明。
import android.util.Log;
import android.webkit.WebView;
import android.webkit.WebViewClient;
public class JSBridgeViewClient extends WebViewClient {
@Override
public boolean shouldOverrideUrlLoading(WebView view, String url) {
JSBridge.call(view, url);
return true;
}
}
複製代碼
以下代碼:
window.location.href = "jsbridge://callNativeNslog?{"data": "111", "cbName": ""}";
window.location.href = "jsbridge://callNativeNslog?{"data": "222", "cbName": ""}";
複製代碼
js 此時的訴求是在同一個運行邏輯內,快速的連續發送出 2 個通訊請求,用客戶端自己 IDE 的 log,按順序打印 111,222,那麼實際結果是 222 的通訊消息根本收不到,直接會被系統拋棄丟掉。
緣由:由於 h5 的請求歸根結底是一種模擬跳轉,跳轉這件事情上 webview 會有限制,當 h5 連續發送多條跳轉的時候,webview 會直接過濾掉後發的跳轉請求,所以第二個消息根本收不到,想要收到怎麼辦?js 裏將第二條消息延時一下。
//發第一條消息
location.href = "jsbridge://callNativeNslog?{"data": "111", "cbName": ""}";
//延時發送第二條消息
setTimeout(500,function(){
location.href = "jsbridge://callNativeNslog?{"data": "222", "cbName": ""}";
});
複製代碼
但這並不能保證此時是否有其餘地方經過這種方式進行請求,爲系統解決此問題,js 端能夠封裝一層隊列,全部 js 代碼調用消息都先進入隊列並不馬上發送,而後 h5 會週期性好比 500 毫秒,清空一次隊列,保證在很快的時間內絕對不會連續發 2 次請求通訊。
若是須要傳輸的數據較長,例如方法參數不少時,因爲 URL 長度限制,仍以丟失部分數據。
即由 h5 發起 alert confirm prompt,native 經過攔截 prompt 等獲取 h5 傳過來的數據。
由於 alert confirm 比較經常使用,因此通常經過 prompt 進行通訊。
約定的傳輸數據的組合方式以及 js 端封裝方法的能夠相似上面的 攔截 URL Schema 提到的方式。
function callNative(methodName, arg, cb) {
...
const url = 'jsbridge://' + method + '?' + JSON.stringify(args);
prompt(url);
}
複製代碼
native 會攔截 h5 發出的 prompt,當檢測到協議爲 jsbridge 而非普通的 http/https/file 等協議時,會攔截該請求,解析出 URL 中的 methodName、arg、 cbName,執行該方法並調用 js 回調函數。
下面以安卓爲例,經過覆蓋 WebChromeClient 類的 onJsPrompt 方法進行攔截,android 端具體封裝會在下面單獨的板塊進行說明。
import android.webkit.JsPromptResult;
import android.webkit.WebChromeClient;
import android.webkit.WebView;
public class JSBridgeChromeClient extends WebChromeClient {
@Override
public boolean onJsPrompt(WebView view, String url, String message, String defaultValue, JsPromptResult result) {
result.confirm(JSBridge.call(view, message));
return true;
}
}
複製代碼
這種方式沒有太大缺點,也不存在連續發送時信息丟失。不過 iOS 的 UIWebView 不支持該方式(WKWebView 支持)。
即由 native 將實例對象經過 webview 提供的方法注入到 js 全局上下文,js 能夠經過調用 native 的實例方法來進行通訊。
具體有安卓 webview 的 addJavascriptInterface,iOS UIWebview 的 JSContext,iOS WKWebview 的 scriptMessageHandler。
下面以安卓 webview 的 addJavascriptInterface 爲例進行講解。
首先 native 端注入實例對象到 js 全局上下文,代碼大體以下,具體封裝會在下面的單獨板塊進行講解:
public class MainActivity extends AppCompatActivity {
private WebView mWebView;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
mWebView = (WebView) findViewById(R.id.mWebView);
...
// 將 NativeMethods 類下面的提供給 js 的方法轉換成 hashMap
JSBridge.register("JSBridge", NativeMethods.class);
// 將 JSBridge 的實例對象注入到 js 全局上下文中,名字爲 _jsbridge,該實例對象下有 call 方法
mWebView.addJavascriptInterface(new JSBridge(mWebView), "_jsbridge");
}
}
public class NativeMethods {
// 用來供 js 調用的方法
public static void methodName(WebView view, JSONObject arg, CallBack callBack) {
}
}
public class JSBridge {
private WebView mWebView;
public JSBridge(WebView webView) {
this.mWebView = webView;
}
private static Map<String, HashMap<String, Method>> exposeMethods = new HashMap<>();
// 靜態方法,用於將傳入的第二個參數的類下面用於提供給 javacript 的接口轉成 Map,名字爲第一個參數
public static void register(String exposeName, Class<?> classz) {
...
}
// 實例方法,用於提供給 js 統一調用的方法
@JavascriptInterface
public String call(String methodName, String args) {
...
}
}
複製代碼
而後 h5 端能夠在 js 調用 window._jsbridge 實例下面的 call 方法,傳入的數據組合方式能夠相似上面兩種方式。具體代碼以下:
window.callbackId = 0;
function callNative(method, arg, cb) {
let args = {
data: arg === undefined ? null : JSON.stringify(arg)
};
if (typeof cb === 'function') {
const cbName = 'CALLBACK' + window.callbackId++;
window[cbName] = cb;
args['cbName'] = cbName;
}
if (window._jsbridge) {
window._jsbridge.call(method, JSON.stringify(args));
}
}
複製代碼
以安卓 webview 的 addJavascriptInterface 爲例,在安卓 4.2 版本以前,js 能夠利用 java 的反射 Reflection API,取得構造該實例對象的類的內部信息,並能直接操做該對象的內部屬性及方法,這種方式會形成安全隱患,例如若是加載了外部網頁,該網頁的惡意 js 腳本能夠獲取手機的存儲卡上的信息。
在安卓 4.2 版本後,能夠經過在提供給 js 調用的 java 方法前加裝飾器 @JavascriptInterface,來代表僅該方法能夠被 js 調用。
方式 | 優勢 | 缺點 |
---|---|---|
攔截 Url Schema(假請求) | 無安全漏洞 | 1. 連續發送時消息丟失 2. Url 長度限制,傳輸數據大小受限 |
攔截 prompt alert confirm | 無安全漏洞 | iOS 的 UIWebView 不支持該方式 |
注入 JS 上下文 | 官方提供,方便簡捷 | 在安卓 4.2 如下有安全漏洞 |
native 與 h5 交互部分的代碼在上面已經提到了,這裏主要是講述 native 端如何封裝暴露給 h5 的方法。
首先單獨封裝一個類 NativeMethods,將供 h5 調用的方法以公有且靜態方法的形式寫入。以下:
public class NativeMethods {
public static void showToast(WebView view, JSONObject arg, CallBack callBack) {
...
}
}
複製代碼
接下來考慮如何在 NativeMethods 和 h5 以前創建一個橋樑,JSBridge 類因運而生。 JSBridge 類下主要有兩個靜態方法 register 和 call。其中 register 方法是用來將供 h5 調用的方法轉化成 Map 形式,以便查詢。而 call 方法主要是用接收 h5 端的調用,分解 h5 端傳來的參數,查找並調用 Map 中的對應的 Native 方法。
首先在 JSBridge 類下聲明一個靜態屬性 exposeMethods,數據類型爲 HashMap 。而後聲明靜態方法 register,參數有字符串 exposeName 和類 classz,將 exposeName 和 classz 的全部靜態方法 組合成一個 map,例如:
{
jsbridge: {
showToast: ...
openScan: ...
}
}
複製代碼
代碼以下:
private static Map<String, HashMap<String, Method>> exposeMethods = new HashMap<>();
public static void register(String exposeName, Class<?> classz) {
if (!exposeMethods.containsKey(exposeName)) {
exposeMethods.put(exposeName, getAllMethod(classz));
}
}
複製代碼
由上可知咱們須要定義一個 getAllMethod 方法用來將類裏的方法轉化爲 HashMap 數據格式。在該方法裏一樣聲明一個 HashMap,並將知足條件的方法轉化成 Map,key 爲方法名,value 爲方法。
其中條件爲 公有 public 靜態 static 方法且第一個參數爲 Webview 類的實例,第二個參數爲 JSONObject 類的實例,第三個參數爲 CallBack 類的實例。 (CallBack 是自定義的類,後面會講到) 代碼以下:
private static HashMap<String, Method> getAllMethod(Class injectedCls) {
HashMap<String, Method> methodHashMap = new HashMap<>();
Method[] methods = injectedCls.getDeclaredMethods();
for (Method method: methods) {
if(method.getModifiers()!=(Modifier.PUBLIC | Modifier.STATIC) || method.getName()==null) {
continue;
}
Class[] parameters = method.getParameterTypes();
if (parameters!=null && parameters.length==3) {
if (parameters[0] == WebView.class && parameters[1] == JSONObject.class && parameters[2] == CallBack.class) {
methodHashMap.put(method.getName(), method);
}
}
}
return methodHashMap;
}
複製代碼
因爲注入 JS 上下文和兩外兩種,h5 端傳過來的參數形式不一樣,因此處理參數的方式略有不一樣。 下面以攔截 Prompt 的方式爲例進行講解,在該方式中 call 接收的第一個參數爲 webView,第二個參數是 arg,即 h5 端傳過來的參數。還記得攔截 Prompt 方式時 native 端和 h5 端約定的傳輸數據的方式麼?
"jsbridge://openScan?{"data": {"scanType": "qrCode"}, "cbName":"handleScanResult"}"
複製代碼
call 方法首先會判斷字符串是否以 jsbridge 開頭(native 端和 h5 端之間約定的傳輸數據的協議名),而後該字符串轉成 Uri 格式,而後獲取其中的 host 名,即方法名,獲取 query,即方法參數和 js 回調函數名組合的對象。最後查找 exposeMethods 的映射,找到對應的方法並執行該方法。
public static String call(WebView webView, String urlString) {
if (!urlString.equals("") && urlString!=null && urlString.startsWith("jsbridge")) {
Uri uri = Uri.parse(urlString);
String methodName = uri.getHost();
try {
JSONObject args = new JSONObject(uri.getQuery());
JSONObject arg = new JSONObject(args.getString("data"));
String cbName = args.getString("cbName");
if (exposeMethods.containsKey("JSBridge")) {
HashMap<String, Method> methodHashMap = exposeMethods.get("JSBridge");
if (methodHashMap!=null && methodHashMap.size()!=0 && methodHashMap.containsKey(methodName)) {
Method method = methodHashMap.get(methodName);
if (method!=null) {
method.invoke(null, webView, arg, new CallBack(webView, cbName));
}
}
}
} catch (Exception e) {
e.printStackTrace();
}
}
return null;
}
複製代碼
js 調用 native 方法成功後,native 有必要返回給 js 一些反饋,例如接口是否調用成功,或者 native 執行後的獲得的數據(例如掃碼)。因此 native 須要執行 js 回調函數。
執行 js 回調函數方式本質是 native 調用 h5 的 js 方法,方式仍舊是上面提到的兩種方式 evaluateJavascript 和 loadUrl。簡單來講能夠直接將 js 的回調函數名傳給對應的 native 方法,native 執行經過 evaluateJavascript 調用。
但爲了統一封裝調用回調的方式,咱們能夠定義一個 CallBack 類,在其中定義一個名爲 apply 的靜態方法,該方法直接調用 js 回調。
注意:native 執行 js 方法須要在主線程上。
public class CallBack {
private String cbName;
private WebView mWebView;
public CallBack(WebView webView, String cbName) {
this.cbName = cbName;
this.mWebView = webView;
}
public void apply(JSONObject jsonObject) {
if (mWebView!=null) {
mWebView.post(() -> {
mWebView.evaluateJavascript("javascript:" + cbName + "(" + jsonObject.toString() + ")", new ValueCallback<String>() {
@Override
public void onReceiveValue(String value) {
return;
}
});
});
}
}
}
複製代碼
到此爲止,JSBridge 的大體原理都講完了。但功能仍可再加完善,例如:
native 執行 js 方法時,可接受 js 方法中異步返回的數據,好比在 js 方法中請求某個接口在返回數據。直接調用 webview 提供的 evaluateJavascript,在第二個參數的類 ValueCallback 的實例方法 onReceiveValue 並不能接收到 js 異步返回的數據。
後面有空 native 調用 js 方式會繼續完善的。
另外最近正在寫一個編譯 Vue 代碼到 React 代碼的轉換器,歡迎你們查閱。