Skip to content
Closed
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
2 changes: 1 addition & 1 deletion gradle.properties
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
GROUP=io.github.generalio.multiweb
VERSION_NAME=0.2.8-SNAPSHOT
VERSION_NAME=0.2.9-SNAPSHOT

kotlin.code.style=official
kotlin.mpp.androidSourceSetLayoutVersion=2
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@ package io.github.multiweb.desktop
import java.awt.Component
import java.awt.event.ComponentAdapter
import java.awt.event.ComponentEvent
import java.awt.event.HierarchyBoundsAdapter
import java.awt.event.HierarchyEvent
import java.awt.event.HierarchyListener
import javax.swing.JComponent
import javax.swing.SwingUtilities
import javax.swing.Timer

/**
* 可参与 JCEF 初始布局同步的原生视图。
Expand All @@ -22,6 +24,10 @@ internal interface DesktopNativeViewLayoutTarget {
val width: Int
/** 当前布局高度。 */
val height: Int
/** 当前实际可见区域宽度。 */
val visibleWidth: Int
/** 当前实际可见区域高度。 */
val visibleHeight: Int

/** 注册视图进入 showing 状态时的回调。 */
fun addShowingListener(listener: () -> Unit)
Expand Down Expand Up @@ -58,7 +64,7 @@ internal class ComponentDesktopNativeViewLayoutTarget(
private val component: Component,
) : DesktopNativeViewLayoutTarget {
private val hierarchyListeners = mutableMapOf<() -> Unit, HierarchyListener>()
private val componentListeners = mutableMapOf<() -> Unit, ComponentAdapter>()
private val layoutChangedListeners = mutableMapOf<() -> Unit, LayoutChangedListenerRegistration>()

override val isDisplayable: Boolean
get() = component.isDisplayable
Expand All @@ -72,6 +78,12 @@ internal class ComponentDesktopNativeViewLayoutTarget(
override val height: Int
get() = component.height

override val visibleWidth: Int
get() = component.visibleSize().width

override val visibleHeight: Int
get() = component.visibleSize().height

override fun addShowingListener(listener: () -> Unit) {
if (hierarchyListeners.containsKey(listener)) {
return
Expand All @@ -93,7 +105,7 @@ internal class ComponentDesktopNativeViewLayoutTarget(
}

override fun addLayoutChangedListener(listener: () -> Unit) {
if (componentListeners.containsKey(listener)) {
if (layoutChangedListeners.containsKey(listener)) {
return
}
val componentListener = object : ComponentAdapter() {
Expand All @@ -105,12 +117,28 @@ internal class ComponentDesktopNativeViewLayoutTarget(
listener()
}
}
componentListeners[listener] = componentListener
val hierarchyBoundsListener = object : HierarchyBoundsAdapter() {
override fun ancestorResized(event: HierarchyEvent) {
listener()
}

override fun ancestorMoved(event: HierarchyEvent) {
listener()
}
}
layoutChangedListeners[listener] = LayoutChangedListenerRegistration(
componentListener = componentListener,
hierarchyBoundsListener = hierarchyBoundsListener,
)
component.addComponentListener(componentListener)
component.addHierarchyBoundsListener(hierarchyBoundsListener)
}

override fun removeLayoutChangedListener(listener: () -> Unit) {
componentListeners.remove(listener)?.let(component::removeComponentListener)
layoutChangedListeners.remove(listener)?.let { registration ->
component.removeComponentListener(registration.componentListener)
component.removeHierarchyBoundsListener(registration.hierarchyBoundsListener)
}
}

override fun revalidate() {
Expand Down Expand Up @@ -142,6 +170,13 @@ internal class ComponentDesktopNativeViewLayoutTarget(
override fun repaint() {
component.repaint()
}

private fun Component.visibleSize() = (this as? JComponent)?.visibleRect?.size ?: size

private data class LayoutChangedListenerRegistration(
val componentListener: ComponentAdapter,
val hierarchyBoundsListener: HierarchyBoundsAdapter,
)
}

/**
Expand All @@ -158,6 +193,7 @@ internal class DesktopNativeViewAttachmentCoordinator(
) {
private var isDisposed = false
private var isShowingListenerRegistered = false
private var isLayoutChangedListenerRegistered = false
private var isBrowserCreationRequested = false
private var isBrowserCreationStarted = false
private var isBrowserCreated = false
Expand All @@ -170,9 +206,11 @@ internal class DesktopNativeViewAttachmentCoordinator(
}
target.addShowingListener(showingListener)
isShowingListenerRegistered = true
target.addLayoutChangedListener(showingListener)
isLayoutChangedListenerRegistered = true
}

/** 请求创建浏览器;仅在组件已显示且拥有原生父视图时执行。 */
/** 请求创建浏览器;仅在组件已显示、已完成可见裁剪且拥有原生父视图时执行。 */
fun requestBrowserCreation() {
if (isUnavailable()) {
return
Expand All @@ -187,7 +225,7 @@ internal class DesktopNativeViewAttachmentCoordinator(
return
}
isDisposed = true
removeShowingListener()
removeViewReadyListeners()
}

private fun createBrowserIfReady() {
Expand All @@ -197,26 +235,33 @@ internal class DesktopNativeViewAttachmentCoordinator(
isBrowserCreated ||
isBrowserCreationStarted ||
!target.isDisplayable ||
!target.isShowing
!target.isShowing ||
target.width <= 0 ||
target.height <= 0 ||
target.visibleWidth <= 0 ||
target.visibleHeight <= 0
) {
return
}
isBrowserCreationStarted = true
try {
createBrowser()
isBrowserCreated = true
removeShowingListener()
removeViewReadyListeners()
} finally {
isBrowserCreationStarted = false
}
}

private fun removeShowingListener() {
if (!isShowingListenerRegistered) {
return
private fun removeViewReadyListeners() {
if (isShowingListenerRegistered) {
target.removeShowingListener(showingListener)
isShowingListenerRegistered = false
}
if (isLayoutChangedListenerRegistered) {
target.removeLayoutChangedListener(showingListener)
isLayoutChangedListenerRegistered = false
}
target.removeShowingListener(showingListener)
isShowingListenerRegistered = false
}

private fun isUnavailable(): Boolean = isDisposed || isControllerDisposed()
Expand All @@ -225,25 +270,30 @@ internal class DesktopNativeViewAttachmentCoordinator(
/**
* 协调 JCEF 就绪与 Swing 视图首次 showing 的布局同步。
*
* Compose `SwingPanel` 的首次有效尺寸可能晚于 JCEF 创建或 showing 事件;因此在控制器明确请求后同时监听 showing 与
* 尺寸变化,并在 Swing EDT 延迟重试一次。仅当视图可显示且尺寸有效时,才执行一次
* Compose `SwingPanel` 的首次有效可见区域可能晚于 JCEF 创建或 showing 事件;因此在控制器明确请求后同时监听 showing 与
* 祖先布局变化,并在 JCEF 的原生子窗口完成绑定后再延迟同步一次。仅当视图可显示、尺寸与裁剪区域均有效时,才执行
* [DesktopNativeViewLayoutTarget.revalidate]、[DesktopNativeViewLayoutTarget.paintImmediately] 与
* [DesktopNativeViewLayoutTarget.repaint]。调用方必须在 Swing EDT 调用本类。
*/
internal class DesktopInitialNativeViewLayoutCoordinator(
private val target: DesktopNativeViewLayoutTarget,
private val isControllerDisposed: () -> Boolean,
private val scheduleDelayedRetry: ((() -> Unit) -> Unit) = { action ->
SwingUtilities.invokeLater { action() }
private val scheduleRetry: ((() -> Unit) -> Unit) = { action ->
Timer(INITIAL_LAYOUT_RETRY_DELAY_MILLIS) { action() }.apply {
isRepeats = false
start()
}
},
) {
private var isDisposed = false
private var isShowingListenerRegistered = false
private var isLayoutChangedListenerRegistered = false
private var isInitialLayoutRequested = false
private var isInitialLayoutSynchronized = false
private var isInitialLayoutCompleted = false
private var isSynchronizing = false
private var isDelayedRetryScheduled = false
private var invalidLayoutRetryCount = 0
private var synchronizationPassCount = 0
private val initialLayoutListener: () -> Unit = ::synchronizeNativeViewLayoutIfReady

/** 注册一次 showing 监听;视图先于 JCEF 就绪时也可安全调用。 */
Expand Down Expand Up @@ -279,7 +329,7 @@ internal class DesktopInitialNativeViewLayoutCoordinator(
removeInitialLayoutListeners()
}

/** 初始同步成功后立即移除全部监听,窗口后续缩放不得再次触发同步绘制。 */
/** 初始同步完成后移除全部监听,窗口后续缩放不得再次触发同步绘制。 */
private fun removeInitialLayoutListeners() {
if (isShowingListenerRegistered) {
target.removeShowingListener(initialLayoutListener)
Expand All @@ -291,12 +341,12 @@ internal class DesktopInitialNativeViewLayoutCoordinator(
}
}

/** 仅在 JCEF 已请求、Swing 已显示且布局尺寸有效时执行一次同步。 */
/** 仅在 JCEF 已请求、Swing 已显示且布局尺寸与可见裁剪区域有效时执行同步。 */
private fun synchronizeNativeViewLayoutIfReady() {
if (
isUnavailable() ||
!isInitialLayoutRequested ||
isInitialLayoutSynchronized ||
isInitialLayoutCompleted ||
isSynchronizing
) {
return
Expand All @@ -305,9 +355,11 @@ internal class DesktopInitialNativeViewLayoutCoordinator(
!target.isDisplayable ||
!target.isShowing ||
target.width <= 0 ||
target.height <= 0
target.height <= 0 ||
target.visibleWidth <= 0 ||
target.visibleHeight <= 0
) {
scheduleOneDelayedRetry()
scheduleRetryForInvalidLayout()
return
}
isSynchronizing = true
Expand All @@ -316,24 +368,48 @@ internal class DesktopInitialNativeViewLayoutCoordinator(
target.synchronizeNativeViewSize()
target.paintImmediately()
target.repaint()
isInitialLayoutSynchronized = true
removeInitialLayoutListeners()
synchronizationPassCount++
if (synchronizationPassCount == INITIAL_LAYOUT_SYNCHRONIZATION_PASSES) {
isInitialLayoutCompleted = true
removeInitialLayoutListeners()
} else {
scheduleStabilizationRetry()
}
} finally {
isSynchronizing = false
}
}

/** 覆盖同一 EDT 事件循环内才完成的首次布局;未就绪时仍等待下一次真实视图事件。 */
private fun scheduleOneDelayedRetry() {
if (isDelayedRetryScheduled || isUnavailable() || isInitialLayoutSynchronized) {
/** 布局尚未稳定时的有限重试;次数耗尽后仍保留布局监听等待真实事件。 */
private fun scheduleRetryForInvalidLayout() {
if (invalidLayoutRetryCount == MAX_INVALID_LAYOUT_RETRIES) {
return
}
invalidLayoutRetryCount++
scheduleDelayedRetry()
}

/** JCEF 创建原生子窗口后,再执行一次布局同步以覆盖异步 parent 绑定。 */
private fun scheduleStabilizationRetry() {
scheduleDelayedRetry()
}

private fun scheduleDelayedRetry() {
if (isDelayedRetryScheduled || isUnavailable() || isInitialLayoutCompleted) {
return
}
isDelayedRetryScheduled = true
scheduleDelayedRetry {
scheduleRetry {
isDelayedRetryScheduled = false
synchronizeNativeViewLayoutIfReady()
}
}

private fun isUnavailable(): Boolean = isDisposed || isControllerDisposed()

private companion object {
const val INITIAL_LAYOUT_RETRY_DELAY_MILLIS = 100
const val INITIAL_LAYOUT_SYNCHRONIZATION_PASSES = 2
const val MAX_INVALID_LAYOUT_RETRIES = 3
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import kotlinx.coroutines.flow.asStateFlow
import java.awt.Component
import java.util.concurrent.ConcurrentHashMap
import javax.swing.SwingUtilities
import javax.swing.Timer
import org.cef.CefApp
import org.cef.CefClient
import org.cef.browser.CefBrowser
Expand Down Expand Up @@ -126,7 +127,7 @@ class DesktopWebViewController(
/** 等待 JCEF 与 Swing 均就绪后同步 windowed 原生浏览器的首次布局。 */
private lateinit var initialNativeViewLayoutCoordinator: DesktopInitialNativeViewLayoutCoordinator

/** `createImmediately()` 已调用后才可以请求 JCEF 的正常浏览器关闭路径。 */
/** 已请求 JCEF 创建后才可以请求浏览器的正常关闭路径。 */
private var isBrowserCreationStarted = false

/** 控制器是否已释放。释放后除 [dispose] 外的操作都会抛出 [IllegalStateException]。 */
Expand Down Expand Up @@ -178,7 +179,17 @@ class DesktopWebViewController(
isControllerDisposed = { isDisposed },
createBrowser = {
isBrowserCreationStarted = true
browser.createImmediately()
// `createImmediately()` 会以无原生父窗口的参数创建浏览器。先触发 JCEF 视图绘制,JCEF 的内部延迟
// 更新会携带当前 macOS 原生窗口句柄创建浏览器;仅在该路径没有产生回调时再使用旧路径兜底。
nativeViewTarget.paintImmediately()
Timer(BROWSER_CREATION_FALLBACK_DELAY_MILLIS) {
if (!isDisposed && !isBrowserReady) {
browser.createImmediately()
}
}.apply {
isRepeats = false
start()
}
},
)
initialNativeViewLayoutCoordinator = DesktopInitialNativeViewLayoutCoordinator(
Expand Down Expand Up @@ -794,6 +805,8 @@ class DesktopWebViewController(
}
}

private const val BROWSER_CREATION_FALLBACK_DELAY_MILLIS = 300

/** 统一执行 JCEF 的非强制浏览器关闭顺序,供桌面控制器及回归测试复用。 */
internal fun closeDesktopBrowser(browser: CefBrowser) {
browser.stopLoad()
Expand Down
Loading
Loading