用命令模式設計一個JSBridge用于JavaScript與Android交互通信
在開發APP的過程中,通常會遇到Android需要與H5頁面互相傳遞數據的情況,而Android與H5交互的容器就是WebView。
因此要想設計一個高可用的 J S B r i d g e JSBridge JSBridge,不妨可以參考下述示例:
一、傳輸協議規范
設計一套用于 A n d r o i d Android Android端與 J a v a S c r i p t JavaScript JavaScript傳輸數據的協議規范,如下所示:
{"code": "1000001","msg": "調用成功","content": {"model": "NOH-AL00","brand": "HUAWEI"}
}
其中
- code 字段用來表示調用的狀態碼
- msg 字段用來表示調用信息
- content 字段用來傳輸數據
既然是要設計到Android與JavaScript兩個交互,就必然會涉及
-
Android端傳輸數據給JavaScript
- 一般是通過 w e b V i e w . e v a l u a t e J a v a s c r i p t ( j a v a S c r i p t C o d e , n u l l ) webView.evaluateJavascript(javaScriptCode, null) webView.evaluateJavascript(javaScriptCode,null)
-
JavaScript端傳輸數據給Android
-
J S B r i d g e . c a l l N a t i v e M e t h o d ( ) JSBridge.callNativeMethod() JSBridge.callNativeMethod()
其中要求Android端會有個統一入口,方法名叫做
callNativeMethod
,然后會暴露一個JavaScript的入口webView.addJavascriptInterface(JSBridge(this, webView), “JSBridge”)
-
二、Android端接口
設計一個JSInterface
接口,來執行Javascript
調用Android
回調
interface JSInterface {fun callback(webView: WebView, params: String, successFunction: String, failFunction: String?)}
讓一個抽象類BaseJavaScriptHandler
來實現這個接口
abstract class BaseJavaScriptHandler : JSInterface {override fun callback(webView: WebView,params: String,successFunction: String,failFunction: String?) {}
}
三、全局注冊映射不同方法對應處理類
接著不同的方法,都通過繼承這個BaseJavaScriptHandler
來處理各自方法的回調。比如login
方法對應的處理器LoginHandler
那么前端就只需要傳一個login
參數過來,就可以交給LoginHandler
這個類去處理,這樣Android
的業務代碼就可以和架構代碼解耦了。
class LoginHandler : BaseJavaScriptHandler() {companion object {const val KEY_ACCOUNT = "account"const val KEY_PASSWORD = "password"}override fun callback(webView: WebView,params: String,successFunction: String,failFunction: String?) {login(webView, params, successFunction, failFunction)}private fun login(webView: WebView,params: String,successFunction: String,failFunction: String?) {}}
那么接下來如何讓不同的方法都映射到不同的類名里的callback
方法里去呢?
答案:通過map
保存對應的方法名映射到類名的關系
然后對外暴露getJavaScriptHandler
方法,來獲取對應的Handler
實例對象來運行callback
接口
object HandlerManager {const val TAG = "HandlerManager"private val map = HashMap<String, Class<out BaseJavaScriptHandler>>()fun registerJavaScriptHandler() {register(JSBridgeConstants.METHOD_NAME_LOGIN, LoginHandler::class.java)register(JSBridgeConstants.METHOD_NAME_SHOW_TOAST, ShowToastHandler::class.java)}fun getJavaScriptHandler(methodName: String) : Class<out BaseJavaScriptHandler>? {return if (map.containsKey(methodName)) {map[methodName]} else {NoSuchMethodHandler::class.java}}private fun register(methodName: String, classObject: Class<out BaseJavaScriptHandler>) {map[methodName] = classObject}}
四、統一分發不同方法執行
由于通常前端 J a v a S c r i p t JavaScript JavaScript與 A n d r o i d Android Android交互會有多個不同的方法調用,因此我們需要設計一個統一全局調用的收口地方,然后不同的方法通過不同的參數來區分即可。
在Android
端加上一個@JavascriptInterface
注解,用于收斂一個與js交互的入口。
這樣設計的好處是:
- 可以統一埋點統計
Javascript
調用Android
代碼的次數 - 收斂一個入口,找代碼方便,代碼簡潔解耦清晰
class JSBridge(private val context: Context, private val webView: WebView) {/*** @param method 前端調用Native端的方法名* @param params 前端透傳來的參數* @param successFunction 執行成功后回調給前端的方法名* @param failFunction 執行失敗后回調給前端的方法名*/@JavascriptInterfacefun callNativeMethod(method: String, params: String, successFunction: String, failFunction: String) {}
}
然后里面的實現可以通過用method
方法名來解耦開來業務代碼,不同的method
方法對應用不同methodHandler
類去解決單個方法需要執行的邏輯,這樣就解耦開來了。
這樣一來callNativeMethod
方法的實現就好說了,如下所示:
/*** @param method 前端調用Native端的方法名* @param params 前端透傳來的參數* @param successFunction 執行成功后回調給前端的方法名* @param failFunction 執行失敗后回調給前端的方法名*/@JavascriptInterfacefun callNativeMethod(method: String, params: String, successFunction: String, failFunction: String) {val javaScriptHandler = HandlerManager.getJavaScriptHandler(method)// 如果找到對應的 handler,則執行處理javaScriptHandler?.let { handler ->// 生成對應handler的實例對象 val handlerInstance = handler.newInstance()// 觸發對應handler的回調 handlerInstance.callback(webView, params, successFunction, failFunction)} ?: run {// 如果沒有找到對應的 handler,可以打印日志或顯示提示Toast.makeText(context, "未找到對應的處理方法: $method", Toast.LENGTH_SHORT).show()}}
只需要在實例化全局WebView
的時候,去暴露Javascript
接口實例對象即可,如下所示
// 全局注冊
HandlerManager.registerJavaScriptHandler()val webView: WebView = findViewById(R.id.web_container)
webView.settings.javaScriptEnabled = true
webView.webViewClient = WebViewClient()
webView.webChromeClient = WebChromeClient()// Add JSBridge interface
webView.addJavascriptInterface(JSBridge(this, webView), "JSBridge")
webView.loadUrl("file:///android_asset/index.html"))
五、前端調用
這樣前端調用Android端的方法就很簡單了,通過 J S B r i d g e . c a l l N a t i v e M e t h o d ( ) JSBridge.callNativeMethod() JSBridge.callNativeMethod()然后在里面傳不同的方法名參數過來即可。
function login() {// Call the Android login methodJSBridge.callNativeMethod('login', JSON.stringify({account: username, password: password}), 'onLoginSuccess', 'onLoginFail');}
六、所有代碼
下面放出所有代碼
HandlerManager.kt
import kotlin.collections.HashMapobject HandlerManager {const val TAG = "HandlerManager"private val map = HashMap<String, Class<out BaseJavaScriptHandler>>()fun registerJavaScriptHandler() {register(JSBridgeConstants.METHOD_NAME_LOGIN, LoginHandler::class.java)register(JSBridgeConstants.METHOD_NAME_SHOW_TOAST, ShowToastHandler::class.java)}fun getJavaScriptHandler(methodName: String) : Class<out BaseJavaScriptHandler>? {return if (map.containsKey(methodName)) {map[methodName]} else {NoSuchMethodHandler::class.java}}private fun register(methodName: String, classObject: Class<out BaseJavaScriptHandler>) {map[methodName] = classObject}}
JSInterface.kt
import android.webkit.WebViewinterface JSInterface {fun callback(webView: WebView, params: String, successFunction: String, failFunction: String?)}
BaseJavaScriptHandler.kt
import android.os.Build
import android.util.Log
import android.webkit.WebView
import org.json.JSONObjectabstract class BaseJavaScriptHandler : JSInterface {companion object {const val TAG = "BaseJavaScriptHandler"}override fun callback(webView: WebView,params: String,successFunction: String,failFunction: String?) {}fun callbackToJavaScript(webView: WebView, callbackMethod: String?, callbackParams: String?) {if (callbackMethod == null) {return}var javaScriptCode = if (callbackParams != null) {"$callbackMethod($callbackParams)"} else {"$callbackMethod()"}Log.i(TAG, "===> javaScriptCode is $javaScriptCode")MainThreadUtils.runOnMainThread(runnable = Runnable {webView.evaluateJavascript(javaScriptCode, null)})}fun getCallbackParams(code: String?, msg: String?, content: String?) : String {val params = JSONObject().apply {code?.let {put(JSBridgeConstants.KEY_CODE, code)}msg?.let {put(JSBridgeConstants.KEY_MSG, msg)}if (content == null) {put(JSBridgeConstants.KEY_CONTENT, getExtraParams().toString())} else {put(JSBridgeConstants.KEY_CONTENT, content)}}return params.toString()}fun getExtraParams(): JSONObject {val jsonObject = JSONObject().apply {put(JSBridgeConstants.KEY_BRAND, Build.BRAND)put(JSBridgeConstants.KEY_MODEL, Build.MODEL)}return jsonObject}
}
LoginHandler.kt
package com.check.webviewapplicationimport android.webkit.WebView
import android.widget.Toast
import org.json.JSONObjectclass LoginHandler : BaseJavaScriptHandler() {companion object {const val KEY_ACCOUNT = "account"const val KEY_PASSWORD = "password"}override fun callback(webView: WebView,params: String,successFunction: String,failFunction: String?) {login(webView, params, successFunction, failFunction)}private fun login(webView: WebView,params: String,successFunction: String,failFunction: String?) {val paramsObject = JSONObject(params)val account: String = paramsObject.opt(KEY_ACCOUNT) as? String ?: ""val password: String = paramsObject.get(KEY_PASSWORD) as? String ?: ""val isSuccess = checkValid(account, password)if (isSuccess) {showToast(webView, "登錄成功")val callbackParams = getCallbackParams(JSBridgeConstants.CODE_SUCCESS,JSBridgeConstants.MSG_SUCCESS,getExtraParams().toString())callbackToJavaScript(webView, successFunction, callbackParams)} else {showToast(webView, "登錄失敗")val callbackParams = getCallbackParams(JSBridgeConstants.CODE_FAILURE,JSBridgeConstants.MSG_FAILURE,getExtraParams().toString())callbackToJavaScript(webView, failFunction, callbackParams)}}private fun checkValid(account: String, password: String) : Boolean {// 模擬賬號檢驗流程,假設只有賬號是123,密碼是456的才可以檢驗通過return "123" == account && "456" == password}private fun showToast(webView: WebView, msg: String) {webView.context?.let {Toast.makeText(webView.context, msg, Toast.LENGTH_SHORT).show()}}}
ShowToastHandler.kt
import android.webkit.WebView
import android.widget.Toastclass ShowToastHandler : BaseJavaScriptHandler() {override fun callback(webView: WebView,params: String,successFunction: String,failFunction: String?) {webView.context?.let {Toast.makeText(webView.context, JSBridgeConstants.METHOD_NAME_SHOW_TOAST, Toast.LENGTH_SHORT).show()}val callbackParams =getCallbackParams(JSBridgeConstants.CODE_SUCCESS, JSBridgeConstants.MSG_SUCCESS, null)callbackToJavaScript(webView, successFunction, callbackParams)}}
JSBridgeConstants.kt
class JSBridgeConstants {companion object {const val METHOD_NAME_LOGIN = "login"const val METHOD_NAME_SHOW_TOAST = "showToast"const val MSG_SUCCESS = "此方法執行成功"const val MSG_FAILURE = "此方法執行失敗"const val CODE_SUCCESS = "1"const val CODE_FAILURE = "0"const val KEY_CODE = "code"const val KEY_MSG = "msg"const val KEY_CONTENT = "content"const val VALUE_SUCCESS = "1"const val VALUE_FAILURE = "0"const val KEY_MODEL = "model"const val KEY_BRAND = "brand"}}
JSBridge.kt
import android.content.Context
import android.webkit.JavascriptInterface
import android.webkit.WebView
import android.widget.Toastclass JSBridge(private val context: Context, private val webView: WebView) {/*** @param method 前端調用Native端的方法名* @param params 前端透傳來的參數* @param successFunction 執行成功后回調給前端的方法名* @param failFunction 執行失敗后回調給前端的方法名*/@JavascriptInterfacefun callNativeMethod(method: String, params: String, successFunction: String, failFunction: String) {val javaScriptHandler = HandlerManager.getJavaScriptHandler(method)// 如果找到對應的 handler,則執行處理javaScriptHandler?.let { handler ->val handlerInstance = handler.newInstance()handlerInstance.callback(webView, params, successFunction, failFunction)} ?: run {// 如果沒有找到對應的 handler,可以打印日志或顯示提示Toast.makeText(context, "未找到對應的處理方法: $method", Toast.LENGTH_SHORT).show()}}
}
BaseWebView.kt
import android.annotation.SuppressLint
import android.content.Context
import android.util.AttributeSet
import android.webkit.WebChromeClient
import android.webkit.WebResourceError
import android.webkit.WebResourceRequest
import android.webkit.WebSettings
import android.webkit.WebView
import android.webkit.WebViewClient
import android.widget.Toastclass BaseWebView @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : WebView(context, attrs, defStyleAttr) {init {setupWebView()}// 提供一份默認的webViewClient,同時提供自由注入業務的webViewClientprivate var webViewClient: WebViewClient = object : WebViewClient() {override fun onPageStarted(view: WebView?, url: String?, favicon: android.graphics.Bitmap?) {super.onPageStarted(view, url, favicon)// Handle page startToast.makeText(context, "Page started: $url", Toast.LENGTH_SHORT).show()}override fun onPageFinished(view: WebView?, url: String?) {super.onPageFinished(view, url)// Handle page finishToast.makeText(context, "Page finished: $url", Toast.LENGTH_SHORT).show()}override fun onReceivedError(view: WebView?,request: WebResourceRequest?,error: WebResourceError?) {super.onReceivedError(view, request, error)// Handle errorToast.makeText(context, "Error: ${error?.description}", Toast.LENGTH_SHORT).show()}}@SuppressLint("SetJavaScriptEnabled")private fun setupWebView() {// Enable JavaScriptsettings.javaScriptEnabled = true// Enable DOM storagesettings.domStorageEnabled = true// Set a WebViewClient to handle page navigationwebViewClient = getWebViewClient()// Set a WebChromeClient to handle JavaScript dialogs, favicons, titles, and the progresswebChromeClient = WebChromeClient()// Enable zoom controlssettings.setSupportZoom(true)settings.builtInZoomControls = truesettings.displayZoomControls = false// Enable cachingsettings.cacheMode = WebSettings.LOAD_DEFAULT}// Load a URLoverride fun loadUrl(url: String) {super.loadUrl(url)}// Load a URL with additional headersoverride fun loadUrl(url: String, additionalHttpHeaders: Map<String, String>) {super.loadUrl(url, additionalHttpHeaders)}// Lifecycle methodsoverride fun onResume() {}override fun onPause() {}fun onDestroy() {// Clean up WebViewclearHistory()freeMemory()destroy()}override fun setWebViewClient(client: WebViewClient) {this.webViewClient = client}override fun getWebViewClient() : WebViewClient {return webViewClient}
}
MainThreadUtils.kt
import android.os.Handler
import android.os.Looperobject MainThreadUtils {private val mainHandler = Handler(Looper.getMainLooper())/*** 判斷當前是否在主線程*/fun isMainThread(): Boolean {return Looper.getMainLooper().thread === Thread.currentThread()}/*** 在主線程執行代碼塊* @param runnable 需要執行的代碼塊*/fun runOnMainThread(runnable: Runnable) {if (isMainThread()) {runnable.run()} else {mainHandler.post(runnable)}}/*** 在主線程執行代碼塊(使用 lambda 表達式)* @param block 需要執行的代碼塊*/fun runOnMainThread(block: () -> Unit) {if (isMainThread()) {block.invoke()} else {mainHandler.post { block.invoke() }}}/*** 延遲在主線程執行代碼塊* @param delayMillis 延遲時間(毫秒)* @param block 需要執行的代碼塊*/fun runOnMainThreadDelayed(delayMillis: Long, block: () -> Unit) {mainHandler.postDelayed({ block.invoke() }, delayMillis)}
}
MainActivity.kt
import android.annotation.SuppressLint
import android.os.Bundle
import android.webkit.WebChromeClient
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.appcompat.app.AppCompatActivityclass MainActivity : AppCompatActivity() {@SuppressLint("SetJavaScriptEnabled")override fun onCreate(savedInstanceState: Bundle?) {super.onCreate(savedInstanceState)setContentView(R.layout.activity_main)// 全局注冊HandlerManager.registerJavaScriptHandler()val webView: WebView = findViewById(R.id.web_container)webView.settings.javaScriptEnabled = truewebView.webViewClient = WebViewClient()webView.webChromeClient = WebChromeClient()// Add JSBridge interfacewebView.addJavascriptInterface(JSBridge(this, webView), "JSBridge")// Load the local HTML filewebView.loadUrl("file:///android_asset/login.html")}
}
activity_main.xml
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"xmlns:app="http://schemas.android.com/apk/res-auto"xmlns:tools="http://schemas.android.com/tools"android:layout_width="match_parent"android:layout_height="match_parent"tools:context=".MainActivity"><WebViewandroid:id="@+id/web_container"android:layout_width="match_parent"android:layout_height="600dp"android:text="Hello World!"app:layout_constraintBottom_toBottomOf="parent"app:layout_constraintEnd_toEndOf="parent"app:layout_constraintStart_toStartOf="parent"/></androidx.constraintlayout.widget.ConstraintLayout>
index.html
<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Login</title><style>body {font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;display: flex;justify-content: center;align-items: center;height: 100vh;margin: 0;background-color: #e9ecef;}.login-container {background-color: #fff;padding: 30px;border-radius: 10px;box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);width: 320px;text-align: center;}.login-container input,.login-container button {display: block;width: 100%;margin-bottom: 15px;padding: 12px;border-radius: 5px;font-size: 16px;box-sizing: border-box;}.login-container input {border: 1px solid #ddd;}.login-container button {background-color: #007BFF;color: white;border: none;cursor: pointer;transition: background-color 0.3s;}.login-container button:hover {background-color: #0056b3;}.message {margin-top: 15px;font-size: 14px;color: green;}.error {color: red;}</style>
</head>
<body><div class="login-container"><input type="text" id="username" placeholder="Username"><input type="password" id="password" placeholder="Password"><button onclick="login()">Login</button><button onclick="showToast()">ShowToast</button><div id="message" class="message"></div></div><script>function login() {var username = document.getElementById('username').value;var password = document.getElementById('password').value;// Call the Android login methodJSBridge.callNativeMethod('login', JSON.stringify({account: username, password: password}), 'onLoginSuccess', 'onLoginFail');}function showToast() {JSBridge.callNativeMethod('showToast', '', '', '');}function onLoginSuccess(response) {console.log("Raw response:", response);var messageDiv = document.getElementById('message');try {// 先將 response 轉換為 JSON 字符串const jsonString = JSON.stringify(response);console.log("JSON string:", jsonString);// 然后解析為對象const params = JSON.parse(jsonString);console.log("Parsed params:", params);if (params.content) {const content = JSON.parse(params.content);console.log("Parsed content:", content);messageDiv.textContent = `Login successful! Brand: ${content.brand}, Model: ${content.model}`;} else {messageDiv.textContent = "Login successful! " + params.msg;}} catch (e) {console.error("Error parsing response:", e);messageDiv.textContent = "Login failed: " + e.message;}messageDiv.classList.remove('error');}function onLoginFail(response) {var messageDiv = document.getElementById('message');messageDiv.textContent = "Login failed!" + response;messageDiv.classList.add('error');}</script>
</body>
</html>
login.html
<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Login</title><style>body {font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;display: flex;justify-content: center;align-items: flex-end;height: 100vh;margin: 0;background-color: #e9ecef;}.login-container {background-color: #fff;padding: 30px;border-radius: 10px;box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);width: 320px;text-align: center;margin-bottom: 20px;}.login-container input,.login-container button {display: block;width: 100%;margin-bottom: 15px;padding: 12px;border-radius: 5px;font-size: 16px;box-sizing: border-box;}.login-container input {border: 1px solid #ddd;}.login-container button {background-color: #007BFF;color: white;border: none;cursor: pointer;transition: background-color 0.3s;}.login-container button:hover {background-color: #0056b3;}.message {margin-top: 15px;font-size: 14px;color: green;}.error {color: red;}</style>
</head>
<body><div class="login-container"><input type="text" id="username" placeholder="Username"><input type="password" id="password" placeholder="Password"><button onclick="login()">Login</button><button onclick="showToast()">ShowToast</button><div id="message" class="message"></div></div><script>function login() {var username = document.getElementById('username').value;var password = document.getElementById('password').value;// Call the Android login methodJSBridge.callNativeMethod('login', JSON.stringify({account: username, password: password}), 'onLoginSuccess', 'onLoginFail');}function showToast() {JSBridge.callNativeMethod('showToast', '', '', '');}function onLoginSuccess(response) {console.log("Raw response:", response);var messageDiv = document.getElementById('message');try {// 先將 response 轉換為 JSON 字符串const jsonString = JSON.stringify(response);console.log("JSON string:", jsonString);// 然后解析為對象const params = JSON.parse(jsonString);console.log("Parsed params:", params);if (params.content) {const content = JSON.parse(params.content);console.log("Parsed content:", content);messageDiv.textContent = `Login successful! Brand: ${content.brand}, Model: ${content.model}`;} else {messageDiv.textContent = "Login successful! " + params.msg;}} catch (e) {console.error("Error parsing response:", e);messageDiv.textContent = "Login failed: " + e.message;}messageDiv.classList.remove('error');}function onLoginFail(response) {var messageDiv = document.getElementById('message');messageDiv.textContent = "Login failed!" + response;messageDiv.classList.add('error');}</script>
</body>
</html>
最后運行截圖:
用chrome://inspect/#devices還可以查看對應的JavaScript
控制臺輸出的信息
代碼目錄結構