前阵子做内部工具的时候,遇到一个特别头疼的事情:主界面一旦需要同时打开多个子页面,窗口就开始失控。页面开少了功能不够用,页面开多了用户根本分不清哪个是哪个,点错了就来回关,体验可以说是非常差。
后来仔细研究了一下PyQt5自带的QMdiArea(经常也写成MDIArea),才发现这东西是解决桌面应用多窗口混乱的正经方案。它是一个多文档界面容器,负责管理所有内部子窗口的排列、切换、堆叠和布局。简单说:你只需要往里丢子窗口,它把"窗口管理"这件事全接过去,你不用自己维护一堆QWidget的坐标和层级。
这篇文章不搞空泛的介绍,我会直接从一个可运行的骨架代码讲起,把子窗口的创建、排列、切换、去重、关闭保存、布局恢复、甚至性能问题全过一遍。内容是我自己实际写代码时摸索出来的,踩过的坑都标了出来,适合准备做编辑器类、工具集类或内部管理系统类桌面应用的朋友。读完之后你大概率不会再想回去手写一堆move()和resize()了。
1. 先搞清楚一个问题:QMdiArea到底替你省了什么
1.1 没有它的时候,桌面窗口管理有多痛
很多刚开始写PyQt5桌面应用的开发者,遇到"多页面"需求的第一反应是:写个QTabWidget,或者干脆弹多个QWidget。这种方式在小工具里确实能用,但一旦页面需要同时可见、来回拖拽、自由缩放,问题就会成堆出现。
举个例子:你要做一个类似一体化工作台的工具,左边是数据浏览页,右边是编辑页,底下还有日志页。如果用普通QWidget弹窗,每个窗口默认没有父对象约束,用户把它们拖到屏幕外面就找不回来了;如果把所有页面塞进一个Tab控件,用户又没法同时看两个页面——这跟多窗口的初衷正好矛盾。
我自己早期踩过的另一个坑是"窗口状态维护"。每个独立弹窗都需要手动记录它上次的位置、大小、最小化状态,等用户下次打开再恢复一遍。一个两个还行,同时维护七八个这种窗口状态,代码很快就成了补丁摞补丁。
QMdiArea把这些问题集中处理了:所有子窗口都被限制在一个统一的容器区域内,既能独立移动、缩放、最小化、最大化,又能以级联、平铺等方式重新排列。你不需要操心子窗口之间的遮挡关系,因为容器本身就给了完整的解决方案。这相当于给所有的内部页面划定了一个"活动空间",而不是让它们散落在桌面上自生自灭。
1.2 "多文档界面"这个词听上去老气,但概念很实用
QMdiArea对应的概念叫MDI,也就是多文档界面,在早期的IDE、文本编辑器、数据库管理工具里非常常见。它让你在主窗口中创建一个内部工作区,然后在这个工作区里塞进多个子窗口。子窗口是QMdiSubWindow,它们可以互相覆盖、拖动、缩放,也可以被统一平铺或级联。
这里有个容易混淆的点:QMdiArea本身是"容器",不是一个"窗口"。真正显示内容的,是被它托管的一个个QMdiSubWindow。每个QMdiSubWindow里面再放实际的业务控件,比如QTextEdit、QTableView或者自定义控件等。你得把这个层级关系弄清楚,后面写代码才不会被控件的父对象绕晕。
在实际项目中,我用它写过类似"聚合查询工具"的桌面客户端:主窗口是MDI区域,用户从左侧导航栏点击不同的菜单,右侧就会打开一个新的子窗口,里面是不同的查询面板。窗口打开多了,用户可以在窗口菜单里选择平铺或级联,也可以直接拖拽分组对比,体验比传统的"一屏一页"好很多。
1.3 为什么不是QTabWidget、QStackedWidget或独立弹窗
聊一下选型。很多人在知乎上问"为什么不用QTabWidget",其实答案取决于场景缺什么。
QTabWidget适合"同一时间只看一个页面"的场景,比如表单步骤、设置页分组。它的优点是实现简单、零配置,缺点是用户不能同时看到两个tab内容,而且不能自由调整每个页面的布局。QStackedWidget就更极端了,它只维护一个索引,切换时整个视图重排,几乎没有窗口管理的概念。
独立弹窗(无父子QWidget)则完全不同:每个窗口拥有独立的系统级边框、任务栏条目和桌面位置,管理复杂度更高,在Windows和Linux上的行为差异也更大。假如你的功能窗口必须始终依附在主应用区域内,不能让用户随意拖到主窗口外面,那么MDI区域天然就是正确的选择。
所以我的结论是:当你的应用有多个"可以并存、可以独立操作、但容器需要掌控它们"的工作区时,QMdiArea就是比Tab和弹窗都合适的底层方案。下面开始搭骨架。
2. 搭建最小可用窗口管理骨架,从写代码第一步就避开坑
2.1 主窗口与MDI区域的初始化姿势
在PyQt5里,QMdiArea的正确用法是把QMainWindow的中央控件换成QMdiArea。注意,不是把它塞进某个布局,而是setCentralWidget,这样MDI区域会自动填满主窗口的剩余空间,同时还会随着主窗口缩放而自适应。如果硬要塞QVBoxLayout,大概率会出现边距、缩放不跟手的问题。
先给一个最小可运行骨架,后面所有的功能都是在这个基础上叠加的:
import sys from PyQt5.QtWidgets import ( QApplication, QMainWindow, QMdiArea, QMdiSubWindow, QTextEdit, QAction ) class MainWindow(QMainWindow): def __init__(self): super().__init__() self.setWindowTitle("MDI窗口管理示例") self.resize(1200, 800) # 中央区域必须是QMdiArea self.mdi = QMdiArea() self.setCentralWidget(self.mdi) self.createActions() self.createMenus() self.openNewWindow() def createActions(self): self.newAction = QAction("新建窗口", self) self.newAction.triggered.connect(self.openNewWindow) self.tileAction = QAction("平铺", self) self.tileAction.triggered.connect(self.mdi.tileSubWindows) self.cascadeAction = QAction("级联", self) self.cascadeAction.triggered.connect(self.mdi.cascadeSubWindows) def createMenus(self): fileMenu = self.menuBar().addMenu("文件") fileMenu.addAction(self.newAction) windowMenu = self.menuBar().addMenu("窗口") windowMenu.addAction(self.tileAction) windowMenu.addAction(self.cascadeAction) def openNewWindow(self): sub = QMdiSubWindow() editor = QTextEdit() editor.setPlainText(f"这是第 {len(self.mdi.subWindowList()) + 1} 个子窗口") sub.setWidget(editor) sub.setWindowTitle(f"文档 - {len(self.mdi.subWindowList()) + 1}") self.mdi.addSubWindow(sub) sub.show() if __name__ == "__main__": app = QApplication(sys.argv) win = MainWindow() win.show() sys.exit(app.exec_())这段代码跑起来之后,你应该能通过菜单项不断新建子窗口,并且从"窗口"菜单里一键平铺或级联所有子窗口。整个过程中不需要手动碰任何move()或resize(),排列逻辑全部由QMdiArea完成。
有个细节必须提:addSubWindow()之后一定要手动调用sub.show()。很多人第一次写会漏掉,结果子窗口迟迟不显示。addSubWindow()只是把子窗口注册进容器,它不会自动show。哪怕前面调了setWidget,子窗口本身仍然是隐藏的。这个坑我没少踩。
2.2 创建子窗口:addSubWindow、setWidget与ownership的细节
接着说创建子窗口的几种方式,它们之间的细微差别也值得抠一抠。
第一种是先构造QMdiSubWindow,然后用setWidget()塞入内容控件,最后addSubWindow()。第二种是直接调用addSubWindow(widget),让QMdiArea在内部自动创建一个子窗口并放入widget。两种方式在最终效果上几乎一样,但第一种更灵活——因为你能在加入容器之前就设置子窗口的初始大小、标题、图标等属性。
推荐用第一种,因为代码可读性强,后面要对子窗口做状态管理时,你能直接拿到QMdiSubWindow的引用来响应对应操作。第二种方式如果想把内容控件和子窗口对应起来,还得额外调用subWindowList()去反查,比较麻烦。
关于所有权:QMdiArea会获取你传入的控件的所有权,所以不要让被添加的widget再设置一个父对象。换句话说,sub.setWidget(editor)之后,不要再editor.setParent(anything)。如果多次设置父对象,轻则控件消失,重则直接抛RuntimeError: wrapped C/C++ object of type QTextEdit has been deleted。
这里还有一个隐藏知识点:setWidget()执行后,子窗口的大小会自动适应widget的sizeHint()和minimumSizeHint()。所以如果你的内容控件在加载后还没确定好最小尺寸,子窗口看起来就会很小。我给表格类控件塞进子窗口时,通常会先setMinimumSize(600, 400),再setWidget,这样第一次打开就不会出现一个窄条。
2.3 子窗口的标题、图标和可调节属性
子窗口会显示标题栏,这个标题栏里面的标题默认来自sub.setWindowTitle()。既然QMdiSubWindow继承自QWidget,凡是你对普通窗口熟悉的方法,比如setWindowIcon、setWindowTitle,都能直接用。
还有一个重要的属性组:setWindowFlags()。如果希望某个子窗口不可关闭、只读或固定大小,可以通过setWindowFlags(Qt.WindowMinimizeButtonHint)之类的标志调整。但我要提醒一句:不要随便改这些flags,除非业务场景明确要求。比如做内部管理系统时,如果所有子窗口都禁止了窗口关闭按钮,用户反而会觉得奇怪。
QMdiSubWindow还提供了常见的resizable、minimizable、maximizable、closable属性,用setOption()方法控制。比如限制某个面板不能被最大化:
sub.setOption(QMdiSubWindow.Maximizable, False)这些属性加在子窗口上,既不会影响内容控件,又能让用户操作更符合你的产品预期。真正常见的场景是:普通业务页面允许自由缩放和最大最小化,但"日志监控"这种固定面板保持固定大小,避免拖乱布局。这个功能就是拿setOption实现的。
3. 窗口管理操作全梳理:平铺、级联、切换、关闭、恢复
3.1 两种视图模式:自由浮动的MDI视图与Tab折叠视图
QMdiArea有setViewMode()方法,可以切换两种完全不同的布局策略。
默认是QMdiArea.SubWindowView,也就是传统MDI风格:所有子窗口自由浮动在容器里,可以移动,可以层叠,可以平铺。另一种是QMdiArea.TabbedView,它看起来和QTabWidget很像,所有子窗口以标签页的形式叠加排列,同一时间只能看到一个。
不少刚接触QMdiArea的开发者以为TabbedView更方便省事,但实际上,一旦切到TabbedView,平铺、级联这类排列方法就基本失效了。因为Tab模式下每个子窗口都没有独立的位置坐标,整个容器只有一个"当前页"。如果你的需求是"页面之间可以对比查看",就不要用TabbedView;如果只是为了让多个页面存在但互不干扰,TabbedView倒是不错。
我自己常用的方式:默认用SubWindowView,同时给"窗口"菜单加一个切换视图模式的选项,让用户自己决定是自由排列还是折叠成Tab。这样既能满足喜欢多窗口并排的深度用户,也照顾到只想快速切换页面的人。切换时调用:
if self.mdi.viewMode() == QMdiArea.SubWindowView: self.mdi.setViewMode(QMdiArea.TabbedView) else: self.mdi.setViewMode(QMdiArea.SubWindowView)切换过程中,所有子窗口本身不会销毁,只是在容器内部重新组织显示逻辑,所以业务数据不会丢。这一点是QMdiArea做得很棒的地方,你可以随时在两种模式之间反复横跳。
3.2 平铺和级联的实现细节与常见失效原因
tileSubWindows()和cascadeSubWindows()是官方提供的两个排列方法。正常使用时,它们会把所有已显示的、处于正常状态的子窗口排成棋盘格式或层叠格式,非常省事。但这里有几个失效原因,挺多人没注意到。
第一个失效场景:子窗口处于最大化状态。如果你先把某个子窗口最大化了,再点"平铺",大部分情况下tileSubWindows()不会恢复出预期效果,它很可能只调整了非最大化窗口的位置。所以正确做法是先遍历子窗口,把所有最大化的窗口恢复为正常显示:
for sub in self.mdi.subWindowList(): sub.showNormal() self.mdi.tileSubWindows()第二个失效场景:存在隐藏的子窗口。有些子窗口只是被关闭或隐藏,但它们仍然留在subWindowList()列表里。平铺操作并不会为隐藏窗口分配位置,但子窗口列表的长度会影响后续逻辑。我在做"文档数量统计"的时候就吃过这个亏:列表里有12个窗口,实际显示的只有5个,计算的数字完全对不上。
第三个注意点:平铺不是等比压缩。tileSubWindows()倾向于给每个窗口分配一个矩形区域,如果子窗口数量很多,矩形会越来越小,最后文字几乎看不清。如果业务上经常需要打开十几个窗口,我会建议改用级联视图,或者在达到一定数量时自动切换成Tab模式。这个经验在用户测试中被验证过很多次——超过8个平铺窗口的时候,几乎没有人能从视觉上快速找到自己想要的那个。
3.3 窗口菜单不止是放按钮:切换、关闭、激活联动
很多应用做"窗口"菜单,只是把平铺、级联按钮放上去,这其实浪费了QMdiArea提供的窗口管理能力。它有几个特别实用的API,应该配合菜单一起用。
activeSubWindow()返回当前激活的子窗口;setActiveSubWindow(sub)可以把某个子窗口提到最前并激活。通过这两个方法,你可以实现一个"窗口切换列表"。比较朴素的做法是遍历subWindowList(),然后在菜单里用addAction(sub.windowTitle())把每个窗口的标题加进去,再绑定到切换槽函数。这样用户就能像在IDE里一样,直接从"窗口"菜单选中任意子窗口跳转。
我给窗口菜单还加了几个固定动作:"关闭当前窗口""关闭所有窗口""最小化所有窗口""恢复所有窗口"。这些动作的枚举逻辑都围绕subWindowList()展开,比如关闭全部就遍历并调用sub.close()。实现成本很低,但对用户感知的提升特别明显,尤其是"关闭所有窗口"这个动作,在工具类应用里救过很多次手滑。
要注意的是,sub.close()会触发QMdiSubWindow的closeEvent,如果里面已经写了一些保存数据的逻辑,关闭全部时会逐个弹出确认框。如果你希望"关闭所有窗口"是一个硬操作,不给确认机会,那就要绕开close(),直接调用sub.widget().deleteLater()或sub.deleteLater()。这个区别后面专门讲生命周期时还会再提一次。
4. 真实项目里最绕不开的子窗口生命周期:创建、去重、保存、恢复
4.1 同一个文档窗口被重复打开的问题
"用户点一次菜单就新开一个窗口"看起来没问题,但真实业务里很快会变成灾难。比如一个数据查询工具,用户每点一次"查询面板"就生成一个新面板,五分钟后开了八个一模一样的窗口,内存和界面都受不了。
解决的思路很简单:打开前先查重。查重的依据通常是内容控件的数据标识,比如文档路径、表名、查询ID等。实现上,我习惯在子窗口内容控件上挂一个自定义属性,比如用widget.setProperty("panel_key")保存唯一键,然后在subWindowList()里遍历查找:
def find_subwindow_by_key(self, key): for sub in self.mdi.subWindowList(): w = sub.widget() if w and w.property("panel_key") == key: return sub return None如果找到了,就self.mdi.setActiveSubWindow(sub)把它激活并恢复到正常显示,而不是重新创建。这种"单例子窗口"模式在内部工具里非常实用:导航点击、菜单触发、甚至系统托盘都能调用同一个子窗口实例,不会重复。
还有一点要注意:subWindowList()返回的子窗口顺序是"创建先后顺序",不是"显示的前后层级"。做去重和查找时可以放心遍历它,但如果你想找出"最上层的窗口",用activeSubWindow()更靠谱,别指望列表里最后一个就是当前可见的。
4.2 子窗口关闭前保存数据:closeEvent到底写在谁身上
多窗口应用里,最让人紧张的时刻就是"用户点了关闭,但数据还没保存"。很多人习惯给子窗口内容控件写closeEvent,却发现有时根本不触发或触发了不该触发的逻辑。原因要先理清:QMdiSubWindow的closeEvent和它内部widget的closeEvent是两个不同的事件。
当用户点击子窗口标题栏上的关闭按钮,触发的是QMdiSubWindow.closeEvent。如果直接在这里拦截,你可以阻止子窗口关闭,但这只是"窗口关闭",里面的业务控件并没有收到你自己的通知。反过来,如果你只给内容控件写closeEvent,用户在子窗口标题栏点关闭时,这个事件通常不会被调用,因为关闭的是容器子窗口而不是widget本身。
所以我通常建议:自定义一个内容控件类,将业务数据保存在里面,同时实现一个maybeSaveData()方法;然后在子窗口的closeEvent中调用它,根据返回值决定是否放行。代码模式大致长这样:
class DocPanel(QTextEdit): def maybeSaveData(self): if self.document().isModified(): # 这里可以根据实际场景弹提示框 return True return True class DocSubWindow(QMdiSubWindow): def closeEvent(self, event): panel = self.widget() if panel and panel.maybeSaveData(): event.accept() else: event.ignore()这样无论关闭子窗口的动作来自标题栏、菜单还是代码里的sub.close(),都会走到一致的保存逻辑。别把保存逻辑直接写进QMdiArea的关闭事件里,因为MDI区域的关闭事件是关闭整个主窗口时才会触发的,不是每个子窗口关闭都会触发。
4.3 关闭之后如何恢复上次的布局
如果不是立刻销毁数据,而是希望在下次打开时恢复布局,你需要把每个子窗口的geometry和业务标识保存下来。
保存时机可以放在上面说的closeEvent里:先拿到sub.geometry(),也就是子窗口在QMdiArea坐标系中的相对位置和大小,再和"面板标识"一起写入配置。下次用户重新打开面板时,先创建子窗口,加入容器后立刻sub.setGeometry(saved_geometry),再show()。
这段逻辑看似简单,实际上有一个顺序问题:必须先addSubWindow()再setGeometry(),最后show()。如果先setGeometry()再addSubWindow(),MDI容器可能会在初始化时重新调整子窗口的位置,把你设置好的geometry覆盖掉。我在自己的工具里恢复布局时,遇到过若干次"记住的位置被复原成左上角堆叠"的诡异情况,排查到最后就是这一行顺序没写对。
主窗口级的布局恢复还有另一个API:QMainWindow.saveState()和restoreState(),它保存的是主窗口工具栏、停靠窗口、状态栏的状态,和子窗口布局是两码事。很多初学者把这两者混为一谈,结果用saveState()存完子窗口,重启后什么都没恢复——这是不同层级的机制,不能互相替代。
5. 进阶打磨与性能优化:子窗口多起来之后怎么办
5.1 大量子窗口时的内存与状态管理
不要迷信"QMdiArea能管理所有子窗口,所以随便创建"。它管理的是窗口状态和布局,不是内存。如果你在一个窗口里开20个QTextEdit,每个里面都是大文本数据,内存占用仍然是按每个widget实打实算的。
比较好的做法是延迟创建,或者按需销毁。比如"最近打开的面板列表"可以先只显示标题,用户真正点击某个面板时才加载数据并创建widget;等用户关闭这个面板时,如果数据已保存,就deleteLater()销毁widget,而不是藏在子窗口列表里占据资源。
这里又牵扯到前面提到的一个选择:sub.close()会触发确认流程,而"严肃销毁"要走sub.deleteLater()。在内部工具里我一般这样分工:跟用户数据无关的临时面板,直接删除;跟核心业务相关的面板,允许关闭但不销毁,只隐藏,等下次点击时重新显示。
关于subWindowList()返回的是当前所有子窗口的引用,注意别在遍历列表时同时删除元素。以前我写过这样的代码:
for sub in self.mdi.subWindowList(): if should_delete(sub): self.mdi.removeSubWindow(sub) sub.deleteLater()在大部分场景下没问题,但如果你在这一轮循环里把sub从subWindowList()移除了,迭代器底层的列表引用就可能出现漂移。更稳妥的做法是先拷贝一份列表,再对拷贝做循环:
subs = list(self.mdi.subWindowList()) for sub in subs: if should_delete(sub): self.mdi.removeSubWindow(sub) sub.deleteLater()这样不会跳过任何窗口,也不会在同一轮对已经删除的窗口做操作。
5.2 样式表、背景色与标题栏的几个深坑
QMdiArea接入项目的深色主题时,问题会比较集中。第一个坑是QMdiArea默认背景色是浅灰色,如果你在主窗口的样式表里直接写了background: #1e1e1e,这个颜色不一定会贯彻到MDI区域内部。你需要单独给QMdiArea设置背景,比如:
self.mdi.setStyleSheet("QMdiArea { background: #1e1e1e; }")但注意,一旦给QMdiArea设置了样式表,子窗口的标题栏在部分系统样式下也可能跟着变样,有时候是边框消失,有时候是标题文字颜色不清晰。一个比较稳的方案是只对MDI区域设置背景,对QMenuBar、QStatusBar、菜单控件单独设置样式,不要全部放在一层样式表里。
第二个坑是子窗口标题栏图标的显示。如果子窗口设置了setWindowIcon(),在SubWindowView下标题栏会显示图标,但在TabbedView下取决于当前平台样式,可能不显示。这不算bug,但是用户在切换视图模式时可能会来问"图标为什么不见了",提前了解即可。
还有一个体验细节:如果你给QMdiArea设置了setBackground(QBrush(...)),它只影响容器背景,不会影响子窗口内部控件的背景。想统一子窗口的底色,还是要在内容控件自己的样式表里写,别指望MDI区域顺带把内部也处理了。这个认知到位之后,调主题配色会省很多时间。
5.3 我实测过最实用的窗口管理技巧
最后分享几个项目中验证过的小技巧,不一定复杂,但都真能提升体验。
第一个是"按窗口标题过滤"。当子窗口数量多起来,用户希望快速定位到某个窗口。我写过一个简单的过滤器:在"窗口"菜单顶部放一个QLineEdit,输入关键字后动态隐藏子窗口标题不匹配的候选项。实现上不复杂,但配合subWindowList()遍历和菜单重建,能给内部工具带来非常明显的效率提升。
第二个是"记住最后一个激活的窗口"。在应用关闭时,把activeSubWindow().geometry()和对应的业务标识存下来;下次启动时,先恢复主窗口布局,再恢复这些子窗口的几何尺寸。这个操作前后总共不到20行代码,但对"每次打开工具都要手动重新排好窗口"的重度用户来说,体验差异极大。
第三个是"利用QMdiArea的信号做状态栏同步"。QMdiArea会在子窗口激活时发出subWindowActivated信号,我可以把当前子窗口的标题和状态实时同步到主窗口状态栏。比如显示"当前文档:project_config.yaml",这样用户在多个窗口之间切换时,始终知道自己在哪个上下文里,减少误操作。
第四个是"别忘了给QMdiArea设置可用空间提示"。当没有窗口打开时,直接显示一个空白背景很容易让人困惑。我通常会在MDI区域里放一个带提示文字的QLabel(默认隐藏),当subWindowList()为空时显示,有窗口时隐藏。这不算QMdiArea的官方功能,但多几行判断逻辑就能让界面友好很多,属于低成本高回报的优化。
结尾:关于窗口管理,最后说几句掏心窝的话
把QMdiArea真正用熟之后,我最大的感受是:它不是一个"神奇控件",而是一个"托管机制"。你给它正确的子窗口,它就能把排列、切换、布局这些基础事务接管过去,把精力留给业务逻辑。但如果只停留在"会用addSubWindow"的层面,它跟普通弹窗也没多大区别,真正拉开距离的是对生命周期、布局恢复、资源释放和用户交互细节的处理。
我日常写内部工具时,窗口管理的代码量其实没有想象中那么多,但因为QMdiArea挡住了大量的状态复杂度,维护起来反而轻松。以前我花很多时间修窗口坐标、调层级关系,现在这部分几乎不用管了。对于有兴趣做桌面工具的朋友,我建议你先拿这篇文章前面的骨架跑起来,加上去重、保存、恢复这三个功能,再思考你自己的窗口管理菜单要长什么样子——这个过程中踩过的坑,都会成为你日后写多窗口应用的底气。