From c819838cd188aa93cab4bcbbcd97c812c30b2809 Mon Sep 17 00:00:00 2001 From: generalio <1487144524@qq.com> Date: Fri, 7 Aug 2026 21:32:36 +0800 Subject: [PATCH] =?UTF-8?q?fix(desktop):=20=E5=90=8C=E6=AD=A5=E9=A6=96?= =?UTF-8?q?=E6=AC=A1=E5=8E=9F=E7=94=9F=E8=A7=86=E5=9B=BE=E7=BB=98=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...77\347\224\250\346\214\207\345\215\227.md" | 19 ++++++-- ...66\346\236\204\350\257\264\346\230\216.md" | 6 ++- .../io/github/multiweb/sample/desktop/Main.kt | 10 +++- webview-compose/api/jvm/webview-compose.api | 1 + .../compose/ComposeWebView.desktop.kt | 33 +++++++++---- .../compose/DesktopWebViewRuntimeTest.kt | 47 +++++++++++++++++++ ...sktopInitialNativeViewLayoutCoordinator.kt | 28 ++++++++++- ...pInitialNativeViewLayoutCoordinatorTest.kt | 37 ++++++++++++++- 8 files changed, 162 insertions(+), 19 deletions(-) create mode 100644 webview-compose/src/jvmTest/kotlin/io/github/multiweb/compose/DesktopWebViewRuntimeTest.kt diff --git "a/docs/\344\275\277\347\224\250\346\214\207\345\215\227.md" "b/docs/\344\275\277\347\224\250\346\214\207\345\215\227.md" index b66fc2e..5ef97d7 100644 --- "a/docs/\344\275\277\347\224\250\346\214\207\345\215\227.md" +++ "b/docs/\344\275\277\347\224\250\346\214\207\345\215\227.md" @@ -211,9 +211,22 @@ val controller = DesktopWebViewController( JCEF 不能按单个浏览器可靠关闭 JavaScript、第三方 Cookie 或持久化会话。为了避免静默采用进程默认值,Desktop 控制器要求这三项均显式为 `true`;更严格的 JCEF 策略必须在创建 `CefApp` 前由宿主配置。 -macOS 使用 windowed JCEF 与 `SwingPanel` 嵌入原生浏览器。`DesktopWebViewRuntime.initialize(...)` 会在宿主未设置 -`compose.interop.blending` 时将其设为 `true`,但不会覆盖宿主的显式选择;浏览器创建完成且 Swing 视图首次显示后, -MultiWeb 会自动同步原生视图的布局与重绘。调用方无需在 Compose 重组中手动调用 `repaint()`。 +macOS 使用 windowed JCEF 与 `SwingPanel` 嵌入原生浏览器。应在创建 Compose `application {}` 前调用 +`DesktopWebViewRuntime.prepareComposeInterop()`;它只在宿主未设置 `compose.interop.blending` 时将其设为 `true`, +不会覆盖宿主的显式选择。`initialize(...)` 会保留一次兜底调用以兼容旧接入方,但其时机可能晚于 Compose 互操作层初始化: + +```kotlin +fun main() { + DesktopWebViewRuntime.prepareComposeInterop() + + application { + // 创建 CefApp,并调用 DesktopWebViewRuntime.initialize(...) + } +} +``` + +浏览器创建完成且 Swing 视图首次显示后,MultiWeb 会同步执行一次布局、即时绘制和后续重绘,触发 JCEF 的 windowed +原生子窗口绑定。调用方无需在 Compose 重组中手动调用 `repaint()`。 ### JS 与 Wasm diff --git "a/docs/\346\236\266\346\236\204\350\257\264\346\230\216.md" "b/docs/\346\236\266\346\236\204\350\257\264\346\230\216.md" index 8b2444c..545ad24 100644 --- "a/docs/\346\236\266\346\236\204\350\257\264\346\230\216.md" +++ "b/docs/\346\236\266\346\236\204\350\257\264\346\230\216.md" @@ -69,8 +69,10 @@ webview-test-fixtures ──> webview-api(仅工程内测试,不发布) | JS/Wasm | 无原生视图,按策略在新窗口或新标签页打开 URL;不持有浏览器会话。 | Desktop 的 JCEF 采用 windowed 模式,不切换 OSR。控制器会在 JCEF 浏览器创建完成和 Swing 组件首次处于 showing 状态后, -在 Swing EDT 主动完成一次 `revalidate()` 与 `repaint()`,避免 Compose Desktop 初次挂载时原生子窗口没有同步尺寸而黑屏。 -`DesktopWebViewRuntime.initialize` 仅会在 macOS 且宿主未设置 `compose.interop.blending` 时提供 `true` 的互操作默认值。 +在 Swing EDT 主动完成一次 `revalidate()`、`paintImmediately()` 与 `repaint()`,以同步进入 JCEF `JPanel.paint()`,触发 +`doUpdate()` 与原生子窗口绑定,避免 Compose Desktop 初次挂载时黑屏。`DesktopWebViewRuntime.prepareComposeInterop()` 应在 +macOS 的 Compose `application {}` 前调用;它仅在宿主未设置 `compose.interop.blending` 时提供 `true` 的互操作默认值, +`initialize` 仍保留兜底调用以兼容旧接入方。 ## 扩展方式 diff --git a/sample-compose/src/desktopMain/kotlin/io/github/multiweb/sample/desktop/Main.kt b/sample-compose/src/desktopMain/kotlin/io/github/multiweb/sample/desktop/Main.kt index 777fc33..1aed692 100644 --- a/sample-compose/src/desktopMain/kotlin/io/github/multiweb/sample/desktop/Main.kt +++ b/sample-compose/src/desktopMain/kotlin/io/github/multiweb/sample/desktop/Main.kt @@ -25,8 +25,14 @@ import me.friwi.jcefmaven.CefAppBuilder /** 单个图片保存请求允许下载的最大字节数,避免网页桥触发无上限的文件写入。 */ private const val MaxSampleImageBytes = 10L * 1024L * 1024L -/** 桌面示例入口;JCEF 首次启动会按 jcefmaven 配置准备原生运行时。 */ -fun main() = application { +/** 桌面示例入口;必须在创建 Compose 应用前准备 macOS windowed JCEF 的 Swing 互操作层。 */ +fun main() { + DesktopWebViewRuntime.prepareComposeInterop() + runDesktopSampleApplication() +} + +/** JCEF 首次启动会按 jcefmaven 配置准备原生运行时。 */ +private fun runDesktopSampleApplication() = application { var isFullscreen by remember { mutableStateOf(false) } var pendingImageSaveUrl by remember { mutableStateOf(null) } var hostCapabilityNotice by remember { mutableStateOf(null) } diff --git a/webview-compose/api/jvm/webview-compose.api b/webview-compose/api/jvm/webview-compose.api index 09ca3a6..a2d472c 100644 --- a/webview-compose/api/jvm/webview-compose.api +++ b/webview-compose/api/jvm/webview-compose.api @@ -8,6 +8,7 @@ public final class io/github/multiweb/compose/DesktopWebViewRuntime { public static final field INSTANCE Lio/github/multiweb/compose/DesktopWebViewRuntime; public final fun initialize (Lorg/cef/CefApp;Lkotlin/jvm/functions/Function0;)V public static synthetic fun initialize$default (Lio/github/multiweb/compose/DesktopWebViewRuntime;Lorg/cef/CefApp;Lkotlin/jvm/functions/Function0;ILjava/lang/Object;)V + public final fun prepareComposeInterop ()V } public final class io/github/multiweb/compose/WebViewHostCallbacks { diff --git a/webview-compose/src/jvmMain/kotlin/io/github/multiweb/compose/ComposeWebView.desktop.kt b/webview-compose/src/jvmMain/kotlin/io/github/multiweb/compose/ComposeWebView.desktop.kt index 7cee409..af9473f 100644 --- a/webview-compose/src/jvmMain/kotlin/io/github/multiweb/compose/ComposeWebView.desktop.kt +++ b/webview-compose/src/jvmMain/kotlin/io/github/multiweb/compose/ComposeWebView.desktop.kt @@ -24,6 +24,20 @@ import org.cef.CefApp object DesktopWebViewRuntime { private var configuration: DesktopWebViewRuntimeConfiguration? = null + /** + * 在启动 Compose Desktop 应用前准备 macOS 的 Swing 互操作配置。 + * + * windowed JCEF 依赖 Compose 的原生 Swing 混合层;使用 macOS 时,应在 `application {}` 或其他 Compose 应用入口 + * 之前调用。宿主已显式设置 `compose.interop.blending` 时保持其选择不变;非 macOS 不执行任何操作。该方法可重复调用。 + */ + fun prepareComposeInterop() { + prepareMacosComposeInterop( + operatingSystemName = System.getProperty("os.name"), + currentBlendingValue = System.getProperty("compose.interop.blending"), + setBlendingValue = { value -> System.setProperty("compose.interop.blending", value) }, + ) + } + /** * 注入当前进程唯一的 JCEF 应用实例。 * @@ -35,7 +49,8 @@ object DesktopWebViewRuntime { onBrowserClosed: () -> Unit = {}, ) { synchronized(this) { - enableMacosInteropBlendingByDefault() + // 兼容旧接入方:即使未在 application 前准备,仍尽力提供正确的互操作默认值。 + prepareComposeInterop() check(configuration == null) { "DesktopWebViewRuntime 已初始化;同一进程只能注入一个 CefApp。" } @@ -55,15 +70,15 @@ object DesktopWebViewRuntime { /** * macOS 的 JCEF windowed 浏览器属于原生窗口层;Compose 默认绘制层可能遮蔽它。 * - * Compose 通过该属性启用官方 Swing 互操作混合层。仅在调用方未显式配置该属性时提供 Desktop WebView 的 - * 安全默认值,避免覆盖宿主已有的全局 Compose 配置。 + * 参数化后可在不修改 JVM 全局属性的单元测试中验证“仅 macOS、且不覆盖宿主设置”的约束。 */ -private fun enableMacosInteropBlendingByDefault() { - if ( - System.getProperty("os.name").startsWith("Mac") && - System.getProperty("compose.interop.blending") == null - ) { - System.setProperty("compose.interop.blending", "true") +internal fun prepareMacosComposeInterop( + operatingSystemName: String, + currentBlendingValue: String?, + setBlendingValue: (String) -> Unit, +) { + if (operatingSystemName.startsWith("Mac") && currentBlendingValue == null) { + setBlendingValue("true") } } diff --git a/webview-compose/src/jvmTest/kotlin/io/github/multiweb/compose/DesktopWebViewRuntimeTest.kt b/webview-compose/src/jvmTest/kotlin/io/github/multiweb/compose/DesktopWebViewRuntimeTest.kt new file mode 100644 index 0000000..0e460f9 --- /dev/null +++ b/webview-compose/src/jvmTest/kotlin/io/github/multiweb/compose/DesktopWebViewRuntimeTest.kt @@ -0,0 +1,47 @@ +package io.github.multiweb.compose + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class DesktopWebViewRuntimeTest { + + @Test + fun `macOS 且宿主未配置时启用混合互操作`() { + var configuredValue: String? = null + + prepareMacosComposeInterop( + operatingSystemName = "Mac OS X", + currentBlendingValue = null, + setBlendingValue = { configuredValue = it }, + ) + + assertEquals("true", configuredValue) + } + + @Test + fun `不会覆盖宿主已配置的混合互操作值`() { + var configuredValue: String? = null + + prepareMacosComposeInterop( + operatingSystemName = "Mac OS X", + currentBlendingValue = "false", + setBlendingValue = { configuredValue = it }, + ) + + assertNull(configuredValue) + } + + @Test + fun `非 macOS 不配置混合互操作`() { + var configuredValue: String? = null + + prepareMacosComposeInterop( + operatingSystemName = "Linux", + currentBlendingValue = null, + setBlendingValue = { configuredValue = it }, + ) + + assertNull(configuredValue) + } +} 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 5e80747..2ab1aa8 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 @@ -3,6 +3,8 @@ package io.github.multiweb.desktop import java.awt.Component import java.awt.event.HierarchyEvent import java.awt.event.HierarchyListener +import javax.swing.JComponent +import javax.swing.SwingUtilities /** * 可参与 JCEF 初始布局同步的原生视图。 @@ -28,6 +30,9 @@ internal interface DesktopNativeViewLayoutTarget { /** 请求 AWT 重新执行布局。 */ fun revalidate() + /** 同步进入原生 Swing 视图的绘制流程。 */ + fun paintImmediately() + /** 请求 AWT 重绘原生视图。 */ fun repaint() } @@ -74,6 +79,24 @@ internal class ComponentDesktopNativeViewLayoutTarget( component.revalidate() } + /** + * 同步绘制 JCEF 暴露的 JPanel,触发其内部的 `doUpdate()` 与原生浏览器子窗口绑定。 + * + * JCEF windowed 模式下普通 [repaint] 只会异步请求 Swing 绘制,Compose `SwingPanel` 的首次挂载可能不会立刻 + * 进入 `paint()`;这里仅在 Swing EDT 和可见有效边界内强制执行一次。 + */ + override fun paintImmediately() { + check(SwingUtilities.isEventDispatchThread()) { + "JCEF 初始同步绘制必须在 Swing EDT 执行。" + } + val width = component.width + val height = component.height + if (!component.isDisplayable || !component.isShowing || width <= 0 || height <= 0) { + return + } + (component as? JComponent)?.paintImmediately(0, 0, width, height) ?: component.repaint() + } + override fun repaint() { component.repaint() } @@ -83,8 +106,8 @@ internal class ComponentDesktopNativeViewLayoutTarget( * 协调 JCEF 就绪与 Swing 视图首次 showing 的布局同步。 * * windowed JCEF 的原生子窗口需要在 Swing 已完成首次绘制后同步尺寸;因此只在控制器明确请求、视图可显示且尺寸 - * 有效时执行一次 [DesktopNativeViewLayoutTarget.revalidate] 与 [DesktopNativeViewLayoutTarget.repaint]。调用方必须在 - * Swing EDT 调用本类。 + * 有效时执行一次 [DesktopNativeViewLayoutTarget.revalidate]、[DesktopNativeViewLayoutTarget.paintImmediately] 与 + * [DesktopNativeViewLayoutTarget.repaint]。调用方必须在 Swing EDT 调用本类。 */ internal class DesktopInitialNativeViewLayoutCoordinator( private val target: DesktopNativeViewLayoutTarget, @@ -144,6 +167,7 @@ internal class DesktopInitialNativeViewLayoutCoordinator( return } target.revalidate() + target.paintImmediately() target.repaint() isInitialLayoutSynchronized = true } diff --git a/webview-desktop/src/test/kotlin/io/github/multiweb/desktop/DesktopInitialNativeViewLayoutCoordinatorTest.kt b/webview-desktop/src/test/kotlin/io/github/multiweb/desktop/DesktopInitialNativeViewLayoutCoordinatorTest.kt index a07313b..af2cc06 100644 --- a/webview-desktop/src/test/kotlin/io/github/multiweb/desktop/DesktopInitialNativeViewLayoutCoordinatorTest.kt +++ b/webview-desktop/src/test/kotlin/io/github/multiweb/desktop/DesktopInitialNativeViewLayoutCoordinatorTest.kt @@ -14,11 +14,12 @@ class DesktopInitialNativeViewLayoutCoordinatorTest { coordinator.requestInitialNativeViewLayout() assertEquals(0, target.revalidateCount) + assertEquals(0, target.immediatePaintCount) assertEquals(0, target.repaintCount) } @Test - fun `收到 showing 事件后只同步一次`() { + fun `收到 showing 事件后按同步顺序绘制一次`() { val target = FakeDesktopNativeViewLayoutTarget(isShowing = false) val coordinator = DesktopInitialNativeViewLayoutCoordinator(target) { false } @@ -29,7 +30,28 @@ class DesktopInitialNativeViewLayoutCoordinatorTest { target.dispatchShowingChanged() assertEquals(1, target.revalidateCount) + assertEquals(1, target.immediatePaintCount) assertEquals(1, target.repaintCount) + assertEquals( + listOf("revalidate", "paintImmediately", "repaint"), + target.synchronizationOperations, + ) + } + + @Test + fun `尺寸无效时不会同步绘制`() { + val target = FakeDesktopNativeViewLayoutTarget( + isShowing = true, + width = 0, + ) + val coordinator = DesktopInitialNativeViewLayoutCoordinator(target) { false } + + coordinator.registerShowingListener() + coordinator.requestInitialNativeViewLayout() + + assertEquals(0, target.revalidateCount) + assertEquals(0, target.immediatePaintCount) + assertEquals(0, target.repaintCount) } @Test @@ -44,6 +66,7 @@ class DesktopInitialNativeViewLayoutCoordinatorTest { assertEquals(1, target.removeListenerCount) assertEquals(0, target.revalidateCount) + assertEquals(0, target.immediatePaintCount) assertEquals(0, target.repaintCount) } @@ -59,6 +82,7 @@ class DesktopInitialNativeViewLayoutCoordinatorTest { assertEquals(0, target.addListenerCount) assertEquals(0, target.revalidateCount) + assertEquals(0, target.immediatePaintCount) assertEquals(0, target.repaintCount) } @@ -74,6 +98,7 @@ class DesktopInitialNativeViewLayoutCoordinatorTest { assertEquals(1, target.addListenerCount) assertEquals(1, target.revalidateCount) + assertEquals(1, target.immediatePaintCount) assertEquals(1, target.repaintCount) } } @@ -92,8 +117,11 @@ private class FakeDesktopNativeViewLayoutTarget( private set var revalidateCount = 0 private set + var immediatePaintCount = 0 + private set var repaintCount = 0 private set + val synchronizationOperations = mutableListOf() override fun addShowingListener(listener: () -> Unit) { addListenerCount++ @@ -107,10 +135,17 @@ private class FakeDesktopNativeViewLayoutTarget( override fun revalidate() { revalidateCount++ + synchronizationOperations += "revalidate" + } + + override fun paintImmediately() { + immediatePaintCount++ + synchronizationOperations += "paintImmediately" } override fun repaint() { repaintCount++ + synchronizationOperations += "repaint" } fun dispatchShowingChanged() {