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 单线程表面与 AndroidDeadlockProtector
Ghost 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; - 脱离父控件独立弹出的
RoundMenu(qfluentwidgets 的圆角菜单)。
其崩溃特征极具迷惑性:只在 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_DeleteOnClose(Qt.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(parent=parent),且包含子菜单(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,底部导航是内嵌的BottomNavigationBar(app/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),仅供参考