用命令模式设计一个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
控制台输出的信息
代码目录结构