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
Original file line number Diff line number Diff line change
Expand Up @@ -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 的布局同步。
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<WebViewControllerLifecycleExtension>().forEach { extension ->
extension.onControllerAttached(this)
}
Expand Down Expand Up @@ -260,6 +275,7 @@ class DesktopWebViewController(
}

isDisposed = true
nativeViewAttachmentCoordinator.dispose()
initialNativeViewLayoutCoordinator.dispose()
extensions.filterIsInstance<WebViewControllerLifecycleExtension>().forEach { extension ->
extension.onControllerDisposed()
Expand All @@ -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)
}

Expand All @@ -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())
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String>()
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() }
}
}
Loading