在Android中,JSBridge已經不是什麼新鮮的事物了,各家的實現方式也略有差異。大多數人都知道WebView存在一個漏洞,見WebView中介面隱患與手機掛馬利用,雖然該漏洞已經在Android 4.2上修復了,即使用@JavascriptInterface代替addJavascriptInterface,但是由於相容性和安全性問題,基本上我們不會再利用Android系統為我們提供的addJavascriptInterface方法或者@JavascriptInterface註解來實現,所以我們只能另闢蹊徑,去尋找既安全,又能實現相容Android各個版本的方案。
首先我們來了解一下為什麼要使用JSBridge,在開發中,為了追求開發的效率以及移植的便利性,一些展示性強的頁面我們會偏向於使用h5來完成,功能性強的頁面我們會偏向於使用native來完成,而一旦使用了h5,為了在h5中儘可能的得到native的體驗,我們native層需要暴露一些方法給js呼叫,比如,彈Toast提醒,彈Dialog,分享等等,有時候甚至把h5的網路請求放著native去完成,而JSBridge做得好的一個典型就是微信,微信給開發者提供了JSSDK,該SDK中暴露了很多微信native層的方法,比如支付,定位等。
那麼,怎麼去實現一個相容Android各版本又具有一定安全性的JSBridge呢?我們知道,在WebView中,如果java要呼叫js的方法,是非常容易做到的,使用WebView.loadUrl(“javascript:function()”)即可,這樣,就做到了JSBridge的native層呼叫h5層的單向通訊,但是h5層如何調native層呢,我們需要尋找這麼一個通道,仔細回憶一下,WebView有一個方法,叫setWebChromeClient,可以設定WebChromeClient物件,而這個物件中有三個方法,分別是onJsAlert,onJsConfirm,onJsPrompt,當js呼叫window物件的對應的方法,即window.alert,window.confirm,window.prompt,WebChromeClient物件中的三個方法對應的就會被觸發,我們是不是可以利用這個機制,自己做一些處理呢?答案是肯定的。
至於js這三個方法的區別,可以詳見w3c JavaScript 訊息框 。一般來說,我們是不會使用onJsAlert的,為什麼呢?因為js中alert使用的頻率還是非常高的,一旦我們佔用了這個通道,alert的正常使用就會受到影響,而confirm和prompt的使用頻率相對alert來說,則更低一點。那麼到底是選擇confirm還是prompt呢,其實confirm的使用頻率也是不低的,比如你點一個連結下載一個檔案,這時候如果需要彈出一個提示進行確認,點選確認就會下載,點取消便不會下載,類似這種場景還是很多的,因此不能佔用confirm。而prompt則不一樣,在Android中,幾乎不會使用到這個方法,就是用,也會進行自定義,所以我們完全可以使用這個方法。該方法就是彈出一個輸入框,然後讓你輸入,輸入完成後返回輸入框中的內容。因此,佔用prompt是再完美不過了。
到這一步,我們已經找到了JSBridge雙向通訊的一個通道了,接下來就是如何實現的問題了。本文中實現的只是一個簡單的demo,如果要在生產環境下使用,還需要自己做一層封裝。
要進行正常的通訊,通訊協議的制定是必不可少的。我們回想一下熟悉的http請求url的組成部分。形如http://host:port/path?param=value,我們參考http,制定JSBridge的組成部分,我們的JSBridge需要傳遞給native什麼資訊,native層才能完成對應的功能,然後將結果返回呢?顯而易見我們native層要完成某個功能就需要呼叫某個類的某個方法,我們需要將這個類名和方法名傳遞過去,此外,還需要方法呼叫所需的引數,為了通訊方便,native方法所需的引數我們規定為json物件,我們在js中傳遞這個json物件過去,native層拿到這個物件再進行解析即可。為了區別於http協議,我們的jsbridge使用jsbridge協議,為了簡單起見,問號後面不適用鍵值對,我們直接跟上我們的json字串,於是就有了形如下面的這個uri
1 |
jsbridge://className:port/methodName?jsonObj |
有人會問,這個port用來幹嘛,其實js層呼叫native層方法後,native需要將執行結果返回給js層,不過你會覺得通過WebChromeClient物件的onJsPrompt方法將返回值返回給js不就好了嗎,其實不然,如果這麼做,那麼這個過程就是同步的,如果native執行非同步操作的話,返回值怎麼返回呢?這時候port就發揮了它應有的作用,我們在js中呼叫native方法的時候,在js中註冊一個callback,然後將該callback在指定的位置上快取起來,然後native層執行完畢對應方法後通過WebView.loadUrl呼叫js中的方法,回撥對應的callback。那麼js怎麼知道呼叫哪個callback呢?於是我們需要將callback的一個儲存位置傳遞過去,那麼就需要native層呼叫js中的方法的時候將儲存位置回傳給js,js再呼叫對應儲存位置上的callback,進行回撥。於是,完整的協議定義如下:
1 |
jsbridge://className:callbackAddress/methodName?jsonObj |
假設我們需要呼叫native層的Logger類的log方法,當然這個類以及方法肯定是遵循某種規範的,不是所有的java類都可以呼叫,不然就跟文章開頭的WebView漏洞一樣了,引數是msg,執行完成後js層要有一個回撥,那麼地址就如下
1 |
jsbridge://Logger:callbackAddress/log?{"msg":"native log"} |
至於這個callback物件的地址,可以儲存到js中的window物件中去。至於怎麼儲存,後文會慢慢倒來。
上面是js向native的通訊協議,那麼另一方面,native向js的通訊協議也需要制定,一個必不可少的元素就是返回值,這個返回值和js的引數做法一樣,通過json物件進行傳遞,該json物件中有狀態碼code,提示資訊msg,以及返回結果result,如果code為非0,則執行過程中發生了錯誤,錯誤資訊在msg中,返回結果result為null,如果執行成功,返回的json物件在result中。下面是兩個例子,一個成功呼叫,一個呼叫失敗。
1 2 3 4 5 |
{ "code":500, "msg":"method is not exist", "result":null } |
1 2 3 4 5 6 7 8 9 10 11 12 |
{ "code":0, "msg":"ok", "result":{ "key1":"returnValue1", "key2":"returnValue2", "key3":{ "nestedKey":"nestedValue" "nestedArray":["value1","value2"] } } } |
那麼這個結果如何返回呢,native呼叫js暴露的方法即可,然後將js層傳給native層的port一併帶上,進行呼叫即可,呼叫的方式就是通過WebView.loadUrl方式來完成,如下。
1 |
mWebView.loadUrl("javascript:JSBridge.onFinish(port,jsonObj);"); |
關於JsBridge.onFinish方法的實現,後面再敘述。前面我們提到了native層的方法必須遵循某種規範,不然就非常不安全了。在native中,我們需要一個JSBridge統一管理這些暴露給js的類和方法,並且能實時新增,這時候就需要這麼一個方法
1 |
JSBridge.register("jsName",javaClass.class) |
這個javaClass就是滿足某種規範的類,該類中有滿足規範的方法,我們規定這個類需要實現一個空介面,為什麼呢?主要作用就混淆的時候不會發生錯誤,還有一個作用就是約束JSBridge.register方法第二個引數必須是該介面的實現類。那麼我們定義這個介面
1 2 |
public interface IBridge{ } |
類規定好了,類中的方法我們還需要規定,為了呼叫方便,我們規定類中的方法必須是static的,這樣直接根據類而不必新建物件進行呼叫了(還要是public的),然後該方法不具有返回值,因為返回值我們在回撥中返回,既然有回撥,引數列表就肯定有一個callback,除了callback,當然還有前文提到的js傳來的方法呼叫所需的引數,是一個json物件,在java層中我們定義成JSONObject物件;方法的執行結果需要通過callback傳遞回去,而java執行js方法需要一個WebView物件,於是,滿足某種規範的方法原型就出來了。
1 2 3 |
public static void methodName(WebView web view,JSONObject jsonObj,Callback callback){ } |
js層除了上文說到的JSBridge.onFinish(port,jsonObj);方法用於回撥,應該還有一個方法提供呼叫native方法的功能,該函式的原型如下
1 |
JSBridge.call(className,methodName,params,callback) |
在call方法中再將引數組合成形如下面這個格式的uri
1 |
jsbridge://className:callbackAddress/methodName?jsonObj |
然後呼叫window.prompt方法將uri傳遞過去,這時候java層就會收到這個uri,再進一步解析即可。
萬事具備了,只欠如何編碼了,別急,下面我們一步一步的來實現,先完成js的兩個方法。新建一個檔案,命名為JSBridge.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 |
(function (win) { var hasOwnProperty = Object.prototype.hasOwnProperty; var JSBridge = win.JSBridge || (win.JSBridge = {}); var JSBRIDGE_PROTOCOL = 'JSBridge'; var Inner = { callbacks: {}, call: function (obj, method, params, callback) { console.log(obj+" "+method+" "+params+" "+callback); var port = Util.getPort(); console.log(port); this.callbacks[port] = callback; var uri=Util.getUri(obj,method,params,port); console.log(uri); window.prompt(uri, ""); }, onFinish: function (port, jsonObj){ var callback = this.callbacks[port]; callback & callback(jsonObj); delete this.callbacks[port]; }, }; var Util = { getPort: function () { return Math.floor(Math.random() * (1 30)); }, getUri:function(obj, method, params, port){ params = this.getParam(params); var uri = JSBRIDGE_PROTOCOL + '://' + obj + ':' + port + '/' + method + '?' + params; return uri; }, getParam:function(obj){ if (obj & typeof obj === 'object') { return JSON.stringify(obj); } else { return obj || ''; } } }; for (var key in Inner) { if (!hasOwnProperty.call(JSBridge, key)) { JSBridge[key] = Inner[key]; } } })(window); |
可以看到,我們裡面有一個Util類,裡面有三個方法,getPort()用於隨機生成port,getParam()用於生成json字串,getUri()用於生成native需要的協議uri,裡面主要做字串拼接的工作,然後有一個Inner類,裡面有我們的call和onFinish方法,在call方法中,我們呼叫Util.getPort()獲得了port值,然後將callback物件儲存在了callbacks中的port位置,接著呼叫Util.getUri()將引數傳遞過去,將返回結果賦值給uri,呼叫window.prompt(uri, “”)將uri傳遞到native層。而onFinish()方法接受native回傳的port值和執行結果,根據port值從callbacks中得到原始的callback函式,執行callback函式,之後從callbacks中刪除。最後將Inner類中的函式暴露給外部的JSBrige物件,通過一個for迴圈一一賦值即可。
當然這個實現是最最簡單的實現了,實際情況要考慮的因素太多,由於本人不是很精通js,所以只能以java的思想去寫js,沒有考慮到的因素姑且忽略吧,比如記憶體的回收等等機制。
這樣,js層的編碼就完成了,接下來實現java層的編碼。
上文說到java層有一個空介面來進行約束暴露給js的類和方法,同時也便於混淆
1 2 |
public interface IBridge { } |
首先我們要將js傳來的uri獲取到,編寫一個WebChromeClient子類。
1 2 3 4 5 6 7 |
public class JSBridgeWebChromeClient extends WebChromeClient { @Override public boolean onJsPrompt(WebView view, String url, String message, String defaultValue, JsPromptResult result) { result.confirm(JSBridge.callJava(view, message)); return true; } } |
之後不要忘記了將該物件設定給WebView
1 2 3 4 5 |
WebView mWebView = (WebView) findViewById(R.id.webview); WebSettings settings = mWebView.getSettings(); settings.setJavaScriptEnabled(true); mWebView.setWebChromeClient(new JSBridgeWebChromeClient()); mWebView.loadUrl("file:///android_asset/index.html"); |
核心的內容來了,就是JSBridgeWebChromeClient中呼叫的JSBridge類的實現。前文提到該類中有這麼一個方法提供註冊暴露給js的類和方法
1 |
JSBridge.register("jsName",javaClass.class) |
該方法的實現其實很簡單,從一個Map中查詢key是不是存在,不存在則反射拿到對應的Class中的所有方法,將方法是public static void 型別的,並且引數是三個引數,分別是Webview,JSONObject,Callback型別的,如果滿足條件,則將所有滿足條件的方法put進去,整個實現如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
public class JSBridge { private static Map> exposedMethods = new HashMap(); public static void register(String exposedName, Class extends IBridge> clazz) { if (!exposedMethods.containsKey(exposedName)) { try { exposedMethods.put(exposedName, getAllMethod(clazz)); } catch (Exception e) { e.printStackTrace(); } } } private static HashMapgetAllMethod(Class injectedCls) throws Exception { HashMap mMethodsMap = new HashMap(); Method[] methods = injectedCls.getDeclaredMethods(); for (Method method : methods) { String name; if (method.getModifiers() != (Modifier.PUBLIC | Modifier.STATIC) || (name = method.getName()) == null) { continue; } Class[] parameters = method.getParameterTypes(); if (null != parameters & parameters.length == 3) { if (parameters[0] == WebView.class && parameters[1] == JSONObject.class && parameters[2] == JSCallback.class) { mMethodsMap.put(name, method); } } } return mMethodsMap; } } |
而至於JSBridge類中的callJava方法,就是將js傳來的uri進行解析,然後根據呼叫的類名別名從剛剛的map中查詢是不是存在,存在的話拿到該類所有方法的methodMap,然後根據方法名從methodMap拿到方法,反射呼叫,並將引數傳進去,引數就是前文說的滿足條件的三個引數,即WebView,JSONObject,Callback。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
public static String callJava(WebView webView, String uriString) { String methodName = ""; String className = ""; String param = "{}"; String port = ""; if (!TextUtils.isEmpty(uriString) & uriString.startsWith("JSBridge")) { Uri uri = Uri.parse(uriString); className = uri.getHost(); param = uri.getQuery(); port = uri.getPort() + ""; String path = uri.getPath(); if (!TextUtils.isEmpty(path)) { methodName = path.replace("/", ""); } } if (exposedMethods.containsKey(className)) { HashMapString, Method> methodHashMap = exposedMethods.get(className); if (methodHashMap != null & methodHashMap.size() != 0 && methodHashMap.containsKey(methodName)) { Method method = methodHashMap.get(methodName); if (method != null) { try { method.invoke(null, webView, new JSONObject(param), new Callback(webView, port)); } catch (Exception e) { e.printStackTrace(); } } } } return null; } |
看到該方法中使用了 new Callback(webView, port)進行新建物件,該物件就是用來回撥js中回撥方法的java對應的類。這個類你需要將js傳來的port傳進來之外,還需要將WebView的引用傳進來,因為要使用到WebView的loadUrl方法,為了防止記憶體洩露,這裡使用弱引用。如果你需要回撥js的callback,在對應的方法裡呼叫一下callback.apply()方法將返回資料傳入即可,
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
public class Callback { private static Handler mHandler = new Handler(Looper.getMainLooper()); private static final String CALLBACK_JS_FORMAT = "javascript:JSBridge.onFinish('%s', %s);"; private String mPort; private WeakReference mWebViewRef; public Callback(WebView view, String port) { mWebViewRef = new WeakReference(view); mPort = port; } public void apply(JSONObject jsonObject) { final String execJs = String.format(CALLBACK_JS_FORMAT, mPort, String.valueOf(jsonObject)); if (mWebViewRef != null & mWebViewRef.get() != null) { mHandler.post(new Runnable() { @Override public void run() { mWebViewRef.get().loadUrl(execJs); } }); } } } |
唯一需要注意的是apply方法我把它扔在主執行緒執行了,為什麼呢,因為暴露給js的方法可能會在子執行緒中呼叫這個callback,這樣的話就會報錯,所以我在方法內部將其切回主執行緒。
編碼完成的差不多了,那麼就剩實現IBridge即可了,我們來個簡單的,就來顯示Toast為例好了,顯示完給js回撥,雖然這個回撥沒有什麼意義。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
public class BridgeImpl implements IBridge { public static void showToast(WebView webView, JSONObject param, final Callback callback) { String message = param.optString("msg"); Toast.makeText(webView.getContext(), message, Toast.LENGTH_SHORT).show(); if (null != callback) { try { JSONObject object = new JSONObject(); object.put("key", "value"); object.put("key1", "value1"); callback.apply(getJSONObject(0, "ok", object)); } catch (Exception e) { e.printStackTrace(); } } } private static JSONObject getJSONObject(int code, String msg, JSONObject result) { JSONObject object = new JSONObject(); try { object.put("code", code); object.put("msg", msg); object.putOpt("result", result); return object; } catch (JSONException e) { e.printStackTrace(); } return null; } } |
你可以往該類中扔你需要的方法,但是必須是public static void且引數列表滿足條件,這樣才能找到該方法。
不要忘記將該類註冊進去
1 |
JSBridge.register("bridge", BridgeImpl.class); |
進行一下簡單的測試,將之前實現好的JSBridge.js檔案扔到assets目錄下,然後新建index.html,輸入
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
html> head> meta charset="utf-8"> title>JSBridgetitle> meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1, user-scalable=no"/> script src="file:///android_asset/JSBridge.js" type="text/javascript">script> script type="text/javascript"> script> style> style> head> body> div> h3>JSBridge 測試h3> div> ul class="list"> li> div> button onclick="JSBridge.call('bridge','showToast',{'msg':'Hello JSBridge'},function(res){alert(JSON.stringify(res))})"> 測試showToast button> div> li> br/> ul> body> html> |
很簡單,就是按鈕點選時呼叫JSBridge.call()方法,回撥函式是alert出返回的結果。
接著就是使用WebView將該index.html檔案load進來測試了
1 |
mWebView.loadUrl("file:///android_asset/index.html"); |
效果如下圖所示
可以看到整個過程都走通了,然後我們測試下子執行緒回撥,在BridgeImpl中加入測試方法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
public static void testThread(WebView webView, JSONObject param, final Callback callback) { new Thread(new Runnable() { @Override public void run() { try { Thread.sleep(3000); JSONObject object = new JSONObject(); object.put("key", "value"); callback.apply(getJSONObject(0, "ok", object)); } catch (InterruptedException e) { e.printStackTrace(); } catch (JSONException e) { e.printStackTrace(); } } }).start(); } |
在index.html中加入
1 2 3 4 5 6 7 8 9 10 |
ul class="list"> li> div> button onclick="JSBridge.call('bridge','testThread',{},function(res){alert(JSON.stringify(res))})"> 測試子執行緒回撥 button> div> li> br/> ul> |
理想的效果應該是3秒鐘之後回撥彈出alert顯示
很完美,程式碼也不多,就實現了功能。如果你需要使用到生成環境中去,上面的程式碼你一定要再自己封裝一下,因為我只是簡單的實現了功能,其他因素並沒有考慮太多。
當然你也可以參考一個開源的實現
Safe Java-JS WebView Bridge
最後還是慣例,貼上程式碼