From 78e6c55537bc024d5031a2fe8ef847626616922e Mon Sep 17 00:00:00 2001 From: generalio <1487144524@qq.com> Date: Sat, 8 Aug 2026 21:54:42 +0800 Subject: [PATCH] =?UTF-8?q?fix(desktop):=20=E7=AD=89=E5=BE=85=E5=8E=9F?= =?UTF-8?q?=E7=94=9F=E8=A7=86=E5=9B=BE=E5=8F=AF=E8=A7=81=E5=90=8E=E5=88=9B?= =?UTF-8?q?=E5=BB=BA=20JCEF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- gradle.properties | 2 +- ...sktopInitialNativeViewLayoutCoordinator.kt | 134 ++++++++--- .../desktop/DesktopWebViewController.kt | 17 +- ...pInitialNativeViewLayoutCoordinatorTest.kt | 212 ++++++++---------- ...ktopNativeViewAttachmentCoordinatorTest.kt | 81 ++++++- 5 files changed, 288 insertions(+), 158 deletions(-) diff --git a/gradle.properties b/gradle.properties index bafcf12..32593a3 100644 --- a/gradle.properties +++ b/gradle.properties @@ -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 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 d31a5f2..20eb2a5 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,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 初始布局同步的原生视图。 @@ -22,6 +24,10 @@ internal interface DesktopNativeViewLayoutTarget { val width: Int /** 当前布局高度。 */ val height: Int + /** 当前实际可见区域宽度。 */ + val visibleWidth: Int + /** 当前实际可见区域高度。 */ + val visibleHeight: Int /** 注册视图进入 showing 状态时的回调。 */ fun addShowingListener(listener: () -> Unit) @@ -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 @@ -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 @@ -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() { @@ -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() { @@ -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, + ) } /** @@ -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 @@ -170,9 +206,11 @@ internal class DesktopNativeViewAttachmentCoordinator( } target.addShowingListener(showingListener) isShowingListenerRegistered = true + target.addLayoutChangedListener(showingListener) + isLayoutChangedListenerRegistered = true } - /** 请求创建浏览器;仅在组件已显示且拥有原生父视图时执行。 */ + /** 请求创建浏览器;仅在组件已显示、已完成可见裁剪且拥有原生父视图时执行。 */ fun requestBrowserCreation() { if (isUnavailable()) { return @@ -187,7 +225,7 @@ internal class DesktopNativeViewAttachmentCoordinator( return } isDisposed = true - removeShowingListener() + removeViewReadyListeners() } private fun createBrowserIfReady() { @@ -197,7 +235,11 @@ internal class DesktopNativeViewAttachmentCoordinator( isBrowserCreated || isBrowserCreationStarted || !target.isDisplayable || - !target.isShowing + !target.isShowing || + target.width <= 0 || + target.height <= 0 || + target.visibleWidth <= 0 || + target.visibleHeight <= 0 ) { return } @@ -205,18 +247,21 @@ internal class DesktopNativeViewAttachmentCoordinator( 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() @@ -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 就绪时也可安全调用。 */ @@ -279,7 +329,7 @@ internal class DesktopInitialNativeViewLayoutCoordinator( removeInitialLayoutListeners() } - /** 初始同步成功后立即移除全部监听,窗口后续缩放不得再次触发同步绘制。 */ + /** 初始同步完成后移除全部监听,窗口后续缩放不得再次触发同步绘制。 */ private fun removeInitialLayoutListeners() { if (isShowingListenerRegistered) { target.removeShowingListener(initialLayoutListener) @@ -291,12 +341,12 @@ internal class DesktopInitialNativeViewLayoutCoordinator( } } - /** 仅在 JCEF 已请求、Swing 已显示且布局尺寸有效时执行一次同步。 */ + /** 仅在 JCEF 已请求、Swing 已显示且布局尺寸与可见裁剪区域有效时执行同步。 */ private fun synchronizeNativeViewLayoutIfReady() { if ( isUnavailable() || !isInitialLayoutRequested || - isInitialLayoutSynchronized || + isInitialLayoutCompleted || isSynchronizing ) { return @@ -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 @@ -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 + } } 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 8843c90..bcb89dc 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 @@ -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 @@ -126,7 +127,7 @@ class DesktopWebViewController( /** 等待 JCEF 与 Swing 均就绪后同步 windowed 原生浏览器的首次布局。 */ private lateinit var initialNativeViewLayoutCoordinator: DesktopInitialNativeViewLayoutCoordinator - /** `createImmediately()` 已调用后才可以请求 JCEF 的正常浏览器关闭路径。 */ + /** 已请求 JCEF 创建后才可以请求浏览器的正常关闭路径。 */ private var isBrowserCreationStarted = false /** 控制器是否已释放。释放后除 [dispose] 外的操作都会抛出 [IllegalStateException]。 */ @@ -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( @@ -794,6 +805,8 @@ class DesktopWebViewController( } } +private const val BROWSER_CREATION_FALLBACK_DELAY_MILLIS = 300 + /** 统一执行 JCEF 的非强制浏览器关闭顺序,供桌面控制器及回归测试复用。 */ internal fun closeDesktopBrowser(browser: CefBrowser) { browser.stopLoad() 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 e74fcc3..68776e1 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 @@ -8,209 +8,177 @@ class DesktopInitialNativeViewLayoutCoordinatorTest { @Test fun `视图尚未 showing 时不会立即同步`() { val target = FakeDesktopNativeViewLayoutTarget(isShowing = false) - val coordinator = DesktopInitialNativeViewLayoutCoordinator( - target = target, - isControllerDisposed = { false }, - scheduleDelayedRetry = {}, - ) + val coordinator = createCoordinator(target) coordinator.registerShowingListener() coordinator.requestInitialNativeViewLayout() - assertEquals(0, target.revalidateCount) - assertEquals(0, target.immediatePaintCount) - assertEquals(0, target.repaintCount) + assertSynchronizationPerformed(target, expectedCount = 0) } @Test - fun `收到 showing 事件后按同步顺序绘制一次`() { - val target = FakeDesktopNativeViewLayoutTarget(isShowing = false) - val coordinator = DesktopInitialNativeViewLayoutCoordinator( - target = target, - isControllerDisposed = { false }, - scheduleDelayedRetry = {}, - ) - - coordinator.registerShowingListener() - coordinator.requestInitialNativeViewLayout() - target.isShowing = true - target.dispatchShowingChanged() - target.dispatchShowingChanged() - - assertEquals(1, target.revalidateCount) - assertEquals(1, target.nativeViewSizeSynchronizationCount) - assertEquals(1, target.immediatePaintCount) - assertEquals(1, target.repaintCount) - assertEquals( - listOf("revalidate", "synchronizeNativeViewSize", "paintImmediately", "repaint"), - target.synchronizationOperations, - ) - } - - @Test - fun `尺寸无效时不会同步绘制`() { + fun `可见裁剪区域无效时不会提前同步`() { val target = FakeDesktopNativeViewLayoutTarget( isShowing = true, - width = 0, + visibleWidth = 0, + visibleHeight = 0, ) - val coordinator = DesktopInitialNativeViewLayoutCoordinator(target, { false }) + val coordinator = createCoordinator(target) coordinator.registerShowingListener() coordinator.requestInitialNativeViewLayout() - assertEquals(0, target.revalidateCount) - assertEquals(0, target.immediatePaintCount) - assertEquals(0, target.repaintCount) + assertSynchronizationPerformed(target, expectedCount = 0) } @Test - fun `首次尺寸变为有效后会同步绘制一次`() { - val target = FakeDesktopNativeViewLayoutTarget( - isShowing = true, - width = 0, - height = 0, - ) - val coordinator = DesktopInitialNativeViewLayoutCoordinator( - target = target, - isControllerDisposed = { false }, - scheduleDelayedRetry = {}, - ) + fun `收到 showing 事件后会开始稳定同步`() { + val target = FakeDesktopNativeViewLayoutTarget(isShowing = false) + val delayedRetries = mutableListOf<() -> Unit>() + val coordinator = createCoordinator(target) { action -> delayedRetries += action } coordinator.registerShowingListener() coordinator.requestInitialNativeViewLayout() - target.width = 100 - target.height = 100 - target.dispatchLayoutChanged() + target.isShowing = true + target.dispatchShowingChanged() + delayedRetries.removeFirst().invoke() - assertEquals(1, target.revalidateCount) - assertEquals(1, target.nativeViewSizeSynchronizationCount) - assertEquals(1, target.immediatePaintCount) - assertEquals(1, target.repaintCount) - assertEquals( - listOf("revalidate", "synchronizeNativeViewSize", "paintImmediately", "repaint"), - target.synchronizationOperations, - ) + assertSynchronizationPerformed(target, expectedCount = 2) } @Test - fun `同一事件循环内尺寸变为有效时延迟重试会同步绘制`() { + fun `可见裁剪区域稳定后会执行两次同步`() { val target = FakeDesktopNativeViewLayoutTarget( isShowing = true, - width = 0, - height = 0, + visibleWidth = 0, + visibleHeight = 0, ) val delayedRetries = mutableListOf<() -> Unit>() - val coordinator = DesktopInitialNativeViewLayoutCoordinator( - target, - { false }, - { action -> delayedRetries += action }, - ) + val coordinator = createCoordinator(target) { action -> delayedRetries += action } coordinator.registerShowingListener() coordinator.requestInitialNativeViewLayout() - target.width = 100 - target.height = 100 - delayedRetries.single().invoke() + target.visibleWidth = 100 + target.visibleHeight = 100 + delayedRetries.removeFirst().invoke() + + assertSynchronizationPerformed(target, expectedCount = 1) + assertEquals(1, delayedRetries.size) + assertEquals(0, target.removeShowingListenerCount) + assertEquals(0, target.removeLayoutChangedListenerCount) - assertSynchronizationPerformedOnce(target) + delayedRetries.removeFirst().invoke() + + assertSynchronizationPerformed(target, expectedCount = 2) + assertEquals(1, target.removeShowingListenerCount) + assertEquals(1, target.removeLayoutChangedListenerCount) } @Test - fun `Swing 先 showing 后 JCEF 就绪时会同步绘制一次`() { + fun `JCEF 就绪后会安排第二次稳定布局同步`() { val target = FakeDesktopNativeViewLayoutTarget(isShowing = true) - val coordinator = DesktopInitialNativeViewLayoutCoordinator(target, { false }) + val delayedRetries = mutableListOf<() -> Unit>() + val coordinator = createCoordinator(target) { action -> delayedRetries += action } coordinator.registerShowingListener() coordinator.requestInitialNativeViewLayout() - assertSynchronizationPerformedOnce(target) + assertSynchronizationPerformed(target, expectedCount = 1) + assertEquals(1, delayedRetries.size) + + delayedRetries.removeFirst().invoke() + + assertSynchronizationPerformed(target, expectedCount = 2) + assertEquals(1, target.removeShowingListenerCount) + assertEquals(1, target.removeLayoutChangedListenerCount) } @Test - fun `JCEF 就绪后会重新同步当前尺寸`() { + fun `真实布局事件可在延迟重试前完成稳定同步`() { val target = FakeDesktopNativeViewLayoutTarget(isShowing = true) - val coordinator = DesktopInitialNativeViewLayoutCoordinator(target, { false }) + val delayedRetries = mutableListOf<() -> Unit>() + val coordinator = createCoordinator(target) { action -> delayedRetries += action } coordinator.registerShowingListener() coordinator.requestInitialNativeViewLayout() + target.dispatchLayoutChanged() + delayedRetries.single().invoke() - assertEquals(1, target.nativeViewSizeSynchronizationCount) + assertSynchronizationPerformed(target, expectedCount = 2) + assertEquals(1, target.removeShowingListenerCount) + assertEquals(1, target.removeLayoutChangedListenerCount) } @Test - fun `初始同步完成后多次 resize 不会重复绘制`() { + fun `同步完成后多次 resize 不会重复绘制`() { val target = FakeDesktopNativeViewLayoutTarget(isShowing = true) - val coordinator = DesktopInitialNativeViewLayoutCoordinator(target, { false }) + val delayedRetries = mutableListOf<() -> Unit>() + val coordinator = createCoordinator(target) { action -> delayedRetries += action } coordinator.registerShowingListener() coordinator.requestInitialNativeViewLayout() + delayedRetries.removeFirst().invoke() target.dispatchLayoutChanged() target.dispatchLayoutChanged() - assertSynchronizationPerformedOnce(target) - assertEquals(1, target.removeShowingListenerCount) - assertEquals(1, target.removeLayoutChangedListenerCount) + assertSynchronizationPerformed(target, expectedCount = 2) } @Test - fun `销毁后不再同步且移除监听`() { + fun `销毁后不再执行已排队的稳定同步`() { val target = FakeDesktopNativeViewLayoutTarget(isShowing = true) - val coordinator = DesktopInitialNativeViewLayoutCoordinator(target, { false }) + val delayedRetries = mutableListOf<() -> Unit>() + val coordinator = createCoordinator(target) { action -> delayedRetries += action } coordinator.registerShowingListener() - coordinator.dispose() coordinator.requestInitialNativeViewLayout() - target.dispatchShowingChanged() + coordinator.dispose() + delayedRetries.single().invoke() + assertSynchronizationPerformed(target, expectedCount = 1) assertEquals(1, target.removeShowingListenerCount) assertEquals(1, target.removeLayoutChangedListenerCount) - assertEquals(0, target.revalidateCount) - assertEquals(0, target.immediatePaintCount) - assertEquals(0, target.repaintCount) } @Test fun `控制器已销毁时不执行同步`() { - val isControllerDisposed = true val target = FakeDesktopNativeViewLayoutTarget(isShowing = true) - val coordinator = DesktopInitialNativeViewLayoutCoordinator(target, { isControllerDisposed }) + val coordinator = createCoordinator(target, isControllerDisposed = { true }) coordinator.registerShowingListener() coordinator.requestInitialNativeViewLayout() target.dispatchShowingChanged() - assertEquals(0, target.addListenerCount) + assertEquals(0, target.addShowingListenerCount) assertEquals(0, target.addLayoutChangedListenerCount) - assertEquals(0, target.revalidateCount) - assertEquals(0, target.immediatePaintCount) - assertEquals(0, target.repaintCount) + assertSynchronizationPerformed(target, expectedCount = 0) } +} - @Test - fun `多次注册不会重复添加 showing 监听`() { - val target = FakeDesktopNativeViewLayoutTarget(isShowing = true) - val coordinator = DesktopInitialNativeViewLayoutCoordinator(target, { false }) - - coordinator.registerShowingListener() - coordinator.registerShowingListener() - coordinator.requestInitialNativeViewLayout() - target.dispatchShowingChanged() - - assertEquals(1, target.addListenerCount) - assertEquals(1, target.addLayoutChangedListenerCount) - assertEquals(1, target.revalidateCount) - assertEquals(1, target.immediatePaintCount) - assertEquals(1, target.repaintCount) - } +private fun createCoordinator( + target: FakeDesktopNativeViewLayoutTarget, + isControllerDisposed: () -> Boolean = { false }, + scheduleDelayedRetry: ((() -> Unit) -> Unit) = {}, +): DesktopInitialNativeViewLayoutCoordinator { + return DesktopInitialNativeViewLayoutCoordinator( + target = target, + isControllerDisposed = isControllerDisposed, + scheduleRetry = scheduleDelayedRetry, + ) } -private fun assertSynchronizationPerformedOnce(target: FakeDesktopNativeViewLayoutTarget) { - assertEquals(1, target.revalidateCount) - assertEquals(1, target.nativeViewSizeSynchronizationCount) - assertEquals(1, target.immediatePaintCount) - assertEquals(1, target.repaintCount) +private fun assertSynchronizationPerformed( + target: FakeDesktopNativeViewLayoutTarget, + expectedCount: Int, +) { + assertEquals(expectedCount, target.revalidateCount) + assertEquals(expectedCount, target.nativeViewSizeSynchronizationCount) + assertEquals(expectedCount, target.immediatePaintCount) + assertEquals(expectedCount, target.repaintCount) assertEquals( - listOf("revalidate", "synchronizeNativeViewSize", "paintImmediately", "repaint"), + List(expectedCount) { + listOf("revalidate", "synchronizeNativeViewSize", "paintImmediately", "repaint") + }.flatten(), target.synchronizationOperations, ) } @@ -220,11 +188,13 @@ private class FakeDesktopNativeViewLayoutTarget( override var isShowing: Boolean, override var width: Int = 100, override var height: Int = 100, + override var visibleWidth: Int = 100, + override var visibleHeight: Int = 100, ) : DesktopNativeViewLayoutTarget { private val showingListeners = mutableSetOf<() -> Unit>() private val layoutChangedListeners = mutableSetOf<() -> Unit>() - var addListenerCount = 0 + var addShowingListenerCount = 0 private set var removeShowingListenerCount = 0 private set @@ -243,7 +213,7 @@ private class FakeDesktopNativeViewLayoutTarget( val synchronizationOperations = mutableListOf() override fun addShowingListener(listener: () -> Unit) { - addListenerCount++ + addShowingListenerCount++ showingListeners += listener } 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 index 2a65cdd..d585897 100644 --- a/webview-desktop/src/test/kotlin/io/github/multiweb/desktop/DesktopNativeViewAttachmentCoordinatorTest.kt +++ b/webview-desktop/src/test/kotlin/io/github/multiweb/desktop/DesktopNativeViewAttachmentCoordinatorTest.kt @@ -25,6 +25,7 @@ class DesktopNativeViewAttachmentCoordinatorTest { assertEquals(listOf("browser"), created) assertEquals(1, target.removeShowingListenerCount) + assertEquals(1, target.removeLayoutChangedListenerCount) } @Test @@ -63,20 +64,81 @@ class DesktopNativeViewAttachmentCoordinatorTest { assertEquals(0, createCount) assertEquals(1, target.removeShowingListenerCount) + assertEquals(1, target.removeLayoutChangedListenerCount) + } + + @Test + fun `原生视图尺寸有效后才创建浏览器`() { + val target = FakeAttachmentTarget( + isShowing = true, + width = 0, + height = 0, + ) + var createCount = 0 + val coordinator = DesktopNativeViewAttachmentCoordinator( + target = target, + isControllerDisposed = { false }, + createBrowser = { createCount++ }, + ) + + coordinator.registerShowingListener() + coordinator.requestBrowserCreation() + + assertEquals(0, createCount) + + target.width = 100 + target.height = 100 + target.dispatchLayoutChanged() + + assertEquals(1, createCount) + assertEquals(1, target.removeShowingListenerCount) + assertEquals(1, target.removeLayoutChangedListenerCount) + } + + @Test + fun `实际可见区域有效后才创建浏览器`() { + val target = FakeAttachmentTarget( + isShowing = true, + visibleWidth = 0, + visibleHeight = 0, + ) + var createCount = 0 + val coordinator = DesktopNativeViewAttachmentCoordinator( + target = target, + isControllerDisposed = { false }, + createBrowser = { createCount++ }, + ) + + coordinator.registerShowingListener() + coordinator.requestBrowserCreation() + + assertEquals(0, createCount) + + target.visibleWidth = 100 + target.visibleHeight = 100 + target.dispatchLayoutChanged() + + assertEquals(1, createCount) + assertEquals(1, target.removeShowingListenerCount) + assertEquals(1, target.removeLayoutChangedListenerCount) } } private class FakeAttachmentTarget( override var isDisplayable: Boolean = true, override var isShowing: Boolean, + override var width: Int = 100, + override var height: Int = 100, + override var visibleWidth: Int = 100, + override var visibleHeight: Int = 100, ) : DesktopNativeViewLayoutTarget { private val showingListeners = mutableSetOf<() -> Unit>() + private val layoutChangedListeners = mutableSetOf<() -> Unit>() var removeShowingListenerCount = 0 private set - - override val width: Int = 100 - override val height: Int = 100 + var removeLayoutChangedListenerCount = 0 + private set override fun addShowingListener(listener: () -> Unit) { showingListeners += listener @@ -87,9 +149,14 @@ private class FakeAttachmentTarget( showingListeners -= listener } - override fun addLayoutChangedListener(listener: () -> Unit) = Unit + override fun addLayoutChangedListener(listener: () -> Unit) { + layoutChangedListeners += listener + } - override fun removeLayoutChangedListener(listener: () -> Unit) = Unit + override fun removeLayoutChangedListener(listener: () -> Unit) { + removeLayoutChangedListenerCount++ + layoutChangedListeners -= listener + } override fun revalidate() = Unit @@ -102,4 +169,8 @@ private class FakeAttachmentTarget( fun dispatchShowingChanged() { showingListeners.toList().forEach { listener -> listener() } } + + fun dispatchLayoutChanged() { + layoutChangedListeners.toList().forEach { listener -> listener() } + } }