ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Ghost Downloader-3 Android 端弹出层架构决策:为何所有 Popup 必须是主窗口的子控件(PySide6 EGL 死锁规避)

Ghost Downloader-3 Android 端弹出层架构决策:为何所有 Popup 必须是主窗口的子控件(PySide6 EGL 死锁规避) Ghost Downloader-3 Android 端弹出层架构决策为何所有 Popup 必须是主窗口的子控件PySide6 EGL 死锁规避【免费下载链接】Ghost-Downloader-3The only downloader you need. 下载器的集大成者。项目地址: https://gitcode.com/GitHub_Trending/gh/Ghost-Downloader-3导读本文以 Ghost Downloader-3 仓库中的架构决策记录 docs/adr/0004-android-popup-must-be-child-widget.md 为主体深入剖析 PySide6 应用在 Android EGL 渲染路径下第二个顶层窗口必然触发 surfaceflinger 死锁这一设备相关崩溃问题的成因、决策过程与全局补丁实现。读完本文你将掌握Android 上 Qt 弹窗Qt.Popup、无父级QDialog、独立RoundMenu为何必须重挂载为窗口子控件、WA_DeleteOnClose菜单在焦点回收时的 SIGSEGV 陷阱及其修复以及 Ghost Downloader-3 通过patchMenus()一处猴子补丁统一解决问题的工程做法。背景EGL 单线程表面与 AndroidDeadlockProtectorGhost Downloader-3 的 Android 端基于 PySide6 构建应用入口见 Ghost-Downloader-3.py平台判别通过hasattr(sys, getandroidapilevel)见 app/platform/android.py。在 Android 上Qt 的渲染走 EGL / surfaceflinger 合成路径而该路径的核心约束是EGL surface 是单线程的一旦第一个 surface 创建并开始渲染第二个 surface 的创建就会与第一个发生死锁。因此任何尝试创建第二个顶层窗口的操作都会触发 Android 运行时中的AndroidDeadlockProtector典型触发场景包括以Qt.Popup窗口类型弹出的菜单没有父窗口的Qt.Dialog脱离父控件独立弹出的RoundMenuqfluentwidgets 的圆角菜单。其崩溃特征极具迷惑性只在 GL 驱动较慢的真机上出现模拟器中无法复现。这意味着常规的本地调试手段模拟器、软渲染全部失效问题一旦在用户设备上爆发就是直接闪退且难以通过日志复现定位。正是这个设备相关、模拟器不可复现的残酷现实促使项目在架构层面立下硬性规则ADR 原文结论All popup-like widgets (RoundMenu, Flyout, TeachingTip) must be reparented as child widgets of the main window.即所有类弹窗控件RoundMenu、Flyout、TeachingTip都必须作为主窗口的子控件存在而不是独立的顶层窗口。为何是子控件而非带父级的 QDialog两个被否决的方案ADR 记录了实际评估过的两条备选路线及其否决理由理解这些理由有助于避免在别的项目里重蹈覆辙。方案一使用带 parent 的 QDialog —— 被否决直觉上的第一反应是给 QDialog 传一个 parent 不就行了吗。但结论是不行Use QDialog with parent — rejected: QDialog still creates a separate window handle on Android.在桌面平台上带 parent 的QDialog与主窗口共享窗口体系行为符合直觉但在 Android 的 Qt 实现中QDialog 无论是否携带 parent依然会创建独立的原生 window handle独立 surface。问题的根源不是有没有父级而是是否创建了第二个渲染 surface。只要走了 QDialog第二个 surface 必然出现死锁照旧。因此唯一可靠的路径是以普通子控件child widget的方式内嵌彻底不产生第二个窗口句柄。方案二关闭硬件加速 —— 被否决另一个看似釜底抽薪的办法是全局禁用硬件加速Disable hardware acceleration — rejected: unacceptable rendering performance.软件渲染虽然能绕开 EGL 死锁但对一个以流畅交互为基本要求的下载器界面列表滚动、进度条刷新、卡片动画来说性能代价不可接受。ADR 明确将其判定为unacceptable rendering performance予以否决。由此工程上唯一可行且性能无损的方案被锁定在渲染路径上让所有弹窗控件降级为普通子控件。全局落地方案patchMenus()猴子补丁规则确立之后Ghost Downloader-3 选择在 app/view/mobile/patches.py 中通过patchMenus()对 qfluentwidgets 的RoundMenu做全局猴子补丁一次改动覆盖全应用所有菜单弹层而不是逐处修改业务调用点。核心思路exec 时重挂载为窗口子控件补丁替换了RoundMenu.exec与RoundMenu.hideEvent两个方法其执行流程如下def execInWindow(self, pos, *args, **kwargs): host hostWindow(self) if host is None: return originalExec(self, pos, *args, **kwargs) self.setParent(host, Qt.WindowType.Widget) if not self.isSubMenu: self._androidOverlay MenuDismissOverlay(host, self) self._androidOverlay.show() self.raise_() return originalExec(self, host.mapFromGlobal(pos), *args, **kwargs)要点拆解对应 app/view/mobile/patches.py寻找宿主窗口hostWindow()沿parent()链向上遍历跳过嵌套的RoundMenu子菜单最终调用widget.window()取得真正的宿主顶层窗口若菜单完全脱离窗口体系则回退到QApplication.activeWindow()。找不到宿主时例如理论上发生在桌面端直接走原始exec逻辑保证补丁在非 Android 场景零影响。关键一步setParent(host, Qt.WindowType.Widget)。把菜单从独立顶层窗口重挂载为宿主窗口的普通子控件这是规避第二个 EGL surface 的核心动作。Qt.WindowType.Widget而非Qt.Popup确保不再生成独立窗口句柄。点击外部关闭补丁自绘了一个继承QWidget的MenuDismissOverlay半透明覆盖层见 patches.py铺满宿主窗口矩形。用户在菜单外按下鼠标时它调用self._menu._hideMenu(False)并隐藏所有可见的RoundMenu模拟原生菜单的点击外部关闭语义——因为菜单变成子控件后Qt 默认的 popup 级外部点击关闭行为不再生效必须自行补齐。坐标换算菜单坐标从全局坐标pos换算为宿主窗口局部坐标host.mapFromGlobal(pos)再调用原始exec保证菜单仍精确出现在触发按钮下方。这个设计的一个显著优点是对业务代码完全透明RoundMenu.exec(globalPos)的调用方签名不变所有现有菜单详见下文调用点无需任何改动即可获得 Android 安全行为。补丁的副作用WA_DeleteOnClose菜单与 libsigchain SIGSEGV补丁解决了 EGL 死锁却引入了第二个更隐蔽的崩溃点ADR 对此有专门记录A side-effect:WA_DeleteOnClosemenus crash on focus teardown during destruction (SIGSEGVvialibsigchain). Fix:host.setFocus()before the menu hides.WA_DeleteOnCloseQt.WidgetAttribute.WA_DeleteOnClose是 Qt 中窗口关闭即销毁的属性Ghost Downloader-3 中有多处对话框使用该属性如 app/view/dialogs/release_info.py、app/view/dialogs/extension_install.py、app/view/windows/main_window.py。当菜单被重挂载为子控件后若它带有WA_DeleteOnClose且在销毁过程中进行焦点回收focus teardown会经由 Android 上的libsigchain信号链触发SIGSEGV崩溃。修复思路不是去掉WA_DeleteOnClose而是在菜单隐藏之前主动把焦点交还给宿主窗口避免在销毁路径中做焦点切换。补丁在hideEventInWindow中实现def hideEventInWindow(self, event) - None: overlay self.__dict__.pop(_androidOverlay, None) if overlay is not None: overlay.deleteLater() focused QApplication.focusWidget() if focused is not None and (focused is self or self.isAncestorOf(focused)): host self.parent() if isinstance(host, QWidget): host.setFocus(Qt.FocusReason.OtherFocusReason) originalHideEvent(self, event)要点拆解对应 app/view/mobile/patches.py清理覆盖层从__dict__中弹出_androidOverlay引用并deleteLater()避免循环引用与悬挂。焦点预回收判断当前焦点控件是否为菜单本身或其子孙isAncestorOf。若是则在菜单真正隐藏前用Qt.FocusReason.OtherFocusReason把焦点显式转移给宿主窗口。再走原逻辑焦点转移完成后再调用原始的hideEvent此时销毁路径中不再存在焦点回收动作SIGSEGV 被根除。仓库中的菜单调用全景补丁覆盖的典型场景为了理解patchMenus()的覆盖面可以看仓库中真实的菜单调用点这些调用点无需改动全部受益于全局补丁移动端任务卡溢出菜单Android 端把任务卡的操作按钮收进⋮溢出按钮通过menu.exec(self.overflowButton.mapToGlobal(...))弹出上下文菜单见 app/view/mobile/cards.py。这里正是patchMenus()重挂载与坐标换算逻辑的直接受益者——若不重挂载这就是一个标准的独立 Popup 顶层窗口会在真机上触发 EGL 死锁。桌面端任务卡上下文菜单app/view/cards/task_cards.py 中RoundMenu被广泛用于任务操作。身份/配置文件选择菜单app/view/components/option_cards.py 的buildProfileMenu()构建RoundMenu(parentparent)且包含子菜单RoundMenu(...)嵌套补丁中isSubMenu分支与沿 parent 链跳过 RoundMenu的hostWindow()正是为这类嵌套菜单设计。音轨/字幕选择菜单app/view/components/track_bar.py 的MenuTrackButton._openMenu()用CheckableMenu加menu.closedSignal.connect(menu.deleteLater)——即关闭即销毁deleteLater语义与WA_DeleteOnClose同理配合补丁的焦点预回收逻辑才能安全销毁。由此可见patchMenus()虽是一处补丁实则是整套 Android UI 弹层安全的基石。工程集成setupAndroid()与其他 Android 适配patchMenus()并非孤立存在。Android 端通过 app/view/mobile/init.py 的setupAndroid()统一编排所有平台补丁调用顺序为def setupAndroid() - None: from .device import setupFont, setupTheme from .patches import ( patchDialogWidth, patchFileDialogs, patchGroupTouch, patchIconRendering, patchMenus, patchOptionCardLayout, ) setupTheme() setupFont() patchIconRendering() patchFileDialogs() patchDialogWidth() patchGroupTouch() patchMenus() patchOptionCardLayout()与弹层安全直接相关的配套适配还包括patchDialogWidth()patches.py拦截MessageBoxBase.showEvent把消息框宽度限制在父窗口宽度减 24 像素内防止窄屏撑爆布局。patchFileDialogs()patches.py处理 Androidcontent://URI 与文件路径的映射SAF 文档树、/document/ 协议等保证文件对话框返回真实可访问路径。patchOptionCardLayout()/patchGroupTouch()将桌面横排设置卡在窄屏重排为竖排、把折叠组的展开改为触屏友好的按下/抬起语义属于同一套桌面 UI 移动化工程下的布局适配。Android 端主窗口本身也体现了 ADR 原则MobileMainWindow是一个普通的QWidget见 app/view/mobile/window.py页面切换使用QStackedWidget底部导航是内嵌的BottomNavigationBarapp/view/mobile/navigation.py整个界面结构刻意保持单窗口、单 surface与所有弹窗必须是子控件的决策互为表里。决策的适用范围与限制需要明确该 ADR 的边界避免过度推广适用前提该规则针对PySide6 Android EGL硬件加速渲染路径。桌面端Windows/macOS/Linux不存在 EGL surface 单线程死锁问题无需此类补丁——这正是patchMenus()在找不到宿主窗口时回退原始exec的原因保证补丁对桌面零侵入。崩溃的不可复现性ADR 明确指出崩溃device-specific, not reproducible in the emulator因此该决策无法依赖模拟器回归验证只能靠架构规则从源头杜绝。这提醒 Android 移植项目渲染路径相关的移植风险必须在架构层面设防而非事后打补丁。同类控件的适用范围规则明确点名RoundMenu、Flyout、TeachingTip三类弹层。qfluentwidgets 生态中这三类控件是独立顶层窗口的高发区仓库以RoundMenu为切入点全局处理其余类型同理应遵循子控件化原则。小结Ghost Downloader-3 的这条 ADR 提供了一份完整的 Android Qt 弹层移植教科书案例从EGL surface 单线程 → 第二个顶层窗口死锁 → 真机专属崩溃的问题定性到QDialog 带父级无效 / 关硬件加速性能不可接受的选项排除再到所有弹窗控件子控件化的架构规则最终落地为patchMenus()一处猴子补丁加setupAndroid()的统一编排并妥善处理了WA_DeleteOnClose焦点回收的 SIGSEGV 次生问题。对于任何将 PySide6/PyQt 应用移植到 Android 的团队本文记录的决策链ADR 原文、补丁实现、Android 平台层都值得直接复刻——它同时回答了为什么与怎么做以及这样做的代价是什么。【免费下载链接】Ghost-Downloader-3The only downloader you need. 下载器的集大成者。项目地址: https://gitcode.com/GitHub_Trending/gh/Ghost-Downloader-3创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表