一 Android WebView Js 原生API
Android WebView 提供了Js 和 WebView相互調(diào)用的接口,js 調(diào)用Android 代碼通過
- @JavascriptInterface 注解
- WebView.addJavascriptInterface(Object object, String name) 方法
實現(xiàn)JS 和java 對象的映射。
同樣 WebView 也提供了 java 調(diào)用Js 代碼的機制。通過以下兩個方法:
- WebView.evaluateJavascript(String script, ValueCallback<String> resultCallback);
- WebView.loadUrl(String script); Android 4.4 以下版本使用
private void evaluateJavascript(String script) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
WebView.evaluateJavascript(String script, ValueCallback<String> resultCallback);
} else {
WebView.loadUrl(String script);
}
}
二 DSBridge 分析
github 上提供了一個Js Bridage, DSBridge-Android, 分析下實現(xiàn)原理:
一共三個java 文件:
文件 | 功能 |
---|---|
DWebView.java | 繼承WebView 封裝了Js調(diào)用 |
CompletionHandler.java | 處理異步請求使用 |
OnReturnValue.java | 返回值 接口 |
DWebView 類 繼承自 WebView 主要包括這幾個函數(shù)
- init 在WebView 的構(gòu)造函數(shù)中調(diào)動,完成一些WebView 的設(shè)置。
- injectJs
- evaluateJavascript(final String script);
1. init
init 函數(shù)中主要有兩個部分處理一個是注冊一個 WebChromeClient, 一個是調(diào)用addJavascriptInterface 接口注冊一個 Js 調(diào)用Java的通用api
- super.setWebChromeClient(mWebChromeClient); 在mWebChromeClient 的回調(diào)中調(diào)用 injectJs 完成Js 注入
- super.addJavascriptInterface(new Object(){}, BRIDGE_NAME),這是DSBridge的核心功能,向 Js 頁面注冊一個通用的Js 對象,這個對象有一個 call 方法,通過這個call 方法實現(xiàn)對其它 Android Api 的調(diào)用,下面主要分析這個方法。
2. js 調(diào)用方式
在 js 頁面調(diào)用 Andoid 代碼時通過:dsBridge 為java evaluateJavascript 調(diào)用是的Object 在Js的映射對象,然后調(diào)用 這個對象的call 方法:
// init dsBridge
<script src="https://unpkg.com/dsbridge/dist/dsbridge.js"> </script>
var dsBridge=require("dsbridge")
//Call synchronously
var str=dsBridge.call("testSyn", {msg: "testSyn"});
//Call asynchronously
dsBridge.call("testAsyn", {msg: "testAsyn"}, function (v) {
alert(v);
})
3. Java 注冊 js Api
java 代碼注冊,js 調(diào)用的函數(shù)都封裝在 JsApi 這個對象中,注意是DWebView 的 setJavascriptInterface,不是原生WebView .
DWebView.setJavascriptInterface(new JsApi());
public class JsApi{
@JavascriptInterface
public void testAsyn(JSONObject jsonObject, CompletionHandler handler) throws JSONException {
handler.complete(jsonObject.getString("msg")+" [ asyn call]");
}
}
4. call 函數(shù)
call 需要使用 JavascriptInterface 注解注釋,Js 中的三個參數(shù)被到這里被簡化為兩個參數(shù),原因在Js 代碼中分析。
- methodName java 方法名
- args 參數(shù), 注意String 格式其實是Json 字符串
具體過程看代碼注釋:
@JavascriptInterface
public String call(String methodName, String args) {
String error = "Js bridge method called, but there is " +
"not a JavascriptInterface object, please set JavascriptInterface object first!";
// 首先檢查是否注冊了Js api 相關(guān)的對象
if (jsb == null) {
Log.e("SynWebView", error);
return "";
}
// 獲取注冊的Js api 對象的Class 對象
Class<?> cls = jsb.getClass();
try {
Method method;
// 異步標記
boolean asyn = false;
// String 類型的參數(shù)轉(zhuǎn)化為 Json 對象
JSONObject arg = new JSONObject(args);
String callback = "";
try {
// 檢查 json 對象中是否有_dscbstub 這個Key,如果有表示有回調(diào)Js的函數(shù),是一個異步調(diào)用,
// 然后移除,那么json對象中保存的都是參數(shù)
// 有了對象,知道了對象的方法的String,通過反射獲取這個方法。通過反射的參數(shù)可知
// 方法的函數(shù)簽名為:xxxMethod(JSONObject object, CompletionHandler handler);
callback = arg.getString("_dscbstub");
arg.remove("_dscbstub");
method = cls.getDeclaredMethod(methodName,
new Class[]{JSONObject.class, CompletionHandler.class});
asyn = true;
} catch (Exception e) {
method = cls.getDeclaredMethod(methodName, new Class[]{JSONObject.class});
}
// 錯誤檢查
if (method == null) {
error = "ERROR! \n Not find method \"" + methodName + "\" implementation! ";
Log.e("SynWebView", error);
evaluateJavascript(String.format("alert(decodeURIComponent(\"%s\"})", error));
return "";
}
// Js 調(diào)用的API 需要使用 @JavascriptInterface 注解,
// 在4.4 以前的平臺上有Js 安全漏洞,通過這個注解檢查是否合法的API.
// call 函數(shù)已經(jīng)用 @JavascriptInterface 標注,是一個合法的API。jsp 對象由于繞過了WebView
// 的 @JavascriptInterface 注解檢查,需要手動校驗。
JavascriptInterface annotation = method.getAnnotation(JavascriptInterface.class);
if (annotation != null) {
Object ret;
// 設(shè)置方法為可訪問的
method.setAccessible(true);
if (asyn) {
// 異步調(diào)用, 講異步調(diào)用的邏輯封裝在CompletionHandler 中,
// 使用閉包的方式實現(xiàn)callback.
final String cb = callback;
ret = method.invoke(jsb, arg, new CompletionHandler() {
、、、
// 可以再method 方法中調(diào)用這個函數(shù),實現(xiàn)異步。
private void complete(String retValue,boolean complete) {
try {
// retValue 為 method 執(zhí)行的結(jié)果,complete 可以控制多次回調(diào)。
// 將callback 和參數(shù)組合為 javascript 語句,然后通過evaluateJavascript
// 方法調(diào)用js 執(zhí)行
if (retValue == null) retValue = "";
retValue = URLEncoder.encode(retValue, "UTF-8").replaceAll("\\+", "%20");
String script = String.format("%s(decodeURIComponent(\"%s\"));", cb, retValue);
// 將callback 方法從Html 的window 對象刪除,原因在js 代碼分析
if(complete) {
script += "delete window."+cb;
}
evaluateJavascript(script);
} catch (UnsupportedEncodingException e) {
e.printStackTrace();
}
}
});
} else {
// 同步調(diào)用
ret = method.invoke(jsb, arg);
}
if (ret == null) {
ret = "";
}
// 返回結(jié)果
return ret.toString();
} else {
error = "Method " + methodName + " is not invoked, since " +
"it is not declared with JavascriptInterface annotation! ";
evaluateJavascript(String.format("alert('ERROR \\n%s')", error));
Log.e("SynWebView", error);
}
} catch (Exception e) {
evaluateJavascript(String.format("alert('ERROR! \\nCall failed:Function does not exist or parameter is invalid[%s]')", e.getMessage()));
e.printStackTrace();
}
return "";
}
@Keep
@JavascriptInterface
public void returnValue(int id, String value) {
OnReturnValue handler = handlerMap.get(id);
if (handler != null) {
handler.onValue(value);
handlerMap.remove(id);
}
}
}, BRIDGE_NAME);
5. injectJs 注入js 代碼
在WebChromeClient 的回調(diào)中,會調(diào)用injectJs 方法注入js,onProgressChanged onReceivedTitle 保證在js 代碼運行前 js注入完成
private WebChromeClient mWebChromeClient = new WebChromeClient() {
@Override
public void onProgressChanged(WebView view, int newProgress) {
injectJs();
}
@Override
public void onReceivedTitle(WebView view, String title) {
injectJs();
}
}
private void injectJs() {
evaluateJavascript("function getJsBridge(){window._dsf=window._dsf||{};return{call:function(b,a,c){\"function\"==typeof a&&(c=a,a={});if(\"function\"==typeof c){window.dscb=window.dscb||0;var d=\"dscb\"+window.dscb++;window[d]=c;a._dscbstub=d}a=JSON.stringify(a||{});return window._dswk?prompt(window._dswk+b,a):\"function\"==typeof _dsbridge?_dsbridge(b,a):_dsbridge.call(b,a)},register:function(b,a){\"object\"==typeof b?Object.assign(window._dsf,b):window._dsf[b]=a}}}dsBridge=getJsBridge();");
}
6. javascript 代碼注入分析
injectJs 調(diào)用的Js 代碼如下:
function getJsBridge() {
// window 對象的 _dsf 賦值, 如果沒有定義過, 則定義為 {};
// dsf 域用來保存 java 調(diào)用js 的function.
window._dsf = window._dsf || {};
// 返回 json 一個匿名json對象, json 對象包含兩個function:call 和 register。
return {
// call function 包含三個參數(shù), 方法名, 參數(shù),回調(diào)函數(shù),
call: function (method, args, cb) {
var ret = "";
// 檢查第二個參數(shù)類型是否為 function , 如果為function 則表示為回調(diào)函數(shù)
if (typeof args == "function") {
cb = args;
args = {}
}
// 這一步處理很有技巧,在設(shè)置回調(diào)函數(shù)的時候,回調(diào)函數(shù)可能為匿名函數(shù),
// 在這里通過window 對象的一個域保存,避免垃圾回收和Java 回調(diào)的時候能夠找到。
// args 對象中 回調(diào)函數(shù)的Key 被設(shè)置為"_dscbstub", java 中是根據(jù)這個名字找到的callback
// 也解釋了java 中的call API 為兩個參數(shù), Js 中為三個參數(shù)的原因。
if (typeof cb == "function") {
window.dscb = window.dscb || 0;
var cbName = "dscb" + window.dscb++;
window[cbName] = cb;
args["_dscbstub"] = cbName
}
args = JSON.stringify(args || {});
if (window._dswk) {
// debug 分支, window 的 _dswk 域決定
ret = prompt(window._dswk + method, args)
} else {
// _dsbridge 對象為java 中調(diào)
addJavascriptInterface(new Object(){}, BRIDGE_NAME) 映射的JS 對象
if (typeof _dsbridge == "function") {
ret = _dsbridge(method, args)
} else {
// 我們的代碼走這里
ret = _dsbridge.call(method, args)
}
}
return ret
}, register: function (name, fun) {
if (typeof name == "object") {
Object.assign(window._dsf, name)
} else {
window._dsf[name] = fun
}
}
}
}
// 最后把這個匿名對象掛在 window dsBridage 域下。
window.dsBridge = getJsBridge();
7 evaluateJavascript
DWebView 對 evaluateJavascript 做了兩次封裝,主要解決兩個問題:
- 在非主線程中調(diào)用的問題, 通過handler post 到主線程中處理。
- 4.4 以前的版本兼容的問題。
public void evaluateJavascript(final String script) {
if (Looper.getMainLooper() == Looper.myLooper()) {
_evaluateJavascript(script);
} else {
Message msg=new Message();
msg.what=EXEC_SCRIPT;
msg.obj=script;
mainThreadHandler.sendMessage(msg);
}
}
private void _evaluateJavascript(String script) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
DWebView.super.evaluateJavascript(script, null);
} else {
loadUrl("javascript:" + script);
}
}
8 java 調(diào)用js
android 的原生方式中java調(diào)用js 的接口已經(jīng)很完善了。DSBridge 使用callHandler。js function 在調(diào)用前需要掛到
window._dsf 域下,參考 js代碼的 register 函數(shù)。
//Register javascript function for Native invocation
dsBridge.register('addValue',function(l,r){
return l+r;
})
java 中調(diào)用前指明window._dsf 下的function。 對代碼做了一個約束。
DWebView.callHandler("addValue",new Object[]{1,"hello"},new OnReturnValue(){
@Override
public void onValue(String retValue) {
Log.d("jsbridge","call succeed,return value is "+retValue);
}
})
public void callHandler(String method, Object[] args, final OnReturnValue handler) {
if (args == null) args = new Object[0];
String arg = new JSONArray(Arrays.asList(args)).toString();
String script = String.format("(window._dsf.%s||window.%s).apply(window._dsf||window,%s)", method,method, arg);
if(handler!=null){
script = String.format("%s.returnValue(%d,%s)",BRIDGE_NAME,callID, script);
handlerMap.put(callID++, handler);
}
evaluateJavascript(script);
}