From 4c8edb8f263ad358c50fc65150c5fd778b6bcc2c Mon Sep 17 00:00:00 2001 From: generalio <1487144524@qq.com> Date: Sat, 8 Aug 2026 00:45:17 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix(desktop):=20=E4=BF=AE=E5=A4=8D=20macOS?= =?UTF-8?q?=20=E9=A6=96=E5=B1=8F=E4=B8=8E=E5=8F=97=E6=8E=A7=E9=80=80?= =?UTF-8?q?=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 31 +- ...77\347\224\250\346\214\207\345\215\227.md" | 53 ++- ...66\346\236\204\350\257\264\346\230\216.md" | 11 +- .../io/github/multiweb/sample/desktop/Main.kt | 24 +- webview-compose/api/jvm/webview-compose.api | 3 + .../compose/ComposeWebView.desktop.kt | 317 +++++++++++++++++- .../compose/DesktopWebViewRuntimeTest.kt | 174 ++++++++++ ...sktopInitialNativeViewLayoutCoordinator.kt | 92 ++++- ...pInitialNativeViewLayoutCoordinatorTest.kt | 123 ++++++- 9 files changed, 752 insertions(+), 76 deletions(-) diff --git a/README.md b/README.md index ba254b8..015f3fb 100644 --- a/README.md +++ b/README.md @@ -65,18 +65,29 @@ fun HelpPage() { } ``` -Desktop 在首次组合上述页面前,还需要由应用入口创建一次 JCEF 并注入运行时。`CefApp` 是进程级资源, -MultiWeb 不会替宿主销毁它: +Desktop Compose 还需要在应用入口初始化一次进程级 JCEF。macOS 的 windowed JCEF 推荐使用受控退出入口:它会先 +关闭所有 Compose WebView,确认 CEF 已终止后再退出 Compose,避免 Cmd+Q 与原生清理竞争: ```kotlin -val cefApp = CefAppBuilder().build() - -DesktopWebViewRuntime.initialize( - cefApp = cefApp, - onBrowserClosed = { - // 确认没有其他浏览器后,由宿主调用 cefApp.dispose()。 - }, -) +fun main() { + DesktopWebViewRuntime.prepareComposeInterop() + + val cefApp = CefAppBuilder().apply { + setAppHandler(DesktopWebViewRuntime.createMacOsTerminationHandler()) + }.build() + + DesktopWebViewRuntime.initialize(cefApp) + + application { + DesktopWebViewRuntime.bindApplicationExit(::exitApplication) + + Window( + onCloseRequest = DesktopWebViewRuntime::requestApplicationExit, + ) { + // Compose 内容 + } + } +} ``` 完整的依赖、初始化与各平台接入方式见[使用指南](docs/使用指南.md)。 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 5ef97d7..fea78be 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" @@ -94,21 +94,43 @@ Activity、UIViewController 或桌面窗口复用。 ### Desktop 运行时初始化 JCEF 的 `CefApp` 是进程级资源,必须由桌面宿主在首次调用 `rememberWebViewController` 前创建并只注入一次。 -MultiWeb 会在浏览器和 Client 均关闭后回调 `onBrowserClosed`,但绝不会自动调用 `CefApp.dispose()`: +使用 Compose Desktop 时,建议将窗口关闭和 macOS Cmd+Q 都交给 `DesktopWebViewRuntime` 协调:运行时会先关闭其注册的 +Compose 控制器,等待每个浏览器的原生关闭回调,再调用一次 `CefApp.dispose()`;只有 `CefApp.getState()` 变为 +`TERMINATED` 后才会调用宿主退出回调。 ```kotlin -val cefApp = CefAppBuilder().build() +fun main() { + // 必须早于 macOS Compose application 创建;不覆盖宿主已设置的互操作属性。 + DesktopWebViewRuntime.prepareComposeInterop() -DesktopWebViewRuntime.initialize( - cefApp = cefApp, - onBrowserClosed = { - // 确认应用中没有其他浏览器后,再由宿主销毁 cefApp。 - }, -) + val cefApp = CefAppBuilder().apply { + setAppHandler(DesktopWebViewRuntime.createMacOsTerminationHandler()) + }.build() + DesktopWebViewRuntime.initialize(cefApp) + + application { + DesktopWebViewRuntime.bindApplicationExit(::exitApplication) + + Window( + onCloseRequest = DesktopWebViewRuntime::requestApplicationExit, + ) { + // 包含 rememberWebViewController(...) 的 Compose 内容 + } + } +} ``` -请在桌面应用的启动代码中调用,而不是放入可重组的 Composable。窗口关闭时应调用 Controller 的 `dispose()`; -等待 `onBrowserClosed` 后再结束 JCEF 进程。 +`initialize(...)`、`bindApplicationExit(...)` 均只能调用一次,且不能放入可重组的 Composable。旧的 +`initialize(cefApp, onBrowserClosed)` 仍可使用以保持兼容;采用新的受控退出方式时,**不要**在旧回调中自行调用 +`cefApp.dispose()` 或退出应用。若宿主需要自定义 `MavenCefAppHandlerAdapter`,其 `onBeforeTerminate()` 应调用 +`DesktopWebViewRuntime.requestApplicationExit()` 并返回 `true`: + +```kotlin +override fun onBeforeTerminate(): Boolean { + DesktopWebViewRuntime.requestApplicationExit() + return true +} +``` ## 3. 非 Compose 平台接入 @@ -193,7 +215,8 @@ view.addSubview(controller.view) ### Desktop 在 Swing EDT 创建 `DesktopWebViewController`,把 `controller.view` 放入 Swing/AWT 容器。进程级 `CefApp` 的创建、 -配置和最终销毁都属于宿主职责: +配置和最终销毁都属于宿主职责;非 Compose 宿主必须等待所有控制器的 `onBrowserClosed` 通知后再调用 +`cefApp.dispose()`: ```kotlin val controller = DesktopWebViewController( @@ -219,14 +242,12 @@ macOS 使用 windowed JCEF 与 `SwingPanel` 嵌入原生浏览器。应在创建 fun main() { DesktopWebViewRuntime.prepareComposeInterop() - application { - // 创建 CefApp,并调用 DesktopWebViewRuntime.initialize(...) - } + // 创建 CefApp、调用 DesktopWebViewRuntime.initialize(...) 后再创建 application。 } ``` -浏览器创建完成且 Swing 视图首次显示后,MultiWeb 会同步执行一次布局、即时绘制和后续重绘,触发 JCEF 的 windowed -原生子窗口绑定。调用方无需在 Compose 重组中手动调用 `repaint()`。 +浏览器创建完成后,MultiWeb 会继续等待 Swing 视图同时处于 showing 且首次获得有效尺寸,再同步执行一次布局、即时绘制和 +后续重绘,触发 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 545ad24..9e7dbcf 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" @@ -30,7 +30,7 @@ webview-test-fixtures ──> webview-api(仅工程内测试,不发布) | `webview-ios` | `WKWebView`、导航代理和 WebKit 消息桥。 | 精确控制 WebKit 的系统级第三方 Cookie 策略。 | | `webview-desktop` | JCEF 浏览器、事件处理、受限桥和关闭流程。 | 管理进程级 `CefApp` 的创建与销毁。 | | `webview-browser` | JS/Wasm 的 URL 校验和新窗口打开。 | 伪装为嵌入式 WebView 或清理浏览器全局会话。 | -| `webview-compose` | 统一的 Compose Controller 创建与原生视图嵌入入口。 | 把 Android、UIKit、JCEF 类型泄漏到 common API,或管理进程级 `CefApp`。 | +| `webview-compose` | 统一的 Compose Controller 创建、原生视图嵌入和 Desktop 受控退出入口。 | 把 Android、UIKit、JCEF 类型泄漏到 common API。 | | `webview-test-fixtures` | 示例和工程内的非设备契约测试替身。 | 作为对外 Maven API 或发布构件。 | ## 调用流程 @@ -65,14 +65,15 @@ webview-test-fixtures ──> webview-api(仅工程内测试,不发布) | --- | --- | | Android | Compose 入口自动转发前后台并释放;直接使用平台控制器时,在主线程挂载 `controller.view`、转发 `onHostPause()`、`onHostResume()` 并调用 `dispose()`。 | | iOS | Compose 入口自动释放;直接使用平台控制器时,在主线程将 `controller.view` 添加到 UIKit 层级并调用 `dispose()`。 | -| Desktop | Compose 入口在 Swing EDT 创建和关闭浏览器;宿主仍须通过 `DesktopWebViewRuntime.initialize` 注入进程级 `CefApp`,并在 `onBrowserClosed` 后自行销毁。 | +| Desktop | Compose 入口在 Swing EDT 创建和关闭浏览器;宿主通过 `DesktopWebViewRuntime.initialize` 注入进程级 `CefApp`。使用运行时的受控退出 API 时,由其在所有 Compose 浏览器正常关闭后销毁一次 CEF,并在 `TERMINATED` 后回调宿主退出;直接使用控制器时仍由宿主等待全部 `onBrowserClosed` 后销毁 CEF。 | | JS/Wasm | 无原生视图,按策略在新窗口或新标签页打开 URL;不持有浏览器会话。 | -Desktop 的 JCEF 采用 windowed 模式,不切换 OSR。控制器会在 JCEF 浏览器创建完成和 Swing 组件首次处于 showing 状态后, -在 Swing EDT 主动完成一次 `revalidate()`、`paintImmediately()` 与 `repaint()`,以同步进入 JCEF `JPanel.paint()`,触发 +Desktop 的 JCEF 采用 windowed 模式,不切换 OSR。控制器会在 JCEF 浏览器创建完成、Swing 组件处于 showing 状态且首次获得 +有效尺寸后,在 Swing EDT 主动完成一次 `revalidate()`、`paintImmediately()` 与 `repaint()`,以同步进入 JCEF `JPanel.paint()`,触发 `doUpdate()` 与原生子窗口绑定,避免 Compose Desktop 初次挂载时黑屏。`DesktopWebViewRuntime.prepareComposeInterop()` 应在 macOS 的 Compose `application {}` 前调用;它仅在宿主未设置 `compose.interop.blending` 时提供 `true` 的互操作默认值, -`initialize` 仍保留兜底调用以兼容旧接入方。 +`initialize` 仍保留兜底调用以兼容旧接入方。macOS 需在创建 `CefApp` 前安装 +`DesktopWebViewRuntime.createMacOsTerminationHandler()`;它会将 Cmd+Q 转入与 `Window.onCloseRequest` 相同的受控退出路径。 ## 扩展方式 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 1aed692..bac29d6 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 @@ -9,7 +9,6 @@ import androidx.compose.ui.window.WindowPlacement import androidx.compose.ui.window.application import androidx.compose.ui.window.rememberWindowState import io.github.multiweb.compose.DesktopWebViewRuntime -import io.github.multiweb.api.WebViewController import io.github.multiweb.extension.HostUiRequest import io.github.multiweb.sample.SampleWebViewApp import io.github.multiweb.sample.SampleWebViewExtension @@ -84,19 +83,14 @@ private fun runDesktopSampleApplication() = application { // JCEF 原生运行时体积较大,放入用户目录以便多次启动复用,避免污染项目工作区。 setInstallDir(jcefInstallDirectory()) configureCefUserDataDirectory() + setAppHandler(DesktopWebViewRuntime.createMacOsTerminationHandler()) }.build() } remember(cefApp) { - DesktopWebViewRuntime.initialize( - cefApp = cefApp, - onBrowserClosed = { - // JCEF 在原生关闭回调后才允许销毁进程级 CefApp,随后再结束 Compose 事件循环。 - SwingUtilities.invokeLater { - cefApp.dispose() - exitApplication() - } - }, - ) + DesktopWebViewRuntime.initialize(cefApp) + } + remember { + DesktopWebViewRuntime.bindApplicationExit(::exitApplication) } val initialization = remember(extension, nativeBridgeExtension) { sampleWebViewInitialization( @@ -110,11 +104,9 @@ private fun runDesktopSampleApplication() = application { ) } } - var controller by remember { mutableStateOf(null) } - Window( - // 不直接退出 Compose;先等待 JCEF 确认浏览器关闭,避免 macOS AppKit 访问已释放的原生对象。 - onCloseRequest = { controller?.dispose() }, + // 不直接退出 Compose;先等待 JCEF 确认浏览器关闭和进程终止,避免 macOS AppKit 访问已释放的原生对象。 + onCloseRequest = DesktopWebViewRuntime::requestApplicationExit, state = windowState, title = "MultiWeb Compose 示例", ) { @@ -130,7 +122,7 @@ private fun runDesktopSampleApplication() = application { } }, onImageSaveDismissed = { pendingImageSaveUrl = null }, - onWebViewControllerReady = { controller = it }, + onWebViewControllerReady = {}, ) } } diff --git a/webview-compose/api/jvm/webview-compose.api b/webview-compose/api/jvm/webview-compose.api index a2d472c..c97e406 100644 --- a/webview-compose/api/jvm/webview-compose.api +++ b/webview-compose/api/jvm/webview-compose.api @@ -6,9 +6,12 @@ public final class io/github/multiweb/compose/ComposeWebView_desktopKt { public final class io/github/multiweb/compose/DesktopWebViewRuntime { public static final field $stable I public static final field INSTANCE Lio/github/multiweb/compose/DesktopWebViewRuntime; + public final fun bindApplicationExit (Lkotlin/jvm/functions/Function0;)V + public final fun createMacOsTerminationHandler ()Lme/friwi/jcefmaven/MavenCefAppHandlerAdapter; 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 fun requestApplicationExit ()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 af9473f..0baa266 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 @@ -11,15 +11,19 @@ import androidx.compose.ui.awt.SwingPanel import io.github.multiweb.api.WebViewController import io.github.multiweb.desktop.DesktopWebViewController import io.github.multiweb.extension.WebViewInitialization +import java.util.Collections +import java.util.IdentityHashMap +import javax.swing.Timer import javax.swing.SwingUtilities +import me.friwi.jcefmaven.MavenCefAppHandlerAdapter import org.cef.CefApp /** * Desktop WebView 的进程级 JCEF 运行时。 * * 宿主必须在首次调用 [rememberWebViewController] 前使用 [initialize] 注入已创建的 [CefApp]。 - * 该对象绝不会主动调用 `CefApp.dispose()`:当每个浏览器原生关闭后,会转发 [onBrowserClosed], - * 由宿主在确认所有浏览器均已关闭后自行销毁进程资源。 + * 调用 [requestApplicationExit] 时,该对象会等待所有已注册浏览器完成正常关闭,再销毁进程级 `CefApp`, + * 确认 CEF 终止后才调用宿主通过 [bindApplicationExit] 绑定的应用退出回调。 */ object DesktopWebViewRuntime { private var configuration: DesktopWebViewRuntimeConfiguration? = null @@ -38,6 +42,20 @@ object DesktopWebViewRuntime { ) } + /** + * 创建 macOS Cmd+Q 的 JCEF 终止处理器。 + * + * 宿主必须在 `CefAppBuilder.build()` 前将返回值传给 `setAppHandler()`。macOS 会拦截默认 AppKit 终止流程, + * 改由 [requestApplicationExit] 协调关闭浏览器、CEF 与 Compose;非 macOS 保持 JCEF 的默认终止行为。 + */ + fun createMacOsTerminationHandler(): MavenCefAppHandlerAdapter { + return createMacOsTerminationHandler( + operatingSystemName = System.getProperty("os.name"), + scheduleOnEdt = { action -> SwingUtilities.invokeLater { action() } }, + requestApplicationExit = ::requestApplicationExit, + ) + } + /** * 注入当前进程唯一的 JCEF 应用实例。 * @@ -58,6 +76,30 @@ object DesktopWebViewRuntime { } } + /** + * 绑定 CEF 已完全终止后的宿主应用退出操作。 + * + * 必须在窗口关闭或 Cmd+Q 前调用一次。回调只会在 `CefApp.getState() == TERMINATED` 后于 Swing EDT 执行, + * 因此可以安全调用 Compose 的 `exitApplication`。 + */ + fun bindApplicationExit(onApplicationExit: () -> Unit) { + runOnSwingEdt { + requireConfiguration().bindApplicationExit(onApplicationExit) + } + } + + /** + * 请求应用按受控顺序退出。 + * + * 该方法可用于 `Window.onCloseRequest`,也由 macOS 的 [createMacOsTerminationHandler] 调用。重复请求会被忽略; + * 浏览器始终通过既有的非强制 `close(false)` 路径关闭。 + */ + fun requestApplicationExit() { + runOnSwingEdt { + currentConfigurationOrNull()?.requestApplicationExit() + } + } + internal fun requireConfiguration(): DesktopWebViewRuntimeConfiguration { return synchronized(this) { requireNotNull(configuration) { @@ -65,6 +107,18 @@ object DesktopWebViewRuntime { } } } + + private fun currentConfigurationOrNull(): DesktopWebViewRuntimeConfiguration? { + return synchronized(this) { configuration } + } + + private fun runOnSwingEdt(action: () -> Unit) { + if (SwingUtilities.isEventDispatchThread()) { + action() + } else { + SwingUtilities.invokeLater { action() } + } + } } /** @@ -82,13 +136,248 @@ internal fun prepareMacosComposeInterop( } } -/** Desktop 实现内部持有的 JCEF 资源与关闭回调。 */ +/** + * 处理 macOS Cmd+Q 的 JCEF 终止回调。 + * + * JCEF 的默认实现在返回 `false` 后会直接走原生终止流程。windowed 模式下这可能与 Swing/Compose 正在关闭 + * 浏览器的过程竞争,因此 macOS 需要先转交到 [DesktopWebViewRuntime.requestApplicationExit]。非 macOS 保持 + * JCEF 原有行为,避免改变其他桌面系统的终止语义。 + */ +internal fun createMacOsTerminationHandler( + operatingSystemName: String, + scheduleOnEdt: ((() -> Unit) -> Unit), + requestApplicationExit: () -> Unit, +): MavenCefAppHandlerAdapter { + return object : MavenCefAppHandlerAdapter() { + override fun onBeforeTerminate(): Boolean { + if (!operatingSystemName.startsWith("Mac")) { + return false + } + scheduleOnEdt(requestApplicationExit) + return true + } + } +} + +/** 供退出协调器读取和销毁 JCEF 进程级资源的最小适配层,便于不启动真实 CEF 的单元测试。 */ +internal interface DesktopCefApplication { + /** 当前 JCEF 生命周期状态。 */ + val state: CefApp.CefAppState + + /** 请求 JCEF 开始进程级销毁。调用方必须先确认所有浏览器均已正常关闭。 */ + fun dispose() +} + +/** CEF 终止前的低频状态检查器。 */ +internal interface DesktopTerminationPoller { + /** 开始检查。 */ + fun start() + + /** 停止检查并释放 Swing Timer。 */ + fun stop() +} + +/** 将真实 [CefApp] 适配为退出协调器所需的最小接口。 */ +private class JcefDesktopCefApplication( + private val cefApp: CefApp, +) : DesktopCefApplication { + override val state: CefApp.CefAppState + get() = CefApp.getState() + + override fun dispose() { + cefApp.dispose() + } +} + +/** 使用 Swing EDT 上的低频 Timer 等待 JCEF 终止,避免阻塞 Compose 或 CEF 回调线程。 */ +private class SwingDesktopTerminationPoller( + onTick: () -> Unit, +) : DesktopTerminationPoller { + private val timer = Timer(100) { onTick() }.apply { + isRepeats = true + } + + override fun start() { + timer.start() + } + + override fun stop() { + timer.stop() + } +} + +/** 返回按对象身份比较的集合,避免控制器意外实现相等性后混淆不同原生浏览器。 */ +private fun identitySet(): MutableSet { + return Collections.newSetFromMap(IdentityHashMap()) +} + +/** + * 协调 Compose 控制器、CEF 与宿主应用的受控退出顺序。 + * + * 所有方法必须在 Swing EDT 调用。每个控制器仍通过自身的 `close(false)` 正常关闭;只有全部原生 + * `onBrowserClosed` 回调到达后才释放进程级 CEF,最后等待 [CefApp.CefAppState.TERMINATED] 再交还给宿主退出。 + */ +internal class DesktopApplicationExitCoordinator( + private val cefApplication: DesktopCefApplication, + private val disposeController: (Controller) -> Unit, + private val createTerminationPoller: ((() -> Unit) -> DesktopTerminationPoller), +) { + private val activeControllers = identitySet() + private val closingControllers = identitySet() + /** 原生先关闭、Compose 随后触发 onDispose 时防止重新加入等待集合。 */ + private val nativeClosedControllers = identitySet() + private var onApplicationExit: (() -> Unit)? = null + private var terminationPoller: DesktopTerminationPoller? = null + private var isApplicationExitRequested = false + private var isCefShutdownStarted = false + private var isApplicationExitDelivered = false + + /** 注册由 Compose 创建的控制器;退出已经开始时立即要求其走正常关闭路径。 */ + fun register(controller: Controller) { + nativeClosedControllers.remove(controller) + if (!isApplicationExitRequested) { + activeControllers += controller + return + } + closingControllers += controller + disposeController(controller) + } + + /** + * 移除 Compose 对控制器的持有。 + * + * 原生浏览器关闭是异步的,不能因为 Composable 已离开就将其从 [closingControllers] 删除;否则应用退出 + * 可能在 JCEF Client 尚未释放时提前执行 `CefApp.dispose()`。 + */ + fun unregister(controller: Controller) { + activeControllers.remove(controller) + if (!nativeClosedControllers.remove(controller)) { + closingControllers += controller + } + startCefShutdownIfReady() + } + + /** 接收特定控制器的原生关闭确认,绝不按集合顺序猜测对应对象。 */ + fun onControllerClosed(controller: Controller) { + activeControllers.remove(controller) + closingControllers.remove(controller) + nativeClosedControllers += controller + startCefShutdownIfReady() + } + + /** 绑定唯一的宿主退出回调;CEF 确认终止前不会调用。 */ + fun bindApplicationExit(onApplicationExit: () -> Unit) { + check(this.onApplicationExit == null) { + "DesktopWebViewRuntime.bindApplicationExit 只能调用一次。" + } + this.onApplicationExit = onApplicationExit + } + + /** 请求关闭当前所有浏览器;重复请求保持幂等。 */ + fun requestApplicationExit() { + if (isApplicationExitRequested) { + return + } + check(onApplicationExit != null) { + "调用 DesktopWebViewRuntime.requestApplicationExit 前必须先绑定 bindApplicationExit。" + } + isApplicationExitRequested = true + val controllersToClose = activeControllers.toList() + activeControllers.clear() + closingControllers += controllersToClose + controllersToClose.forEach(disposeController) + startCefShutdownIfReady() + } + + /** 全部浏览器已关闭后,只开始一次进程级 CEF 关闭。 */ + private fun startCefShutdownIfReady() { + if ( + !isApplicationExitRequested || + activeControllers.isNotEmpty() || + closingControllers.isNotEmpty() || + isCefShutdownStarted + ) { + return + } + isCefShutdownStarted = true + cefApplication.dispose() + checkCefTermination() + } + + /** 仅在 CEF 已真正终止时交给宿主退出;不可用状态停止轮询但不伪造终止成功。 */ + private fun checkCefTermination() { + when (cefApplication.state) { + CefApp.CefAppState.TERMINATED -> deliverApplicationExit() + CefApp.CefAppState.NONE, + CefApp.CefAppState.INITIALIZATION_FAILED, + -> stopTerminationPolling() + else -> startTerminationPollingIfNeeded() + } + } + + private fun startTerminationPollingIfNeeded() { + if (terminationPoller != null || isApplicationExitDelivered) { + return + } + terminationPoller = createTerminationPoller(::checkCefTermination).also( + DesktopTerminationPoller::start, + ) + } + + private fun stopTerminationPolling() { + terminationPoller?.stop() + terminationPoller = null + } + + private fun deliverApplicationExit() { + if (isApplicationExitDelivered) { + return + } + isApplicationExitDelivered = true + stopTerminationPolling() + requireNotNull(onApplicationExit).invoke() + } +} + +/** Desktop 实现内部持有的 JCEF 资源、旧关闭通知与 Compose 退出协调器。 */ internal class DesktopWebViewRuntimeConfiguration( - /** 由宿主初始化、持有并最终销毁的进程级 JCEF 实例。 */ + /** 由宿主初始化并注入的进程级 JCEF 实例。 */ val cefApp: CefApp, - /** 某个浏览器原生关闭且其客户端已释放后通知宿主。 */ - val onBrowserClosed: () -> Unit, -) + /** 保持旧构造入口的浏览器关闭通知;新接入方不应在这里直接销毁 CEF。 */ + private val onBrowserClosed: () -> Unit, +) { + private val applicationExitCoordinator = DesktopApplicationExitCoordinator( + cefApplication = JcefDesktopCefApplication(cefApp), + disposeController = DesktopWebViewController::dispose, + createTerminationPoller = ::SwingDesktopTerminationPoller, + ) + + /** 注册 Compose 当前持有的控制器。 */ + fun registerController(controller: DesktopWebViewController) { + applicationExitCoordinator.register(controller) + } + + /** Compose 不再持有控制器时,继续等待其异步原生关闭完成。 */ + fun unregisterController(controller: DesktopWebViewController) { + applicationExitCoordinator.unregister(controller) + } + + /** 按准确的控制器身份处理 JCEF onBeforeClose 回调。 */ + fun onControllerClosed(controller: DesktopWebViewController) { + onBrowserClosed() + applicationExitCoordinator.onControllerClosed(controller) + } + + /** 绑定 CEF 终止后的宿主应用退出回调。 */ + fun bindApplicationExit(onApplicationExit: () -> Unit) { + applicationExitCoordinator.bindApplicationExit(onApplicationExit) + } + + /** 请求由运行时协调所有 Compose 控制器和 CEF 的正常退出。 */ + fun requestApplicationExit() { + applicationExitCoordinator.requestApplicationExit() + } +} /** 创建并在离开 Compose 时关闭当前 Desktop 浏览器。 */ @Composable @@ -102,16 +391,24 @@ actual fun rememberWebViewController( check(SwingUtilities.isEventDispatchThread()) { "Desktop WebView 必须在 Swing EDT 中创建。" } - DesktopWebViewController( + lateinit var createdController: DesktopWebViewController + createdController = DesktopWebViewController( cefApp = runtimeConfiguration.cefApp, initialization = initialization, onExternalNavigation = { request -> currentCallbacks.onExternalNavigation(request) }, - onBrowserClosed = runtimeConfiguration.onBrowserClosed, + onBrowserClosed = { + runtimeConfiguration.onControllerClosed(createdController) + }, ) + runtimeConfiguration.registerController(createdController) + createdController } DisposableEffect(controller) { - onDispose(controller::dispose) + onDispose { + runtimeConfiguration.unregisterController(controller) + controller.dispose() + } } return controller } 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 index 0e460f9..02009db 100644 --- a/webview-compose/src/jvmTest/kotlin/io/github/multiweb/compose/DesktopWebViewRuntimeTest.kt +++ b/webview-compose/src/jvmTest/kotlin/io/github/multiweb/compose/DesktopWebViewRuntimeTest.kt @@ -2,7 +2,9 @@ package io.github.multiweb.compose import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNull +import kotlin.test.assertTrue class DesktopWebViewRuntimeTest { @@ -44,4 +46,176 @@ class DesktopWebViewRuntimeTest { assertNull(configuredValue) } + + @Test + fun `macOS 终止处理器会拦截默认退出并转交 Runtime`() { + val scheduledRequests = mutableListOf<() -> Unit>() + var requestCount = 0 + + val handler = createMacOsTerminationHandler( + operatingSystemName = "Mac OS X", + scheduleOnEdt = { action -> scheduledRequests += action }, + requestApplicationExit = { requestCount++ }, + ) + + assertTrue(handler.onBeforeTerminate()) + scheduledRequests.single().invoke() + assertEquals(1, requestCount) + } + + @Test + fun `非 macOS 终止处理器保持 JCEF 默认退出行为`() { + var requestCount = 0 + + val handler = createMacOsTerminationHandler( + operatingSystemName = "Linux", + scheduleOnEdt = { action -> action() }, + requestApplicationExit = { requestCount++ }, + ) + + assertFalse(handler.onBeforeTerminate()) + assertEquals(0, requestCount) + } + + @Test + fun `等待所有控制器准确关闭后才销毁 CEF`() { + val cefApplication = FakeDesktopCefApplication() + val disposedControllers = mutableListOf() + val pollers = mutableListOf() + var applicationExitCount = 0 + val coordinator = DesktopApplicationExitCoordinator( + cefApplication = cefApplication, + disposeController = disposedControllers::add, + createTerminationPoller = { onTick -> + FakeDesktopTerminationPoller(onTick).also(pollers::add) + }, + ) + coordinator.bindApplicationExit { applicationExitCount++ } + coordinator.register("first") + coordinator.register("second") + + coordinator.requestApplicationExit() + assertEquals(setOf("first", "second"), disposedControllers.toSet()) + assertEquals(0, cefApplication.disposeCount) + + coordinator.onControllerClosed("second") + assertEquals(0, cefApplication.disposeCount) + + coordinator.onControllerClosed("first") + assertEquals(1, cefApplication.disposeCount) + assertEquals(1, pollers.single().startCount) + assertEquals(0, applicationExitCount) + + cefApplication.state = org.cef.CefApp.CefAppState.TERMINATED + pollers.single().dispatchTick() + + assertEquals(1, pollers.single().stopCount) + assertEquals(1, applicationExitCount) + } + + @Test + fun `重复退出请求不会重复关闭控制器或 CEF`() { + val cefApplication = FakeDesktopCefApplication( + state = org.cef.CefApp.CefAppState.TERMINATED, + ) + val disposedControllers = mutableListOf() + var applicationExitCount = 0 + val coordinator = DesktopApplicationExitCoordinator( + cefApplication = cefApplication, + disposeController = disposedControllers::add, + createTerminationPoller = { error("已终止的 CEF 不应启动轮询") }, + ) + coordinator.bindApplicationExit { applicationExitCount++ } + coordinator.register("controller") + + coordinator.requestApplicationExit() + coordinator.requestApplicationExit() + coordinator.onControllerClosed("controller") + coordinator.onControllerClosed("controller") + + assertEquals(listOf("controller"), disposedControllers) + assertEquals(1, cefApplication.disposeCount) + assertEquals(1, applicationExitCount) + } + + @Test + fun `Compose 已释放控制器后仍等待原生关闭确认`() { + val cefApplication = FakeDesktopCefApplication( + state = org.cef.CefApp.CefAppState.TERMINATED, + ) + val disposedControllers = mutableListOf() + val coordinator = DesktopApplicationExitCoordinator( + cefApplication = cefApplication, + disposeController = disposedControllers::add, + createTerminationPoller = { error("已终止的 CEF 不应启动轮询") }, + ) + coordinator.bindApplicationExit {} + coordinator.register("controller") + + coordinator.unregister("controller") + coordinator.requestApplicationExit() + + assertEquals(0, cefApplication.disposeCount) + assertTrue(disposedControllers.isEmpty()) + + coordinator.onControllerClosed("controller") + + assertEquals(1, cefApplication.disposeCount) + } + + @Test + fun `CEF 不可用时停止终止轮询且不伪造应用退出`() { + val cefApplication = FakeDesktopCefApplication() + val pollers = mutableListOf() + var applicationExitCount = 0 + val coordinator = DesktopApplicationExitCoordinator( + cefApplication = cefApplication, + disposeController = {}, + createTerminationPoller = { onTick -> + FakeDesktopTerminationPoller(onTick).also(pollers::add) + }, + ) + coordinator.bindApplicationExit { applicationExitCount++ } + + coordinator.requestApplicationExit() + assertEquals(1, pollers.single().startCount) + + cefApplication.state = org.cef.CefApp.CefAppState.INITIALIZATION_FAILED + pollers.single().dispatchTick() + + assertEquals(1, pollers.single().stopCount) + assertEquals(0, applicationExitCount) + } +} + +private class FakeDesktopCefApplication( + override var state: org.cef.CefApp.CefAppState = org.cef.CefApp.CefAppState.INITIALIZED, +) : DesktopCefApplication { + var disposeCount = 0 + private set + + override fun dispose() { + disposeCount++ + } +} + +private class FakeDesktopTerminationPoller( + private val onTick: () -> Unit, +) : DesktopTerminationPoller { + var startCount = 0 + private set + var stopCount = 0 + private set + + override fun start() { + startCount++ + } + + override fun stop() { + stopCount++ + } + + fun dispatchTick() { + onTick() + } } 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 2ab1aa8..a9f1999 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 @@ -1,6 +1,8 @@ package io.github.multiweb.desktop import java.awt.Component +import java.awt.event.ComponentAdapter +import java.awt.event.ComponentEvent import java.awt.event.HierarchyEvent import java.awt.event.HierarchyListener import javax.swing.JComponent @@ -27,6 +29,12 @@ internal interface DesktopNativeViewLayoutTarget { /** 移除先前注册的 showing 状态回调。 */ fun removeShowingListener(listener: () -> Unit) + /** 注册组件首次 showing 或尺寸变化时的回调。 */ + fun addLayoutChangedListener(listener: () -> Unit) + + /** 移除先前注册的组件布局变化回调。 */ + fun removeLayoutChangedListener(listener: () -> Unit) + /** 请求 AWT 重新执行布局。 */ fun revalidate() @@ -42,6 +50,7 @@ internal class ComponentDesktopNativeViewLayoutTarget( private val component: Component, ) : DesktopNativeViewLayoutTarget { private val hierarchyListeners = mutableMapOf<() -> Unit, HierarchyListener>() + private val componentListeners = mutableMapOf<() -> Unit, ComponentAdapter>() override val isDisplayable: Boolean get() = component.isDisplayable @@ -75,6 +84,27 @@ internal class ComponentDesktopNativeViewLayoutTarget( hierarchyListeners.remove(listener)?.let(component::removeHierarchyListener) } + override fun addLayoutChangedListener(listener: () -> Unit) { + if (componentListeners.containsKey(listener)) { + return + } + val componentListener = object : ComponentAdapter() { + override fun componentResized(event: ComponentEvent) { + listener() + } + + override fun componentShown(event: ComponentEvent) { + listener() + } + } + componentListeners[listener] = componentListener + component.addComponentListener(componentListener) + } + + override fun removeLayoutChangedListener(listener: () -> Unit) { + componentListeners.remove(listener)?.let(component::removeComponentListener) + } + override fun revalidate() { component.revalidate() } @@ -105,27 +135,36 @@ internal class ComponentDesktopNativeViewLayoutTarget( /** * 协调 JCEF 就绪与 Swing 视图首次 showing 的布局同步。 * - * windowed JCEF 的原生子窗口需要在 Swing 已完成首次绘制后同步尺寸;因此只在控制器明确请求、视图可显示且尺寸 - * 有效时执行一次 [DesktopNativeViewLayoutTarget.revalidate]、[DesktopNativeViewLayoutTarget.paintImmediately] 与 + * Compose `SwingPanel` 的首次有效尺寸可能晚于 JCEF 创建或 showing 事件;因此在控制器明确请求后同时监听 showing 与 + * 尺寸变化,并在 Swing EDT 延迟重试一次。仅当视图可显示且尺寸有效时,才执行一次 + * [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 var isDisposed = false private var isShowingListenerRegistered = false + private var isLayoutChangedListenerRegistered = false private var isInitialLayoutRequested = false private var isInitialLayoutSynchronized = false - private val showingListener: () -> Unit = ::synchronizeNativeViewLayoutIfReady + private var isSynchronizing = false + private var isDelayedRetryScheduled = false + private val initialLayoutListener: () -> Unit = ::synchronizeNativeViewLayoutIfReady /** 注册一次 showing 监听;视图先于 JCEF 就绪时也可安全调用。 */ fun registerShowingListener() { if (isUnavailable() || isShowingListenerRegistered) { return } - target.addShowingListener(showingListener) + target.addShowingListener(initialLayoutListener) isShowingListenerRegistered = true + target.addLayoutChangedListener(initialLayoutListener) + isLayoutChangedListenerRegistered = true } /** 标记 JCEF 已就绪,并在视图已显示时立即完成首次布局同步。 */ @@ -138,7 +177,7 @@ internal class DesktopInitialNativeViewLayoutCoordinator( } /** - * 释放 showing 监听,禁止后续事件触发原生重绘。 + * 释放 showing 与布局监听,禁止后续事件触发原生重绘。 * * 控制器先设置自身已销毁状态也不会影响这里的移除操作。 */ @@ -147,10 +186,19 @@ internal class DesktopInitialNativeViewLayoutCoordinator( return } isDisposed = true + removeInitialLayoutListeners() + } + + /** 初始同步成功后立即移除全部监听,窗口后续缩放不得再次触发同步绘制。 */ + private fun removeInitialLayoutListeners() { if (isShowingListenerRegistered) { - target.removeShowingListener(showingListener) + target.removeShowingListener(initialLayoutListener) isShowingListenerRegistered = false } + if (isLayoutChangedListenerRegistered) { + target.removeLayoutChangedListener(initialLayoutListener) + isLayoutChangedListenerRegistered = false + } } /** 仅在 JCEF 已请求、Swing 已显示且布局尺寸有效时执行一次同步。 */ @@ -159,17 +207,41 @@ internal class DesktopInitialNativeViewLayoutCoordinator( isUnavailable() || !isInitialLayoutRequested || isInitialLayoutSynchronized || + isSynchronizing + ) { + return + } + if ( !target.isDisplayable || !target.isShowing || target.width <= 0 || target.height <= 0 ) { + scheduleOneDelayedRetry() return } - target.revalidate() - target.paintImmediately() - target.repaint() - isInitialLayoutSynchronized = true + isSynchronizing = true + try { + target.revalidate() + target.paintImmediately() + target.repaint() + isInitialLayoutSynchronized = true + removeInitialLayoutListeners() + } finally { + isSynchronizing = false + } + } + + /** 覆盖同一 EDT 事件循环内才完成的首次布局;未就绪时仍等待下一次真实视图事件。 */ + private fun scheduleOneDelayedRetry() { + if (isDelayedRetryScheduled || isUnavailable() || isInitialLayoutSynchronized) { + return + } + isDelayedRetryScheduled = true + scheduleDelayedRetry { + isDelayedRetryScheduled = false + synchronizeNativeViewLayoutIfReady() + } } private fun isUnavailable(): Boolean = isDisposed || isControllerDisposed() 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 af2cc06..7fff8eb 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,7 +8,7 @@ class DesktopInitialNativeViewLayoutCoordinatorTest { @Test fun `视图尚未 showing 时不会立即同步`() { val target = FakeDesktopNativeViewLayoutTarget(isShowing = false) - val coordinator = DesktopInitialNativeViewLayoutCoordinator(target) { false } + val coordinator = DesktopInitialNativeViewLayoutCoordinator(target, { false }) coordinator.registerShowingListener() coordinator.requestInitialNativeViewLayout() @@ -21,7 +21,7 @@ class DesktopInitialNativeViewLayoutCoordinatorTest { @Test fun `收到 showing 事件后按同步顺序绘制一次`() { val target = FakeDesktopNativeViewLayoutTarget(isShowing = false) - val coordinator = DesktopInitialNativeViewLayoutCoordinator(target) { false } + val coordinator = DesktopInitialNativeViewLayoutCoordinator(target, { false }) coordinator.registerShowingListener() coordinator.requestInitialNativeViewLayout() @@ -44,7 +44,7 @@ class DesktopInitialNativeViewLayoutCoordinatorTest { isShowing = true, width = 0, ) - val coordinator = DesktopInitialNativeViewLayoutCoordinator(target) { false } + val coordinator = DesktopInitialNativeViewLayoutCoordinator(target, { false }) coordinator.registerShowingListener() coordinator.requestInitialNativeViewLayout() @@ -54,17 +54,91 @@ class DesktopInitialNativeViewLayoutCoordinatorTest { assertEquals(0, target.repaintCount) } + @Test + fun `首次尺寸变为有效后会同步绘制一次`() { + val target = FakeDesktopNativeViewLayoutTarget( + isShowing = true, + width = 0, + height = 0, + ) + val coordinator = DesktopInitialNativeViewLayoutCoordinator(target, { false }) + + coordinator.registerShowingListener() + coordinator.requestInitialNativeViewLayout() + target.width = 100 + target.height = 100 + target.dispatchLayoutChanged() + + 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, + height = 0, + ) + val delayedRetries = mutableListOf<() -> Unit>() + val coordinator = DesktopInitialNativeViewLayoutCoordinator( + target, + { false }, + { action -> delayedRetries += action }, + ) + + coordinator.registerShowingListener() + coordinator.requestInitialNativeViewLayout() + target.width = 100 + target.height = 100 + delayedRetries.single().invoke() + + assertSynchronizationPerformedOnce(target) + } + + @Test + fun `Swing 先 showing 后 JCEF 就绪时会同步绘制一次`() { + val target = FakeDesktopNativeViewLayoutTarget(isShowing = true) + val coordinator = DesktopInitialNativeViewLayoutCoordinator(target, { false }) + + coordinator.registerShowingListener() + coordinator.requestInitialNativeViewLayout() + + assertSynchronizationPerformedOnce(target) + } + + @Test + fun `初始同步完成后多次 resize 不会重复绘制`() { + val target = FakeDesktopNativeViewLayoutTarget(isShowing = true) + val coordinator = DesktopInitialNativeViewLayoutCoordinator(target, { false }) + + coordinator.registerShowingListener() + coordinator.requestInitialNativeViewLayout() + target.dispatchLayoutChanged() + target.dispatchLayoutChanged() + + assertSynchronizationPerformedOnce(target) + assertEquals(1, target.removeShowingListenerCount) + assertEquals(1, target.removeLayoutChangedListenerCount) + } + @Test fun `销毁后不再同步且移除监听`() { val target = FakeDesktopNativeViewLayoutTarget(isShowing = true) - val coordinator = DesktopInitialNativeViewLayoutCoordinator(target) { false } + val coordinator = DesktopInitialNativeViewLayoutCoordinator(target, { false }) coordinator.registerShowingListener() coordinator.dispose() coordinator.requestInitialNativeViewLayout() target.dispatchShowingChanged() - assertEquals(1, target.removeListenerCount) + assertEquals(1, target.removeShowingListenerCount) + assertEquals(1, target.removeLayoutChangedListenerCount) assertEquals(0, target.revalidateCount) assertEquals(0, target.immediatePaintCount) assertEquals(0, target.repaintCount) @@ -74,13 +148,14 @@ class DesktopInitialNativeViewLayoutCoordinatorTest { fun `控制器已销毁时不执行同步`() { val isControllerDisposed = true val target = FakeDesktopNativeViewLayoutTarget(isShowing = true) - val coordinator = DesktopInitialNativeViewLayoutCoordinator(target) { isControllerDisposed } + val coordinator = DesktopInitialNativeViewLayoutCoordinator(target, { isControllerDisposed }) coordinator.registerShowingListener() coordinator.requestInitialNativeViewLayout() target.dispatchShowingChanged() assertEquals(0, target.addListenerCount) + assertEquals(0, target.addLayoutChangedListenerCount) assertEquals(0, target.revalidateCount) assertEquals(0, target.immediatePaintCount) assertEquals(0, target.repaintCount) @@ -89,7 +164,7 @@ class DesktopInitialNativeViewLayoutCoordinatorTest { @Test fun `多次注册不会重复添加 showing 监听`() { val target = FakeDesktopNativeViewLayoutTarget(isShowing = true) - val coordinator = DesktopInitialNativeViewLayoutCoordinator(target) { false } + val coordinator = DesktopInitialNativeViewLayoutCoordinator(target, { false }) coordinator.registerShowingListener() coordinator.registerShowingListener() @@ -97,12 +172,23 @@ class DesktopInitialNativeViewLayoutCoordinatorTest { target.dispatchShowingChanged() assertEquals(1, target.addListenerCount) + assertEquals(1, target.addLayoutChangedListenerCount) assertEquals(1, target.revalidateCount) assertEquals(1, target.immediatePaintCount) assertEquals(1, target.repaintCount) } } +private fun assertSynchronizationPerformedOnce(target: FakeDesktopNativeViewLayoutTarget) { + assertEquals(1, target.revalidateCount) + assertEquals(1, target.immediatePaintCount) + assertEquals(1, target.repaintCount) + assertEquals( + listOf("revalidate", "paintImmediately", "repaint"), + target.synchronizationOperations, + ) +} + private class FakeDesktopNativeViewLayoutTarget( override var isDisplayable: Boolean = true, override var isShowing: Boolean, @@ -110,10 +196,15 @@ private class FakeDesktopNativeViewLayoutTarget( override var height: Int = 100, ) : DesktopNativeViewLayoutTarget { private val showingListeners = mutableSetOf<() -> Unit>() + private val layoutChangedListeners = mutableSetOf<() -> Unit>() var addListenerCount = 0 private set - var removeListenerCount = 0 + var removeShowingListenerCount = 0 + private set + var addLayoutChangedListenerCount = 0 + private set + var removeLayoutChangedListenerCount = 0 private set var revalidateCount = 0 private set @@ -129,10 +220,20 @@ private class FakeDesktopNativeViewLayoutTarget( } override fun removeShowingListener(listener: () -> Unit) { - removeListenerCount++ + removeShowingListenerCount++ showingListeners -= listener } + override fun addLayoutChangedListener(listener: () -> Unit) { + addLayoutChangedListenerCount++ + layoutChangedListeners += listener + } + + override fun removeLayoutChangedListener(listener: () -> Unit) { + removeLayoutChangedListenerCount++ + layoutChangedListeners -= listener + } + override fun revalidate() { revalidateCount++ synchronizationOperations += "revalidate" @@ -151,4 +252,8 @@ private class FakeDesktopNativeViewLayoutTarget( fun dispatchShowingChanged() { showingListeners.toList().forEach { listener -> listener() } } + + fun dispatchLayoutChanged() { + layoutChangedListeners.toList().forEach { listener -> listener() } + } } From 4834d27dca92f117a4ec4345f3d76cb799b795c5 Mon Sep 17 00:00:00 2001 From: generalio <1487144524@qq.com> Date: Sat, 8 Aug 2026 01:03:43 +0800 Subject: [PATCH 2/2] =?UTF-8?q?ci:=20=E5=8F=96=E6=B6=88=20PR=20=E5=BC=80?= =?UTF-8?q?=E5=8F=91=E8=AE=B0=E5=BD=95=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/pr-governance.yml | 28 +--------------------------- 1 file changed, 1 insertion(+), 27 deletions(-) diff --git a/.github/workflows/pr-governance.yml b/.github/workflows/pr-governance.yml index 1ad3ce5..59091b4 100644 --- a/.github/workflows/pr-governance.yml +++ b/.github/workflows/pr-governance.yml @@ -21,41 +21,15 @@ jobs: runs-on: ubuntu-latest steps: - - name: 校验分支命名与 PR 开发记录 + - name: 校验分支命名 uses: actions/github-script@v7 with: script: | const branch = context.payload.pull_request.head.ref; - const body = context.payload.pull_request.body || ""; const branchPattern = /^generalio\/(feature|fix|docs|chore|refactor|test)\/[a-z0-9]+(?:-[a-z0-9]+)*$/; - const requiredSections = [ - "目的", - "改动内容", - "平台影响", - "验证命令与结果", - "风险与兼容性", - "文档变更", - ]; if (!branchPattern.test(branch)) { core.setFailed( `源分支必须匹配 generalio/<类型>/<主题>,当前为:${branch}。允许的类型:feature、fix、docs、chore、refactor、test。`, ); - return; - } - - const headings = [...body.matchAll(/^##\s+(.+?)\s*$/gm)]; - const sections = new Map( - headings.map((heading, index) => [ - heading[1].trim(), - body - .slice(heading.index + heading[0].length, headings[index + 1]?.index) - .replace(//g, "") - .trim(), - ]), - ); - const missingSections = requiredSections.filter((section) => !sections.get(section)); - - if (missingSections.length > 0) { - core.setFailed(`PR 开发记录缺少或未填写章节:${missingSections.join("、")}。请使用 PR 模板完整说明。`); }