WebView不注入对象,实现JS与Native通信

3,272 阅读3分钟

一、背景

最近在探究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,请取阅。