From 6e1a409f96d02a53d2908b3ea71ef842ab8f497d Mon Sep 17 00:00:00 2001 From: nian1 Date: Sun, 25 Aug 2024 17:53:04 +0800 Subject: [PATCH 1/3] feat(jsbridge): add @JvmOverloads and lazy-load scripts --- .../main/java/com/ding1ding/jsbridge/MessageSerializer.kt | 8 ++++++-- .../com/ding1ding/jsbridge/WebViewJavascriptBridge.kt | 7 +++++-- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/library/src/main/java/com/ding1ding/jsbridge/MessageSerializer.kt b/library/src/main/java/com/ding1ding/jsbridge/MessageSerializer.kt index 316937a..29bfeab 100644 --- a/library/src/main/java/com/ding1ding/jsbridge/MessageSerializer.kt +++ b/library/src/main/java/com/ding1ding/jsbridge/MessageSerializer.kt @@ -59,15 +59,16 @@ object MessageSerializer { return json.toString().escapeJavascript() } + @JvmStatic fun deserializeResponseMessage( jsonString: String, responseCallbacks: Map>, messageHandlers: Map>, - ): ResponseMessage { + ): ResponseMessage = try { val json = JSONObject(jsonString) val responseId = json.optString(RESPONSE_ID) - return if (responseId.isNotEmpty()) { + if (responseId.isNotEmpty()) { val callback = responseCallbacks[responseId] val targetType = callback?.javaClass?.genericInterfaces?.firstOrNull()?.let { (it as? ParameterizedType)?.actualTypeArguments?.firstOrNull() @@ -93,6 +94,9 @@ object MessageSerializer { data = parseData(json.optString(DATA), targetType), ) } + } catch (e: Exception) { + Log.e("[JsBridge]", "Error deserializing message: ${e.message}") + ResponseMessage(null, null, null, null, null) } private fun parseData(jsonString: String, targetType: Type?): Any? { diff --git a/library/src/main/java/com/ding1ding/jsbridge/WebViewJavascriptBridge.kt b/library/src/main/java/com/ding1ding/jsbridge/WebViewJavascriptBridge.kt index ab99e17..f78885e 100644 --- a/library/src/main/java/com/ding1ding/jsbridge/WebViewJavascriptBridge.kt +++ b/library/src/main/java/com/ding1ding/jsbridge/WebViewJavascriptBridge.kt @@ -8,12 +8,16 @@ import android.webkit.WebView class WebViewJavascriptBridge(private val context: Context, private val webView: WebView) { + @JvmField var consolePipe: ConsolePipe? = null private val responseCallbacks = mutableMapOf>() private val messageHandlers = mutableMapOf>() private var uniqueId = 0 + private val bridgeScript by lazy { loadAsset("bridge.js").trimIndent() } + private val consoleHookScript by lazy { loadAsset("hookConsole.js").trimIndent() } + init { setupBridge() } @@ -47,8 +51,6 @@ class WebViewJavascriptBridge(private val context: Context, private val webView: } fun injectJavascript() { - val bridgeScript = loadAsset("bridge.js").trimIndent() - val consoleHookScript = loadAsset("hookConsole.js").trimIndent() webView.post { webView.evaluateJavascript("javascript:$bridgeScript", null) webView.evaluateJavascript("javascript:$consoleHookScript", null) @@ -63,6 +65,7 @@ class WebViewJavascriptBridge(private val context: Context, private val webView: messageHandlers.remove(handlerName) } + @JvmOverloads fun callHandler(handlerName: String, data: Any? = null, callback: Callback<*>? = null) { val callbackId = callback?.let { "native_cb_${++uniqueId}" } callbackId?.let { responseCallbacks[it] = callback } From bff4ef476c14df1bcb6022def2bead03c65d6ca6 Mon Sep 17 00:00:00 2001 From: nian1 Date: Sun, 25 Aug 2024 17:53:29 +0800 Subject: [PATCH 2/3] feat(webview): enable web contents debugging and simplify settings --- .../main/java/com/ding1ding/jsbridge/app/MainActivity.kt | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/ding1ding/jsbridge/app/MainActivity.kt b/app/src/main/java/com/ding1ding/jsbridge/app/MainActivity.kt index 4cc7c63..33dd9e5 100644 --- a/app/src/main/java/com/ding1ding/jsbridge/app/MainActivity.kt +++ b/app/src/main/java/com/ding1ding/jsbridge/app/MainActivity.kt @@ -31,9 +31,11 @@ class MainActivity : @SuppressLint("SetJavaScriptEnabled") private fun setupWebView() { webView = findViewById(R.id.webView).apply { - // WebView.setWebContentsDebuggingEnabled(true) - settings.javaScriptEnabled = true - settings.allowUniversalAccessFromFileURLs = true + WebView.setWebContentsDebuggingEnabled(true) + settings.apply { + javaScriptEnabled = true + allowUniversalAccessFromFileURLs = true + } webViewClient = createWebViewClient() loadUrl("file:///android_asset/index.html") } From 979ab75fb78c1c0631f0f2f3e98822e22d21a245 Mon Sep 17 00:00:00 2001 From: nian1 Date: Sun, 25 Aug 2024 18:38:22 +0800 Subject: [PATCH 3/3] feat(WebViewJavascriptBridge): Improve thread safety and memory management --- .../ding1ding/jsbridge/app/MainActivity.kt | 91 ++++++++++-- app/src/main/res/layout/activity_main.xml | 6 +- .../jsbridge/WebViewJavascriptBridge.kt | 131 ++++++++++-------- 3 files changed, 155 insertions(+), 73 deletions(-) diff --git a/app/src/main/java/com/ding1ding/jsbridge/app/MainActivity.kt b/app/src/main/java/com/ding1ding/jsbridge/app/MainActivity.kt index 33dd9e5..f9fccda 100644 --- a/app/src/main/java/com/ding1ding/jsbridge/app/MainActivity.kt +++ b/app/src/main/java/com/ding1ding/jsbridge/app/MainActivity.kt @@ -3,9 +3,11 @@ package com.ding1ding.jsbridge.app import android.annotation.SuppressLint import android.os.Bundle import android.util.Log +import android.view.KeyEvent import android.view.View import android.webkit.WebView import android.webkit.WebViewClient +import android.widget.LinearLayout import androidx.appcompat.app.AppCompatActivity import com.ding1ding.jsbridge.Callback import com.ding1ding.jsbridge.ConsolePipe @@ -17,8 +19,10 @@ class MainActivity : AppCompatActivity(), View.OnClickListener { - private var webView: WebView? = null - private var bridge: WebViewJavascriptBridge? = null + private lateinit var webView: WebView + private lateinit var bridge: WebViewJavascriptBridge + + private val webViewContainer: LinearLayout by lazy { findViewById(R.id.linearLayout) } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -28,10 +32,53 @@ class MainActivity : setupClickListeners() } + override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean { + if (keyCode == KeyEvent.KEYCODE_BACK && event?.repeatCount == 0) { + when { + webView.canGoBack() -> webView.goBack() + else -> supportFinishAfterTransition() + } + return true + } + return super.onKeyDown(keyCode, event) + } + + override fun onResume() { + super.onResume() + webView.onResume() + webView.resumeTimers() + } + + override fun onPause() { + webView.onPause() + webView.pauseTimers() + super.onPause() + } + + override fun onDestroy() { + // 01 + bridge.release() + // 02 + releaseWebView() + Log.d(TAG, "onDestroy") + super.onDestroy() + } + @SuppressLint("SetJavaScriptEnabled") private fun setupWebView() { - webView = findViewById(R.id.webView).apply { + webView = WebView(this).apply { + removeJavascriptInterface("searchBoxJavaBridge_") + removeJavascriptInterface("accessibility") + removeJavascriptInterface("accessibilityTraversal") + WebView.setWebContentsDebuggingEnabled(true) + + layoutParams = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + 0, + 1f, + ) + settings.apply { javaScriptEnabled = true allowUniversalAccessFromFileURLs = true @@ -39,20 +86,20 @@ class MainActivity : webViewClient = createWebViewClient() loadUrl("file:///android_asset/index.html") } + + webViewContainer.addView(webView) } private fun setupBridge() { - bridge = webView?.let { - WebViewJavascriptBridge(this, it).apply { - consolePipe = object : ConsolePipe { - override fun post(message: String) { - Log.d("[console.log]", message) - } + bridge = WebViewJavascriptBridge(this, webView).apply { + consolePipe = object : ConsolePipe { + override fun post(message: String) { + Log.d("[console.log]", message) } - - registerHandler("DeviceLoadJavascriptSuccess", createDeviceLoadHandler()) - registerHandler("ObjTest", createObjTestHandler()) } + + registerHandler("DeviceLoadJavascriptSuccess", createDeviceLoadHandler()) + registerHandler("ObjTest", createObjTestHandler()) } } @@ -65,7 +112,7 @@ class MainActivity : private fun createWebViewClient() = object : WebViewClient() { override fun onPageStarted(view: WebView?, url: String?, favicon: android.graphics.Bitmap?) { Log.d(TAG, "onPageStarted") - bridge?.injectJavascript() + bridge.injectJavascript() } override fun onPageFinished(view: WebView?, url: String?) { @@ -92,7 +139,7 @@ class MainActivity : when (v?.id) { R.id.buttonSync -> callJsHandler("GetToken") R.id.buttonAsync -> callJsHandler("AsyncCall") - R.id.objTest -> bridge?.callHandler( + R.id.objTest -> bridge.callHandler( "TestJavascriptCallNative", mapOf("message" to "Hello from Android"), null, @@ -101,7 +148,7 @@ class MainActivity : } private fun callJsHandler(handlerName: String) { - bridge?.callHandler( + bridge.callHandler( handlerName, Person("Wukong", 23), object : Callback { @@ -112,6 +159,20 @@ class MainActivity : ) } + private fun releaseWebView() { + webViewContainer.removeView(webView) + webView.apply { + stopLoading() + loadUrl("about:blank") + clearHistory() + removeAllViews() + webChromeClient = null + // webViewClient = null + settings.javaScriptEnabled = false + destroy() + } + } + companion object { private const val TAG = "MainActivity" } diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 29a3ea5..96dc9cd 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -1,19 +1,21 @@ - + android:layout_weight="1" />--> diff --git a/library/src/main/java/com/ding1ding/jsbridge/WebViewJavascriptBridge.kt b/library/src/main/java/com/ding1ding/jsbridge/WebViewJavascriptBridge.kt index f78885e..bbb6f83 100644 --- a/library/src/main/java/com/ding1ding/jsbridge/WebViewJavascriptBridge.kt +++ b/library/src/main/java/com/ding1ding/jsbridge/WebViewJavascriptBridge.kt @@ -5,27 +5,50 @@ import android.content.Context import android.util.Log import android.webkit.JavascriptInterface import android.webkit.WebView - -class WebViewJavascriptBridge(private val context: Context, private val webView: WebView) { - +import androidx.annotation.MainThread +import java.util.concurrent.atomic.AtomicInteger +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch + +class WebViewJavascriptBridge @JvmOverloads constructor( + private val context: Context, + private val webView: WebView, + private val coroutineScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main), +) { @JvmField var consolePipe: ConsolePipe? = null private val responseCallbacks = mutableMapOf>() private val messageHandlers = mutableMapOf>() - private var uniqueId = 0 + private val uniqueId = AtomicInteger(0) - private val bridgeScript by lazy { loadAsset("bridge.js").trimIndent() } - private val consoleHookScript by lazy { loadAsset("hookConsole.js").trimIndent() } + private val bridgeScript by lazy { loadAsset("bridge.js") } + private val consoleHookScript by lazy { loadAsset("hookConsole.js") } + + private var isInjected = false init { setupBridge() } - fun reset() { + @JvmOverloads + fun reset(clearHandlers: Boolean = false) = synchronized(this) { + responseCallbacks.clear() + if (clearHandlers) { + messageHandlers.clear() + } + uniqueId.set(0) + isInjected = false + } + + fun release() { + removeJavascriptInterface() + consolePipe = null responseCallbacks.clear() messageHandlers.clear() - uniqueId = 0 + coroutineScope.launch { /* Cancel all ongoing coroutines */ }.cancel() } @SuppressLint("SetJavaScriptEnabled") @@ -35,9 +58,10 @@ class WebViewJavascriptBridge(private val context: Context, private val webView: webView.addJavascriptInterface(this, "consolePipe") } - fun removeJavascriptInterface() { + private fun removeJavascriptInterface() = synchronized(this) { webView.removeJavascriptInterface("normalPipe") webView.removeJavascriptInterface("consolePipe") + reset(true) } @JavascriptInterface @@ -50,24 +74,30 @@ class WebViewJavascriptBridge(private val context: Context, private val webView: consolePipe?.post(data.orEmpty()) } + @MainThread fun injectJavascript() { - webView.post { + if (!isInjected) { webView.evaluateJavascript("javascript:$bridgeScript", null) webView.evaluateJavascript("javascript:$consoleHookScript", null) + isInjected = true } } fun registerHandler(handlerName: String, messageHandler: MessageHandler<*, *>) { - messageHandlers[handlerName] = messageHandler + synchronized(messageHandlers) { + messageHandlers[handlerName] = messageHandler + } } fun removeHandler(handlerName: String) { - messageHandlers.remove(handlerName) + synchronized(messageHandlers) { + messageHandlers.remove(handlerName) + } } @JvmOverloads fun callHandler(handlerName: String, data: Any? = null, callback: Callback<*>? = null) { - val callbackId = callback?.let { "native_cb_${++uniqueId}" } + val callbackId = callback?.let { "native_cb_${uniqueId.incrementAndGet()}" } callbackId?.let { responseCallbacks[it] = callback } val message = CallMessage(handlerName, data, callbackId) @@ -76,53 +106,42 @@ class WebViewJavascriptBridge(private val context: Context, private val webView: } private fun processMessage(messageString: String) { - try { - val message = MessageSerializer.deserializeResponseMessage( - messageString, - responseCallbacks, - messageHandlers, - ) - if (message.responseId != null) { - handleResponse(message) - } else { - handleRequest(message) + coroutineScope.launch(Dispatchers.Default) { + try { + val message = MessageSerializer.deserializeResponseMessage( + messageString, + responseCallbacks, + messageHandlers, + ) + when { + message.responseId != null -> handleResponse(message) + else -> handleRequest(message) + } + } catch (e: Exception) { + Log.e("[JsBridge]", "Error processing message: ${e.message}") } - } catch (e: Exception) { - Log.e("[JsBridge]", "Error processing message: ${e.message}") } } - private fun handleResponse(responseMessage: ResponseMessage) { - when (val callback = responseCallbacks.remove(responseMessage.responseId)) { - is Callback<*> -> { - @Suppress("UNCHECKED_CAST") - (callback as Callback).onResult(responseMessage.responseData) - } - - else -> Log.w( - "[JsBridge]", - "Callback not found or has invalid type for responseId: ${responseMessage.responseId}", - ) + private suspend fun handleResponse(responseMessage: ResponseMessage) { + val callback = responseCallbacks.remove(responseMessage.responseId) + if (callback is Callback<*>) { + @Suppress("UNCHECKED_CAST") + (callback as Callback).onResult(responseMessage.responseData) } } - private fun handleRequest(message: ResponseMessage) { - when (val handler = messageHandlers[message.handlerName]) { - is MessageHandler<*, *> -> { - @Suppress("UNCHECKED_CAST") - val typedMessageHandler = handler as MessageHandler - val responseData = typedMessageHandler.handle(message.data) - message.callbackId?.let { callbackId -> - val response = ResponseMessage(callbackId, responseData, null, null, null) - val responseString = MessageSerializer.serializeResponseMessage(response) - dispatchMessage(responseString) - } + private suspend fun handleRequest(message: ResponseMessage) { + val handler = messageHandlers[message.handlerName] + if (handler is MessageHandler<*, *>) { + @Suppress("UNCHECKED_CAST") + val typedMessageHandler = handler as MessageHandler + val responseData = typedMessageHandler.handle(message.data) + message.callbackId?.let { callbackId -> + val response = ResponseMessage(callbackId, responseData, null, null, null) + val responseString = MessageSerializer.serializeResponseMessage(response) + dispatchMessage(responseString) } - - else -> Log.w( - "[JsBridge]", - "Handler not found or has invalid type for handlerName: ${message.handlerName}", - ) } } @@ -131,10 +150,10 @@ class WebViewJavascriptBridge(private val context: Context, private val webView: webView.post { webView.evaluateJavascript(script, null) } } - private fun loadAsset(fileName: String): String = try { + private fun loadAsset(fileName: String): String = runCatching { context.assets.open(fileName).bufferedReader().use { it.readText() } - } catch (e: Exception) { - Log.e("[JsBridge]", "Error loading asset $fileName: ${e.message}") + }.getOrElse { + Log.e("[JsBridge]", "Error loading asset $fileName: ${it.message}") "" - } + }.trimIndent() }