fix: marshal js bridge view ops to main thread
This commit is contained in:
@@ -86,6 +86,7 @@ class MainActivity : Activity() {
|
|||||||
cardRepository = CardRepository(http, cache, CATALOG_URL),
|
cardRepository = CardRepository(http, cache, CATALOG_URL),
|
||||||
webAppContainer = webAppContainer,
|
webAppContainer = webAppContainer,
|
||||||
webAppHost = webAppHost,
|
webAppHost = webAppHost,
|
||||||
|
postToMainThread = { desktopWebView.post(it) },
|
||||||
)
|
)
|
||||||
root = FrameLayout(this)
|
root = FrameLayout(this)
|
||||||
setContentView(root)
|
setContentView(root)
|
||||||
@@ -195,15 +196,15 @@ class MainActivity : Activity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override fun syncTabs(tabs: List<Tab>) {
|
override fun syncTabs(tabs: List<Tab>) {
|
||||||
|
// JsBridge 已把该调用 marshal 到主线程,这里可直接 evaluateJavascript
|
||||||
|
// JsBridge already marshaled this call to the main thread, so evaluateJavascript is safe
|
||||||
val json = gson.toJson(
|
val json = gson.toJson(
|
||||||
tabs.map { mapOf("id" to it.id, "name" to it.name, "active" to it.active) }
|
tabs.map { mapOf("id" to it.id, "name" to it.name, "active" to it.active) }
|
||||||
)
|
)
|
||||||
desktopWebView.post {
|
|
||||||
desktopWebView.evaluateJavascript(
|
desktopWebView.evaluateJavascript(
|
||||||
"window.showTopbar && window.showTopbar($json);",
|
"window.showTopbar && window.showTopbar($json);",
|
||||||
null
|
null
|
||||||
)
|
)
|
||||||
}
|
|
||||||
Log.d(TAG, "syncTabs: ${tabs.size} tabs")
|
Log.d(TAG, "syncTabs: ${tabs.size} tabs")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,10 +34,12 @@ class WebAppContainer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 切换激活标签
|
// 切换激活标签:id 不存在时返回 false 且不改动状态(保持原激活标签)
|
||||||
// Switch the active tab
|
// Switch the active tab: return false and keep current state when id is unknown
|
||||||
fun switchTo(id: String) {
|
fun switchTo(id: String): Boolean {
|
||||||
|
if (tabs.none { it.id == id }) return false
|
||||||
setActive(id)
|
setActive(id)
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// 返回标签列表副本(外部无法直接改动内部状态)
|
// 返回标签列表副本(外部无法直接改动内部状态)
|
||||||
|
|||||||
@@ -20,9 +20,17 @@ class JsBridge(
|
|||||||
private val cardRepository: CardRepository? = null,
|
private val cardRepository: CardRepository? = null,
|
||||||
private val webAppContainer: WebAppContainer? = null,
|
private val webAppContainer: WebAppContainer? = null,
|
||||||
private val webAppHost: WebAppHost? = 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()
|
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
|
@android.webkit.JavascriptInterface
|
||||||
fun getDeviceInfo(): String {
|
fun getDeviceInfo(): String {
|
||||||
Log.d("HearthBridge", "getDeviceInfo called")
|
Log.d("HearthBridge", "getDeviceInfo called")
|
||||||
@@ -46,53 +54,64 @@ class JsBridge(
|
|||||||
@android.webkit.JavascriptInterface
|
@android.webkit.JavascriptInterface
|
||||||
fun launchApp(packageName: String): Boolean = appRepository?.launchApp(packageName) ?: false
|
fun launchApp(packageName: String): Boolean = appRepository?.launchApp(packageName) ?: false
|
||||||
|
|
||||||
// 返回 H5 应用清单 JSON(无仓库时返回空数组)
|
// 返回 H5 应用清单 JSON(无仓库时返回空数组),并写入内存缓存供 openWebApp 复用
|
||||||
// Return the H5 web app manifest JSON (empty array when no repository wired)
|
// 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
|
@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 → 记录标签 → 同步顶栏
|
// 打开指定 id 的 webAPP:从清单缓存找 url/name → 记录标签 → 主线程创建内容 WebView → 同步顶栏
|
||||||
// Open a web app by id: look up url/name from the manifest -> create content WebView
|
// Open a web app by id: look up url/name from the cached manifest -> record the tab
|
||||||
// -> record the tab -> sync the topbar
|
// -> create content WebView on main thread -> sync the topbar
|
||||||
@android.webkit.JavascriptInterface
|
@android.webkit.JavascriptInterface
|
||||||
fun openWebApp(id: String) {
|
fun openWebApp(id: String) {
|
||||||
val repo = webAppRepository ?: return
|
|
||||||
val container = webAppContainer ?: return
|
val container = webAppContainer ?: return
|
||||||
val host = webAppHost ?: return
|
val host = webAppHost ?: return
|
||||||
val app = repo.fetchManifest().firstOrNull { it.id == id }
|
val app = manifest().firstOrNull { it.id == id }
|
||||||
if (app == null) {
|
if (app == null) {
|
||||||
Log.d("HearthBridge", "openWebApp: unknown id=$id")
|
Log.d("HearthBridge", "openWebApp: unknown id=$id")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
host.openWebView(id, app.url)
|
|
||||||
container.open(id, app.url, app.name)
|
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}")
|
Log.d("HearthBridge", "openWebApp: id=$id name=${app.name} url=${app.url}")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 关闭指定 id 的标签:销毁 WebView → 移除标签 → 若剩标签激活第一个 → 同步顶栏
|
// 关闭指定 id 的标签:移除标签 → 主线程销毁 WebView → 若剩标签激活第一个 → 同步顶栏
|
||||||
// Close a tab by id: destroy WebView -> remove tab -> activate first if any remain
|
// Close a tab by id: remove tab -> destroy WebView on main thread -> activate the
|
||||||
// -> sync the topbar
|
// first remaining tab -> sync the topbar
|
||||||
@android.webkit.JavascriptInterface
|
@android.webkit.JavascriptInterface
|
||||||
fun closeWebApp(id: String) {
|
fun closeWebApp(id: String) {
|
||||||
val container = webAppContainer ?: return
|
val container = webAppContainer ?: return
|
||||||
val host = webAppHost ?: return
|
val host = webAppHost ?: return
|
||||||
host.closeWebView(id)
|
|
||||||
container.close(id)
|
container.close(id)
|
||||||
container.activeTabId()?.let { host.switchWebView(it) }
|
val nextActiveId = container.activeTabId()
|
||||||
syncTabs()
|
onMain {
|
||||||
|
host.closeWebView(id)
|
||||||
|
nextActiveId?.let { host.switchWebView(it) }
|
||||||
|
host.syncTabs(container.tabs())
|
||||||
|
}
|
||||||
Log.d("HearthBridge", "closeWebApp: id=$id remaining=${container.tabs().size}")
|
Log.d("HearthBridge", "closeWebApp: id=$id remaining=${container.tabs().size}")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 切换激活标签并显示对应 WebView
|
// 切换激活标签:校验 id 存在后更新状态 → 主线程切换 WebView 可见性 → 同步顶栏
|
||||||
// Switch the active tab and show its WebView
|
// Switch the active tab: validate id exists, update state -> switch WebView
|
||||||
|
// visibility on main thread -> sync the topbar
|
||||||
@android.webkit.JavascriptInterface
|
@android.webkit.JavascriptInterface
|
||||||
fun switchTab(id: String) {
|
fun switchTab(id: String) {
|
||||||
val container = webAppContainer ?: return
|
val container = webAppContainer ?: return
|
||||||
val host = webAppHost ?: return
|
val host = webAppHost ?: return
|
||||||
container.switchTo(id)
|
if (!container.switchTo(id)) return
|
||||||
|
onMain {
|
||||||
host.switchWebView(id)
|
host.switchWebView(id)
|
||||||
syncTabs()
|
host.syncTabs(container.tabs())
|
||||||
|
}
|
||||||
Log.d("HearthBridge", "switchTab: id=$id")
|
Log.d("HearthBridge", "switchTab: id=$id")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,36 +126,47 @@ class JsBridge(
|
|||||||
return gson.toJson(tabs)
|
return gson.toJson(tabs)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 当前激活标签回退
|
// 当前激活标签回退(主线程执行 WebView 导航)
|
||||||
// Go back on the active tab
|
// Go back on the active tab (WebView navigation runs on main thread)
|
||||||
@android.webkit.JavascriptInterface
|
@android.webkit.JavascriptInterface
|
||||||
fun webGoBack() {
|
fun webGoBack() {
|
||||||
webAppHost?.goBack()
|
val host = webAppHost ?: return
|
||||||
|
onMain { host.goBack() }
|
||||||
Log.d("HearthBridge", "webGoBack called")
|
Log.d("HearthBridge", "webGoBack called")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 当前激活标签前进
|
// 当前激活标签前进(主线程执行 WebView 导航)
|
||||||
// Go forward on the active tab
|
// Go forward on the active tab (WebView navigation runs on main thread)
|
||||||
@android.webkit.JavascriptInterface
|
@android.webkit.JavascriptInterface
|
||||||
fun webGoForward() {
|
fun webGoForward() {
|
||||||
webAppHost?.goForward()
|
val host = webAppHost ?: return
|
||||||
|
onMain { host.goForward() }
|
||||||
Log.d("HearthBridge", "webGoForward called")
|
Log.d("HearthBridge", "webGoForward called")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 当前激活标签重载
|
// 当前激活标签重载(主线程执行 WebView 导航)
|
||||||
// Reload the active tab
|
// Reload the active tab (WebView navigation runs on main thread)
|
||||||
@android.webkit.JavascriptInterface
|
@android.webkit.JavascriptInterface
|
||||||
fun webReload() {
|
fun webReload() {
|
||||||
webAppHost?.reload()
|
val host = webAppHost ?: return
|
||||||
|
onMain { host.reload() }
|
||||||
Log.d("HearthBridge", "webReload called")
|
Log.d("HearthBridge", "webReload called")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 将容器标签状态推送给宿主(同步桌面 H5 顶栏)
|
// 清单内存缓存:首次拉取后缓存,后续复用(避免 openWebApp 重复 I/O)
|
||||||
// Push the container tab state to the host (sync the desktop H5 topbar)
|
// In-memory manifest cache: fetch once, reuse afterwards (avoid repeated I/O)
|
||||||
private fun syncTabs() {
|
private fun manifest(): List<WebApp> {
|
||||||
val container = webAppContainer ?: return
|
manifestCache?.let { return it }
|
||||||
val host = webAppHost ?: return
|
val apps = webAppRepository?.fetchManifest() ?: emptyList<WebApp>()
|
||||||
host.syncTabs(container.tabs())
|
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 卡)
|
// 返回首页卡片目录 JSON(无仓库时返回空数组;有仓库时由 fetchCatalog 保证内置 time 卡)
|
||||||
|
|||||||
@@ -74,4 +74,13 @@ class WebAppContainerTest {
|
|||||||
c.open("b", "http://x/b")
|
c.open("b", "http://x/b")
|
||||||
assertEquals("b", c.activeTabId())
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -126,4 +126,43 @@ class JsBridgeTest {
|
|||||||
bridge.webGoBack()
|
bridge.webGoBack()
|
||||||
assertEquals(1, host.backCalls)
|
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())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user