Skip to content

Commit

Permalink
feat(WebViewJavascriptBridge): Improve thread safety and memory manag…
Browse files Browse the repository at this point in the history
…ement
  • Loading branch information
syxc committed Aug 25, 2024
1 parent bff4ef4 commit 979ab75
Show file tree
Hide file tree
Showing 3 changed files with 155 additions and 73 deletions.
91 changes: 76 additions & 15 deletions app/src/main/java/com/ding1ding/jsbridge/app/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -28,31 +32,74 @@ 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<WebView>(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
}
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())
}
}

Expand All @@ -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?) {
Expand All @@ -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,
Expand All @@ -101,7 +148,7 @@ class MainActivity :
}

private fun callJsHandler(handlerName: String) {
bridge?.callHandler(
bridge.callHandler(
handlerName,
Person("Wukong", 23),
object : Callback<Any> {
Expand All @@ -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"
}
Expand Down
6 changes: 4 additions & 2 deletions app/src/main/res/layout/activity_main.xml
Original file line number Diff line number Diff line change
@@ -1,19 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/linearLayout"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">

<WebView
<!--<WebView
android:id="@+id/webView"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1" />
android:layout_weight="1" />-->

<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="bottom"
android:gravity="center"
android:orientation="horizontal"
android:padding="16dp">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, Callback<*>>()
private val messageHandlers = mutableMapOf<String, MessageHandler<*, *>>()
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")
Expand All @@ -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
Expand All @@ -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)
Expand All @@ -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<Any?>).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<Any?>).onResult(responseMessage.responseData)
}
}

private fun handleRequest(message: ResponseMessage) {
when (val handler = messageHandlers[message.handlerName]) {
is MessageHandler<*, *> -> {
@Suppress("UNCHECKED_CAST")
val typedMessageHandler = handler as MessageHandler<Any?, Any?>
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<Any?, Any?>
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}",
)
}
}

Expand All @@ -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()
}

0 comments on commit 979ab75

Please sign in to comment.