一、背景
最近在探究JSBridge时,了解到腾讯的方案,不向WebView中注入对象便实现了与JS通信,为了验证方案,自己写了个demo进行了实践。
二、方案论证
方案思想在于,js与native事先约定好特定的invoke协议,通过window.prompt()将协议传入到WebChromeClient的onJSPrompt()方法中,native在获取到协议后,对协议的解析并发起调用。
为什么不直接addJavascriptInterface()注入native对象,而要这个大动干戈呢?
这是因为,直接注入对象在Android端会出现XSS漏洞:js在获得注入的对象后,可以借助对象的类加载器,恶意地反射加载和调用Android项目和系统方法。比如,反射调用Runtime.exec()方法,执行查询手机存储中数据的命令。
<!--Web端会利用Android端提供的原生实例对象,利用Java反射机制执行任意Android原生代码-->
function illegalInvokeJavaMethod(android){
var clz = android.getClass().getClassLoader().loadClass("java.lang.Runtime");
clz.getDeclaredMethod("exec").invoke("sh");
}
虽然官方在android4.4的时候给出了解决方案,即只有通过@JavascriptInterface注解过的方法,才能被js调用。但如果你的项目仍然支持android4.4以下的版本,就还得解决该漏洞,而上面的jsbridge方案不存在这个漏洞。
三、协议解析
协议,
jsbridge://className/functionName?params=xx
所有供js调用的类与方法,约定在一个接口表H5API.js中,
H5BusinessPlugin.trackJSMessage
H5DevicePlugin.deviceInfo
...
在app启动初始化时,解析接口表H5API.js,
//初始化插件表
private final static List<String> pluginForm = new ArrayList<>();
private static void initPluginForm(Context context) {
String apiStr = IOUtils.readStringFromAssets(context, "H5API.js");
String[] apiArray = apiStr.split("\n|\r\n|\r");
for (String api : apiArray) {
String[] comps = api.split("\\.");
if (comps.length != 2) {
continue;
}
String className = comps[0];
String methodName = comps[1];
if (TextUtils.isEmpty(className) || TextUtils.isEmpty(methodName)) {
continue;
}
pluginForm.add(api);
}
}
懒加载策略组装PluginHandler,组装成功的PluginHandler存放在一个HashMap中。使用时先看HashMap中是否已存在的对应的PluginHandler,若存在则直接取出,不存在则先进行组装。
HashMap<String, PluginHandler> pluginHandlers = new HashMap<>();
//通过插件名或取对应的插件
public static PluginHandler getPluginHandler(String pluginName) {
if (TextUtils.isEmpty(pluginName) || !pluginForm.contains(pluginName)) {
return null;
}
PluginHandler handler = pluginHandlers.get(pluginName);
if (handler != null) {
return handler;
}
return registerHandlers(pluginName);
}
//注册插件
private static PluginHandler registerHandlers(String pluginName) {
try {
String[] comps = pluginName.split("\\.");
if (comps.length != 2) {
return null;
}
String className = "com.example.myjsbridge.plugin." + comps[0];
Class<?> handlerClass = Class.forName(className);
Method handlerMethod = handlerClass.getDeclaredMethod(comps[1]);
handlerMethod.setAccessible(true);
PluginHandler handler = (PluginHandler) handlerMethod.invoke(null);
pluginHandlers.put(pluginName, handler);
return handler;
} catch (Exception e) {
e.printStackTrace();
return null;
}
}
使用策略设计模式,在接到js调用时,在hashMap中取出对应的PluginHandler,并执行execute(),在execute()中完成具体的业务逻辑。
private void parseInvokeUrl(String invokeUrl) {
try {
Uri uri = Uri.parse(invokeUrl);
String scheme = uri.getScheme();
if (!"jsbridge".equals(scheme)) {
reportInvokeError();
return;
}
String host = uri.getHost();
if (TextUtils.isEmpty(host)) {
reportInvokeError();
return;
}
host = "H5" + host + "Plugin";
String path = uri.getPath();
if (TextUtils.isEmpty(path)) {
reportInvokeError();
return;
}
path = path.replace("/", "");
PluginHandler handler = H5PluginFactory.pluginHandlers.get(host + "." + path);
if (handler != null) {
handler.h5WebView = this;
handler.execute(uri.getQueryParameter("params"));
}
} catch (Exception e) {
e.printStackTrace();
}
}
以H5BusinessPlugin的trackJSMessge()为例。
private void parseInvokeUrl(String invokeUrl) {
try {
Uri uri = Uri.parse(invokeUrl);
String scheme = uri.getScheme();
if (!"jsbridge".equals(scheme)) {
reportInvokeError();
return;
}
String host = uri.getHost();
if (TextUtils.isEmpty(host)) {
reportInvokeError();
return;
}
host = "H5" + host + "Plugin";
String path = uri.getPath();
if (TextUtils.isEmpty(path)) {
reportInvokeError();
return;
}
path = path.replace("/", "");
PluginHandler handler = H5PluginFactory.getPluginHandler(host + "." + path);
if (handler != null) {
handler.h5WebView = this;
handler.execute(uri.getQueryParameter("params"));
} else {
reportInvokeError();
}
} catch (Exception e) {
e.printStackTrace();
reportInvokeError();
}
}
private void reportInvokeError() {
Toast.makeText(mContext, "无法识别的调用", Toast.LENGTH_SHORT).show();
}
三、JS调用Native
为了便于调试,可以让WebView加载一个本地单页面,html文件需放在assets/web/中。
webView.loadUrl("file:///android_asset/web/test.html");
<!DOCTYPE html>
<html>
<script>
<!--调用H5BusinessPlugin.trackJSMessage()方法-->
function sayHello() {
invoke("Business","trackJSMessage", {"message":"Hello Native!"});
}
<!--调用H5DevicePlugin.deviceInfo()方法-->
var CallBack = function(res){alert(JSON.stringify(res))}
function getDeviceInfo() {
invoke("Device","deviceInfo",{"callback":"CallBack"});
}
function notifyWebViewReady(param) {
var tip = param + new Date();
console.log(tip);
document.getElementById("result").innerHTML = tip;
}
<!--调用Native入口方法-->
function invoke(object, func, params) {
window.prompt("jsbridge://" + object + "/"+ func + "?" + "params="+JSON.stringify(params));
}
</script>
<head>
<meta charset="utf-8">
<meta name="viewport" content="initial-scale=1">
<meta name="description" content="Html5 hello world page!">
<title>MyJSBridge</title>
</head>
<body class="blueBody">
<h1>JSBridge初探</h1>
<hr>
<p>研究目标:不注入js对象,实现webView和native双向通信!</p>
<br>
<div>
<button onclick="sayHello()">say hello to native</button>
<button onclick="getDeviceInfo()">get native device info</button>
</div>
<br>
<span id="result" style="font-size: 20px;"></span>
</body>
</html>
重写WebChromeClient的onJSPrompt()方法,接收js调用,并解析协议,执行对应的PluginHandler调用。
private void initWebViewClient() {
this.setWebChromeClient(new WebChromeClient() {
@Override
public boolean onJsPrompt(WebView view, String url, String message, String defaultValue, JsPromptResult result) {
result.cancel();
parseInvokeUrl(message);
return true;
}
}
}
四、Native调用JS
Webview可以通过下面两种方式调用js,
webview.loadUrl("javascript:jsfunction()")
webView.evaluateJavascript("javascript:(function() {" +
"notifyWebViewReady(\"WebView onCreated at \");" +
"})()", null);
有时js在invoke时还需要返回值,那么可以将返回监听方法callback一并传给native,当native处理完调用逻辑后,可以执行callback方法,接处理结果传回。
public void execute(String params) {
try {
JSONObject paramJSON = new JSONObject(params);
String callback = null;
if (paramJSON.has("callback")) {
callback = paramJSON.getString("callback");
}
JSONObject response = new JSONObject();
response.put("platform", "Android");
response.put("deviceId", "1233456");
//向js传回处理结果
if (h5WebView != null && !TextUtils.isEmpty(callback)) {
String func = "javascript:(function() {" +
callback + "(JSON.stringify(" + response.toString() + "));" +
"})()";
h5WebView.evaluateJavascript(func, null);
}
} catch (JSONException e) {
e.printStackTrace();
}
五、github代码
奉上demo完整的代码,已上传到github,请取阅。