From 4f54165f07cd97eb7c436434f1d2d5c79d5f2626 Mon Sep 17 00:00:00 2001 From: generalio <1487144524@qq.com> Date: Sat, 8 Aug 2026 12:19:39 +0800 Subject: [PATCH] fix(desktop): defer JCEF creation until native view is shown --- ...sktopInitialNativeViewLayoutCoordinator.kt | 78 +++++++++++++ .../desktop/DesktopWebViewController.kt | 40 ++++++- ...ktopNativeViewAttachmentCoordinatorTest.kt | 103 ++++++++++++++++++ 3 files changed, 218 insertions(+), 3 deletions(-) create mode 100644 webview-desktop/src/test/kotlin/io/github/multiweb/desktop/DesktopNativeViewAttachmentCoordinatorTest.kt diff --git a/webview-desktop/src/main/kotlin/io/github/multiweb/desktop/DesktopInitialNativeViewLayoutCoordinator.kt b/webview-desktop/src/main/kotlin/io/github/multiweb/desktop/DesktopInitialNativeViewLayoutCoordinator.kt index a9f1999..25d49a3 100644 --- a/webview-desktop/src/main/kotlin/io/github/multiweb/desktop/DesktopInitialNativeViewLayoutCoordinator.kt +++ b/webview-desktop/src/main/kotlin/io/github/multiweb/desktop/DesktopInitialNativeViewLayoutCoordinator.kt @@ -132,6 +132,84 @@ internal class ComponentDesktopNativeViewLayoutTarget( } } +/** + * 协调 JCEF 浏览器创建与 Compose Swing 组件挂载。 + * + * windowed JCEF 会在 [org.cef.browser.CefBrowser.createImmediately] 时创建原生子窗口;如果此时 Compose 的 + * `SwingPanel` 尚未进入原生窗口层级,首帧可能不会获得有效的父视图,直到用户缩放窗口才触发后续原生布局。 + * 因此必须等待目标组件真正 showing 后再创建浏览器。 + */ +internal class DesktopNativeViewAttachmentCoordinator( + private val target: DesktopNativeViewLayoutTarget, + private val isControllerDisposed: () -> Boolean, + private val createBrowser: () -> Unit, +) { + private var isDisposed = false + private var isShowingListenerRegistered = false + private var isBrowserCreationRequested = false + private var isBrowserCreationStarted = false + private var isBrowserCreated = false + private val showingListener: () -> Unit = ::createBrowserIfReady + + /** 注册 showing 监听;浏览器创建请求可以先于原生视图挂载到达。 */ + fun registerShowingListener() { + if (isUnavailable() || isShowingListenerRegistered) { + return + } + target.addShowingListener(showingListener) + isShowingListenerRegistered = true + } + + /** 请求创建浏览器;仅在组件已显示且拥有原生父视图时执行。 */ + fun requestBrowserCreation() { + if (isUnavailable()) { + return + } + isBrowserCreationRequested = true + createBrowserIfReady() + } + + /** 取消等待中的创建请求;已开始创建的浏览器由控制器自身关闭。 */ + fun dispose() { + if (isDisposed) { + return + } + isDisposed = true + removeShowingListener() + } + + private fun createBrowserIfReady() { + if ( + isUnavailable() || + !isBrowserCreationRequested || + isBrowserCreated || + isBrowserCreationStarted || + !target.isDisplayable || + !target.isShowing + ) { + return + } + isBrowserCreationStarted = true + try { + createBrowser() + isBrowserCreated = true + removeShowingListener() + } finally { + isBrowserCreationStarted = false + } + } + + private fun removeShowingListener() { + if (!isShowingListenerRegistered) { + return + } + target.removeShowingListener(showingListener) + isShowingListenerRegistered = false + } + + private fun isUnavailable(): Boolean = isDisposed || isControllerDisposed() +} + /** * 协调 JCEF 就绪与 Swing 视图首次 showing 的布局同步。 * diff --git a/webview-desktop/src/main/kotlin/io/github/multiweb/desktop/DesktopWebViewController.kt b/webview-desktop/src/main/kotlin/io/github/multiweb/desktop/DesktopWebViewController.kt index f9ee857..8843c90 100644 --- a/webview-desktop/src/main/kotlin/io/github/multiweb/desktop/DesktopWebViewController.kt +++ b/webview-desktop/src/main/kotlin/io/github/multiweb/desktop/DesktopWebViewController.kt @@ -121,9 +121,14 @@ class DesktopWebViewController( /** 供宿主添加到 Swing/AWT 视图层级的 JCEF 原生组件。 */ val view: Component + /** 等待 Compose Swing 宿主挂载后才创建 JCEF 原生浏览器,避免首帧缺失父视图。 */ + private lateinit var nativeViewAttachmentCoordinator: DesktopNativeViewAttachmentCoordinator /** 等待 JCEF 与 Swing 均就绪后同步 windowed 原生浏览器的首次布局。 */ private lateinit var initialNativeViewLayoutCoordinator: DesktopInitialNativeViewLayoutCoordinator + /** `createImmediately()` 已调用后才可以请求 JCEF 的正常浏览器关闭路径。 */ + private var isBrowserCreationStarted = false + /** 控制器是否已释放。释放后除 [dispose] 外的操作都会抛出 [IllegalStateException]。 */ @Volatile var isDisposed: Boolean = false @@ -167,12 +172,22 @@ class DesktopWebViewController( ) browser = client.createBrowser("about:blank", false, false) view = browser.uiComponent + val nativeViewTarget = ComponentDesktopNativeViewLayoutTarget(view) + nativeViewAttachmentCoordinator = DesktopNativeViewAttachmentCoordinator( + target = nativeViewTarget, + isControllerDisposed = { isDisposed }, + createBrowser = { + isBrowserCreationStarted = true + browser.createImmediately() + }, + ) initialNativeViewLayoutCoordinator = DesktopInitialNativeViewLayoutCoordinator( - target = ComponentDesktopNativeViewLayoutTarget(view), + target = nativeViewTarget, isControllerDisposed = { isDisposed }, ) + nativeViewAttachmentCoordinator.registerShowingListener() initialNativeViewLayoutCoordinator.registerShowingListener() - browser.createImmediately() + nativeViewAttachmentCoordinator.requestBrowserCreation() extensions.filterIsInstance().forEach { extension -> extension.onControllerAttached(this) } @@ -260,6 +275,7 @@ class DesktopWebViewController( } isDisposed = true + nativeViewAttachmentCoordinator.dispose() initialNativeViewLayoutCoordinator.dispose() extensions.filterIsInstance().forEach { extension -> extension.onControllerDisposed() @@ -270,7 +286,11 @@ class DesktopWebViewController( activeFileChoosers.forEach(DesktopFileChooserCallbackGuard::cancel) activeFileChoosers.clear() scriptBridgeInstallation.dispose() - closeBrowser() + if (isBrowserCreationStarted) { + closeBrowser() + } else { + disposeUncreatedBrowser() + } state = state.copy(isLoading = false) } @@ -285,6 +305,20 @@ class DesktopWebViewController( closeDesktopBrowser(browser) } + /** + * Compose 页面可能在原生视图首次 showing 前离开组合;此时没有可关闭的 JCEF Browser,直接释放 Client 并通知宿主。 + * + * 不能等待 `onBeforeClose`:未创建的浏览器不会产生该回调,应用退出协调器会因此永久等待控制器关闭确认。 + */ + private fun disposeUncreatedBrowser() { + if (isClientDisposed) { + return + } + isClientDisposed = true + client.dispose() + SwingUtilities.invokeLater(onBrowserClosed) + } + private fun configureClient(client: CefClient) { client.addRequestHandler(createRequestHandler()) client.addLoadHandler(createLoadHandler()) diff --git a/webview-desktop/src/test/kotlin/io/github/multiweb/desktop/DesktopNativeViewAttachmentCoordinatorTest.kt b/webview-desktop/src/test/kotlin/io/github/multiweb/desktop/DesktopNativeViewAttachmentCoordinatorTest.kt new file mode 100644 index 0000000..8a1fb61 --- /dev/null +++ b/webview-desktop/src/test/kotlin/io/github/multiweb/desktop/DesktopNativeViewAttachmentCoordinatorTest.kt @@ -0,0 +1,103 @@ +package io.github.multiweb.desktop + +import kotlin.test.Test +import kotlin.test.assertEquals + +class DesktopNativeViewAttachmentCoordinatorTest { + + @Test + fun `创建浏览器会等待原生视图进入 showing 状态`() { + val target = FakeAttachmentTarget(isShowing = false) + val created = mutableListOf() + val coordinator = DesktopNativeViewAttachmentCoordinator( + target = target, + isControllerDisposed = { false }, + createBrowser = { created += "browser" }, + ) + + coordinator.registerShowingListener() + coordinator.requestBrowserCreation() + + assertEquals(emptyList(), created) + + target.isShowing = true + target.dispatchShowingChanged() + + assertEquals(listOf("browser"), created) + assertEquals(1, target.removeShowingListenerCount) + } + + @Test + fun `已显示的视图只会创建一次浏览器`() { + val target = FakeAttachmentTarget(isShowing = true) + var createCount = 0 + val coordinator = DesktopNativeViewAttachmentCoordinator( + target = target, + isControllerDisposed = { false }, + createBrowser = { createCount++ }, + ) + + coordinator.registerShowingListener() + coordinator.requestBrowserCreation() + target.dispatchShowingChanged() + coordinator.requestBrowserCreation() + + assertEquals(1, createCount) + } + + @Test + fun `控制器销毁后不会创建浏览器`() { + val target = FakeAttachmentTarget(isShowing = false) + var createCount = 0 + val coordinator = DesktopNativeViewAttachmentCoordinator( + target = target, + isControllerDisposed = { false }, + createBrowser = { createCount++ }, + ) + + coordinator.registerShowingListener() + coordinator.dispose() + target.isShowing = true + target.dispatchShowingChanged() + coordinator.requestBrowserCreation() + + assertEquals(0, createCount) + assertEquals(1, target.removeShowingListenerCount) + } +} + +private class FakeAttachmentTarget( + override var isDisplayable: Boolean = true, + override var isShowing: Boolean, +) : DesktopNativeViewLayoutTarget { + private val showingListeners = mutableSetOf<() -> Unit>() + + var removeShowingListenerCount = 0 + private set + + override val width: Int = 100 + override val height: Int = 100 + + override fun addShowingListener(listener: () -> Unit) { + showingListeners += listener + } + + override fun removeShowingListener(listener: () -> Unit) { + removeShowingListenerCount++ + showingListeners -= listener + } + + override fun addLayoutChangedListener(listener: () -> Unit) = Unit + + override fun removeLayoutChangedListener(listener: () -> Unit) = Unit + + override fun revalidate() = Unit + + override fun paintImmediately() = Unit + + override fun repaint() = Unit + + fun dispatchShowingChanged() { + showingListeners.toList().forEach { listener -> listener() } + } +}