Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 16 additions & 3 deletions docs/使用指南.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 4 additions & 2 deletions docs/架构说明.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` 仍保留兜底调用以兼容旧接入方。

## 扩展方式

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String?>(null) }
var hostCapabilityNotice by remember { mutableStateOf<String?>(null) }
Expand Down
1 change: 1 addition & 0 deletions webview-compose/api/jvm/webview-compose.api
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 应用实例。
*
Expand All @@ -35,7 +49,8 @@ object DesktopWebViewRuntime {
onBrowserClosed: () -> Unit = {},
) {
synchronized(this) {
enableMacosInteropBlendingByDefault()
// 兼容旧接入方:即使未在 application 前准备,仍尽力提供正确的互操作默认值。
prepareComposeInterop()
check(configuration == null) {
"DesktopWebViewRuntime 已初始化;同一进程只能注入一个 CefApp。"
}
Expand All @@ -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")
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 初始布局同步的原生视图。
Expand All @@ -28,6 +30,9 @@ internal interface DesktopNativeViewLayoutTarget {
/** 请求 AWT 重新执行布局。 */
fun revalidate()

/** 同步进入原生 Swing 视图的绘制流程。 */
fun paintImmediately()

/** 请求 AWT 重绘原生视图。 */
fun repaint()
}
Expand Down Expand Up @@ -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()
}
Expand All @@ -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,
Expand Down Expand Up @@ -144,6 +167,7 @@ internal class DesktopInitialNativeViewLayoutCoordinator(
return
}
target.revalidate()
target.paintImmediately()
target.repaint()
isInitialLayoutSynchronized = true
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 }

Expand All @@ -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
Expand All @@ -44,6 +66,7 @@ class DesktopInitialNativeViewLayoutCoordinatorTest {

assertEquals(1, target.removeListenerCount)
assertEquals(0, target.revalidateCount)
assertEquals(0, target.immediatePaintCount)
assertEquals(0, target.repaintCount)
}

Expand All @@ -59,6 +82,7 @@ class DesktopInitialNativeViewLayoutCoordinatorTest {

assertEquals(0, target.addListenerCount)
assertEquals(0, target.revalidateCount)
assertEquals(0, target.immediatePaintCount)
assertEquals(0, target.repaintCount)
}

Expand All @@ -74,6 +98,7 @@ class DesktopInitialNativeViewLayoutCoordinatorTest {

assertEquals(1, target.addListenerCount)
assertEquals(1, target.revalidateCount)
assertEquals(1, target.immediatePaintCount)
assertEquals(1, target.repaintCount)
}
}
Expand All @@ -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<String>()

override fun addShowingListener(listener: () -> Unit) {
addListenerCount++
Expand All @@ -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() {
Expand Down
Loading