fix: marshal js bridge view ops to main thread

This commit is contained in:
2026-08-16 17:21:02 +08:00
parent 6ca55dbe5f
commit a0f8873b7a
5 changed files with 126 additions and 45 deletions
@@ -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<WebApp>? = 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<WebApp>())
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<WebApp> {
manifestCache?.let { return it }
val apps = webAppRepository?.fetchManifest() ?: emptyList<WebApp>()
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 卡)