From a0f8873b7a8dac49e08103ae795c26a6fce2e624 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sun, 16 Aug 2026 17:21:02 +0800 Subject: [PATCH] fix: marshal js bridge view ops to main thread --- .../main/java/top/yeij/hearth/MainActivity.kt | 13 +-- .../top/yeij/hearth/webapp/WebAppContainer.kt | 8 +- .../java/top/yeij/hearth/webview/JsBridge.kt | 102 +++++++++++------- .../yeij/hearth/webapp/WebAppContainerTest.kt | 9 ++ .../top/yeij/hearth/webview/JsBridgeTest.kt | 39 +++++++ 5 files changed, 126 insertions(+), 45 deletions(-) diff --git a/app/src/main/java/top/yeij/hearth/MainActivity.kt b/app/src/main/java/top/yeij/hearth/MainActivity.kt index 2f33685..4483731 100644 --- a/app/src/main/java/top/yeij/hearth/MainActivity.kt +++ b/app/src/main/java/top/yeij/hearth/MainActivity.kt @@ -86,6 +86,7 @@ class MainActivity : Activity() { cardRepository = CardRepository(http, cache, CATALOG_URL), webAppContainer = webAppContainer, webAppHost = webAppHost, + postToMainThread = { desktopWebView.post(it) }, ) root = FrameLayout(this) setContentView(root) @@ -195,15 +196,15 @@ class MainActivity : Activity() { } override fun syncTabs(tabs: List) { + // JsBridge 已把该调用 marshal 到主线程,这里可直接 evaluateJavascript + // JsBridge already marshaled this call to the main thread, so evaluateJavascript is safe val json = gson.toJson( tabs.map { mapOf("id" to it.id, "name" to it.name, "active" to it.active) } ) - desktopWebView.post { - desktopWebView.evaluateJavascript( - "window.showTopbar && window.showTopbar($json);", - null - ) - } + desktopWebView.evaluateJavascript( + "window.showTopbar && window.showTopbar($json);", + null + ) Log.d(TAG, "syncTabs: ${tabs.size} tabs") } diff --git a/app/src/main/java/top/yeij/hearth/webapp/WebAppContainer.kt b/app/src/main/java/top/yeij/hearth/webapp/WebAppContainer.kt index 3394530..db77a95 100644 --- a/app/src/main/java/top/yeij/hearth/webapp/WebAppContainer.kt +++ b/app/src/main/java/top/yeij/hearth/webapp/WebAppContainer.kt @@ -34,10 +34,12 @@ class WebAppContainer { } } - // 切换激活标签 - // Switch the active tab - fun switchTo(id: String) { + // 切换激活标签:id 不存在时返回 false 且不改动状态(保持原激活标签) + // Switch the active tab: return false and keep current state when id is unknown + fun switchTo(id: String): Boolean { + if (tabs.none { it.id == id }) return false setActive(id) + return true } // 返回标签列表副本(外部无法直接改动内部状态) diff --git a/app/src/main/java/top/yeij/hearth/webview/JsBridge.kt b/app/src/main/java/top/yeij/hearth/webview/JsBridge.kt index 43fb0e3..da24e3a 100644 --- a/app/src/main/java/top/yeij/hearth/webview/JsBridge.kt +++ b/app/src/main/java/top/yeij/hearth/webview/JsBridge.kt @@ -20,9 +20,17 @@ class JsBridge( private val cardRepository: CardRepository? = null, private val webAppContainer: WebAppContainer? = null, private val webAppHost: WebAppHost? = null, + // 主线程执行器:View 操作必须切到主线程;null(单测)时同步执行 + // Main-thread executor: view ops must run on main; null (unit tests) runs inline + private val postToMainThread: ((() -> Unit) -> Unit)? = null, ) { private val gson = com.google.gson.Gson() + // 清单内存缓存:openWebApp 首次拉取后缓存,后续复用避免重复 I/O + // In-memory manifest cache: cached after first fetch to avoid repeated I/O + @Volatile + private var manifestCache: List? = null + @android.webkit.JavascriptInterface fun getDeviceInfo(): String { Log.d("HearthBridge", "getDeviceInfo called") @@ -46,53 +54,64 @@ class JsBridge( @android.webkit.JavascriptInterface fun launchApp(packageName: String): Boolean = appRepository?.launchApp(packageName) ?: false - // 返回 H5 应用清单 JSON(无仓库时返回空数组) - // Return the H5 web app manifest JSON (empty array when no repository wired) + // 返回 H5 应用清单 JSON(无仓库时返回空数组),并写入内存缓存供 openWebApp 复用 + // Return the H5 web app manifest JSON (empty array when no repository wired) and + // seed the in-memory cache for openWebApp reuse @android.webkit.JavascriptInterface - fun fetchWebApps(): String = gson.toJson(webAppRepository?.fetchManifest() ?: emptyList()) + fun fetchWebApps(): String { + val apps = manifest() + return gson.toJson(apps) + } - // 打开指定 id 的 webAPP:从清单找 url/name → 创建内容 WebView → 记录标签 → 同步顶栏 - // Open a web app by id: look up url/name from the manifest -> create content WebView - // -> record the tab -> sync the topbar + // 打开指定 id 的 webAPP:从清单缓存找 url/name → 记录标签 → 主线程创建内容 WebView → 同步顶栏 + // Open a web app by id: look up url/name from the cached manifest -> record the tab + // -> create content WebView on main thread -> sync the topbar @android.webkit.JavascriptInterface fun openWebApp(id: String) { - val repo = webAppRepository ?: return val container = webAppContainer ?: return val host = webAppHost ?: return - val app = repo.fetchManifest().firstOrNull { it.id == id } + val app = manifest().firstOrNull { it.id == id } if (app == null) { Log.d("HearthBridge", "openWebApp: unknown id=$id") return } - host.openWebView(id, app.url) container.open(id, app.url, app.name) - syncTabs() + onMain { + host.openWebView(id, app.url) + host.syncTabs(container.tabs()) + } Log.d("HearthBridge", "openWebApp: id=$id name=${app.name} url=${app.url}") } - // 关闭指定 id 的标签:销毁 WebView → 移除标签 → 若剩标签激活第一个 → 同步顶栏 - // Close a tab by id: destroy WebView -> remove tab -> activate first if any remain - // -> sync the topbar + // 关闭指定 id 的标签:移除标签 → 主线程销毁 WebView → 若剩标签激活第一个 → 同步顶栏 + // Close a tab by id: remove tab -> destroy WebView on main thread -> activate the + // first remaining tab -> sync the topbar @android.webkit.JavascriptInterface fun closeWebApp(id: String) { val container = webAppContainer ?: return val host = webAppHost ?: return - host.closeWebView(id) container.close(id) - container.activeTabId()?.let { host.switchWebView(it) } - syncTabs() + val nextActiveId = container.activeTabId() + onMain { + host.closeWebView(id) + nextActiveId?.let { host.switchWebView(it) } + host.syncTabs(container.tabs()) + } Log.d("HearthBridge", "closeWebApp: id=$id remaining=${container.tabs().size}") } - // 切换激活标签并显示对应 WebView - // Switch the active tab and show its WebView + // 切换激活标签:校验 id 存在后更新状态 → 主线程切换 WebView 可见性 → 同步顶栏 + // Switch the active tab: validate id exists, update state -> switch WebView + // visibility on main thread -> sync the topbar @android.webkit.JavascriptInterface fun switchTab(id: String) { val container = webAppContainer ?: return val host = webAppHost ?: return - container.switchTo(id) - host.switchWebView(id) - syncTabs() + if (!container.switchTo(id)) return + onMain { + host.switchWebView(id) + host.syncTabs(container.tabs()) + } Log.d("HearthBridge", "switchTab: id=$id") } @@ -107,36 +126,47 @@ class JsBridge( return gson.toJson(tabs) } - // 当前激活标签回退 - // Go back on the active tab + // 当前激活标签回退(主线程执行 WebView 导航) + // Go back on the active tab (WebView navigation runs on main thread) @android.webkit.JavascriptInterface fun webGoBack() { - webAppHost?.goBack() + val host = webAppHost ?: return + onMain { host.goBack() } Log.d("HearthBridge", "webGoBack called") } - // 当前激活标签前进 - // Go forward on the active tab + // 当前激活标签前进(主线程执行 WebView 导航) + // Go forward on the active tab (WebView navigation runs on main thread) @android.webkit.JavascriptInterface fun webGoForward() { - webAppHost?.goForward() + val host = webAppHost ?: return + onMain { host.goForward() } Log.d("HearthBridge", "webGoForward called") } - // 当前激活标签重载 - // Reload the active tab + // 当前激活标签重载(主线程执行 WebView 导航) + // Reload the active tab (WebView navigation runs on main thread) @android.webkit.JavascriptInterface fun webReload() { - webAppHost?.reload() + val host = webAppHost ?: return + onMain { host.reload() } Log.d("HearthBridge", "webReload called") } - // 将容器标签状态推送给宿主(同步桌面 H5 顶栏) - // Push the container tab state to the host (sync the desktop H5 topbar) - private fun syncTabs() { - val container = webAppContainer ?: return - val host = webAppHost ?: return - host.syncTabs(container.tabs()) + // 清单内存缓存:首次拉取后缓存,后续复用(避免 openWebApp 重复 I/O) + // In-memory manifest cache: fetch once, reuse afterwards (avoid repeated I/O) + private fun manifest(): List { + manifestCache?.let { return it } + val apps = webAppRepository?.fetchManifest() ?: emptyList() + manifestCache = apps + return apps + } + + // 将操作派发到主线程:未注入执行器(单测)时同步执行 + // Dispatch to the main thread; run inline when no executor is injected (unit tests) + private fun onMain(block: () -> Unit) { + val post = postToMainThread + if (post == null) block() else post(block) } // 返回首页卡片目录 JSON(无仓库时返回空数组;有仓库时由 fetchCatalog 保证内置 time 卡) diff --git a/app/src/test/java/top/yeij/hearth/webapp/WebAppContainerTest.kt b/app/src/test/java/top/yeij/hearth/webapp/WebAppContainerTest.kt index c9a6594..3f072cb 100644 --- a/app/src/test/java/top/yeij/hearth/webapp/WebAppContainerTest.kt +++ b/app/src/test/java/top/yeij/hearth/webapp/WebAppContainerTest.kt @@ -74,4 +74,13 @@ class WebAppContainerTest { c.open("b", "http://x/b") assertEquals("b", c.activeTabId()) } + + @Test + fun switchTo_unknownId_keepsCurrentActiveAndReturnsFalse() { + val c = WebAppContainer() + c.open("a", "http://x/a") + c.open("b", "http://x/b") + assertTrue(!c.switchTo("unknown")) + assertEquals("b", c.tabs().first { it.active }.id) + } } diff --git a/app/src/test/java/top/yeij/hearth/webview/JsBridgeTest.kt b/app/src/test/java/top/yeij/hearth/webview/JsBridgeTest.kt index 05dd81c..1fb624b 100644 --- a/app/src/test/java/top/yeij/hearth/webview/JsBridgeTest.kt +++ b/app/src/test/java/top/yeij/hearth/webview/JsBridgeTest.kt @@ -126,4 +126,43 @@ class JsBridgeTest { bridge.webGoBack() assertEquals(1, host.backCalls) } + + @Test + fun openWebApp_defersViewOpsToMainThread() { + val dir = File(System.getProperty("java.io.tmpdir"), "jb-marshal") + val http = object : HttpClient { + override fun get(url: String) = """{"apps":[{"id":"a","name":"云音乐","icon":"i","url":"http://x/a"}]}""" + } + val repo = WebAppRepository(http, CacheManager(dir), "http://fake") + val container = WebAppContainer() + val host = FakeHost() + val posted = mutableListOf<() -> Unit>() + val bridge = JsBridge( + deviceWidthPx = 800, deviceHeightPx = 480, density = 2.0f, darkMode = true, + webAppRepository = repo, webAppContainer = container, webAppHost = host, + postToMainThread = { posted.add(it) }, + ) + bridge.openWebApp("a") + assertEquals(1, container.tabs().size) + assertEquals(null, host.openedId) + assertEquals(1, posted.size) + posted.forEach { it() } + assertEquals("a", host.openedId) + assertEquals("http://x/a", host.openedUrl) + dir.deleteRecursively() + } + + @Test + fun switchTab_unknownId_doesNotTouchHost() { + val container = WebAppContainer() + container.open("a", "http://x/a", "云音乐") + val host = FakeHost() + val bridge = JsBridge( + deviceWidthPx = 800, deviceHeightPx = 480, density = 2.0f, darkMode = true, + webAppContainer = container, webAppHost = host, + ) + bridge.switchTab("unknown") + assertEquals(null, host.switchedId) + assertEquals("a", container.activeTabId()) + } }