1. 为什么非要一个MDI容器单窗口的凌乱与QTabWidget的局限先说个真实场景。我之前做过一个工程管理类的桌面工具核心需求是把数据看板、配置面板、日志查看器、SQL控制台全部塞进主窗口。一开始用QMainWindow的DockWidget停靠窗口来布局再配合QTabWidget做页签切换。结果界面越做越像塞满标签页的浏览器用户想同时看两份日志就得来回切Tab想对比两个子页面的内容就得拖出Dock拖完又容易乱套主界面最终变成一堆悬浮窗挤在一起连谁是谁都分不清。后来我换成了QMdiArea效果立竿见影。这个组件是PyQt5自带的本质就是一个多文档界面MDI容器专门用来解决“一个应用里同时打开多个内部窗口”的杂乱问题。它可以像操作系统桌面一样管理子窗口能自由移动、缩放、最小化、最大化还支持层叠和平铺排列甚至一键切到页签模式。那些按钮、菜单栏、状态栏全都不用自己手动接管QMdiArea已经把窗口管理逻辑封装好了。1.1 单主窗口方案的三个痛点如果你没写过MDI应用可能觉得QMainWindow加QTabWidget就够用了。我当初也是这么想的直到被现实教育了一整轮。第一个痛点是布局自由度几乎为零。QTabWidget的本职工作是“同一时刻只给你看一个页签”用户想同时看两个子界面时只能来回切。有人会塞两个QTabWidget进QSplitter做成左右两栏但这样左右栏的数量是写死的没法动态增减而且拖拽、二次排列全是自己写代码、自己管状态复杂度直接翻倍。第二个痛点是子窗口的生命周期没人管。每个子页面都要自己写创建、销毁、最小化、恢复的逻辑窗口间的层级关系也得手工维护。你想做个“文件A和文件B并排对比”操作得先找到A和B的内容控件再算出主窗口的一半尺寸再重新布局——这种代码写多了其实就是自己拿头撞墙把QMdiArea能干的活全干了一遍。第三个痛点是主窗口菜单联动。MDI最方便的一点是子窗口激活时能自动把菜单、工具栏、状态栏信息切换成对应内容。这个联动在QTabWidget上需要你自己监听currentChanged信号再去逐个更新到了QMdiArea这里一套信号槽就解决了而且不用区分到底是哪个子窗口被激活。1.2 MDI模型的思想把主窗口当成物理桌面QMdiArea的底层思想特别直白主界面就是一张桌面每个QMdiSubWindow就是摊在桌上的文档。你可以把两份文档并排摊开也可以把暂时不用的文档缩小、丢到角落还可以把正在写的文档全屏铺满。所有文档互不遮挡是不可能的但用户有一百种方法把它们摆整齐比如一键层叠Cascade、一键平铺Tile、一键切页签。这种模型最适合文档密集型应用场景比如多文档编辑器、邮件客户端、工程管理工具、数据比对平台。它不适合纯移动端也不适合那种永远只展示一个独立页面的表单型App。记住一个判断标准你的用户有没有“同时看两个子界面”的需求有就上MDI没有QStackedWidget配合QTabWidget已经足够。2. 开工前的环境准备与安装坑实录这一节先聊环境。很多人刚开始用PyQt5在pip安装上就会卡一阵子。我用的组合是Python 3.8 搭配 PyQt5 5.15.x这个版本的QtWidgets模块已经非常稳定QMdiArea的所有特性都能正常使用。安装命令并不复杂pip install PyQt55.15.11理论上装完就能用但如果你的系统里还有别的PyQt5相关包比如PyQt5-sip、PyQt5-tools就得留心版本之间的依赖关系。PyQt5是依赖sip库来绑定C对象的sip版本过高或过低都会导致import阶段直接报错常见的错误是ModuleNotFoundError: No module named PyQt5.sip。碰到这种问题最直接的办法是把整套重装一遍让pip自动匹配依赖pip uninstall PyQt5 PyQt5-sip PyQt5-tools -y pip install PyQt55.15.11不要手动去装单独某个包让pip自己解析依赖能省掉一大半麻烦。2.1 labelme装不上PyQt5是怎么回事我注意到最近总有人在问“labelme 无法安装 pyqt5”。这个问题其实很好理解。labelme是图像标注工具它里面也依赖PyQt5做界面。当你先装了某个PyQt5版本再装labelme时pip发现labelme要求的PyQt5版本和你当前装的不一致就会提示版本冲突或者直接拒绝安装。解决办法有两个把现有PyQt5卸干净先装labelme让labelme自己把配套的PyQt5拉起来。更推荐用虚拟环境单独开一个conda环境或venv给labelme用别和主开发环境搅在一起。我之前就是贪方便全局混装结果一个项目要PyQt5.15、另一个工具要PyQt5.9最后只能天天切环境纯属给自己挖坑。2.2 界面设计辅助工具Qt Designer标题热词里还有“pyqt5界面设计”和“pyqt5界面设计 pycharm”这里顺便带一句。用QMdiArea这种方式做窗口系统我的习惯是核心代码手写复杂界面用Qt Designer辅助。Qt Designer是随PyQt5-tools一起装的打开后左侧组件栏里就有MDI Area这个控件可以直接拖进主窗口。如果你是新手我的建议是Designer负责搭骨架主窗口、菜单栏、QMdiArea占位逻辑全放到代码里。因为Designer生成的ui文件一旦涉及复杂信号逻辑反而不好改手写代码反而更容易控制细节。3. QMdiArea核心API拆解从addSubWindow到排列策略环境就绪后我们来拆核心API。QMdiArea虽然是容器但它的API数量并不多掌握几个关键方法就能应付绝大多数场景。3.1 创建QMdiArea与第一个子窗口主窗口里嵌入QMdiArea最基础的做法是这样import sys from PyQt5.QtWidgets import QApplication, QMainWindow, QMdiArea, QMdiSubWindow, QTextEdit class MainWindow(QMainWindow): def __init__(self): super().__init__() self.setWindowTitle(MDI Demo) self.resize(1000, 700) self.mdi QMdiArea() self.setCentralWidget(self.mdi) # 创建子窗口 sub QMdiSubWindow() editor QTextEdit() editor.setPlainText(这是第一个子窗口的内容) sub.setWidget(editor) sub.setWindowTitle(文档1) self.mdi.addSubWindow(sub) sub.show() def open_new_doc(self, title): sub QMdiSubWindow() editor QTextEdit() editor.setPlainText(新文档内容) sub.setWidget(editor) sub.setWindowTitle(title) self.mdi.addSubWindow(sub) sub.show() return sub if __name__ __main__: app QApplication(sys.argv) win MainWindow() win.show() sys.exit(app.exec_())注意几个细节addSubWindow返回的是QMdiSubWindow本身但创建时空参数也能调用然后再用setWidget填充内容。sub.show()不能省。addSubWindow只是把子窗口挂到MDI容器里不调用show的话它不会显示出来。子窗口的标题用setWindowTitle这会影响后续窗口菜单里的显示文字以及层叠/平铺排列时的标题栏。3.2 排列方式与视图模式切换QMdiArea提供了两个经典的方法cascadeSubWindows()层叠和tileSubWindows()平铺。层叠的效果是窗口依次错开叠放平铺则是把主区域等分铺满。# 在工具栏上绑定两个动作 act_cascade.triggered.connect(self.mdi.cascadeSubWindows) act_tile.triggered.connect(self.mdi.tileSubWindows)如果你想要更现代的观感可以切换到页签模式self.mdi.setViewMode(QMdiArea.TabbedView)切到TabbedView后子窗口的标题会变成页签用户点击页签就能切换内容。本质上这就是把QTabWidget的交互整合进了MDI里窗口列表、切换逻辑全都不用自己写。这里有一个很重要的取舍SubWindowView普通窗口模式适合需要自由拖拽、并排对比的用户TabbedView适合偏向顺序切换的用户。最好的做法是给用户提供切换视图模式的入口而不是自己替用户决定。3.3 一个坑TabbedView模式下setActiveSubWindow的行为我踩过一次很深的坑在TabbedView模式下调用setActiveSubWindow(sub)有时不会立刻切换到目标子窗口。原因是页签模式下激活行为被Qt自动延迟到事件循环空闲阶段处理。解决办法是加一行强制刷新self.mdi.setActiveSubWindow(sub) QApplication.processEvents() # 强制处理事件队列不加这行有些用户在快速点击菜单切换子窗口时界面上显示的页签和实际内容会出现短暂错位。这个问题在SubWindowView模式下不明显但TabbedView模式下肉眼可见。4. 实战打造一个带记忆功能的工程文档总控台光讲API没意思我直接把之前做的一个“工程文档总控台”简化成一个可复现的实例。这个工具的行为是可以打开任意多个文本文件每个文件一个QMdiSubWindow侧边栏有文件列表窗口菜单可以切换所有打开的文档关闭应用后记住上次打开的文档列表和主窗口的几何位置。4.1 功能设计与界面骨架主窗口QMdiArea 顶部工具栏 侧边文件树文件树用QListWidget列出工程目录下的.txt文件双击打开窗口菜单列出所有子窗口并支持切换、关闭状态栏显示当前激活子窗口的文件名关键代码段如下import os from PyQt5.QtWidgets import (QAction, QFileDialog, QListWidget, QMdiArea, QMdiSubWindow, QMainWindow, QTextEdit, QDockWidget, QApplication, QWidget) from PyQt5.QtCore import Qt, QSettings class DocCenter(QMainWindow): def __init__(self): super().__init__() self.setWindowTitle(工程文档总控台) self.resize(1200, 800) # MDI self.mdi QMdiArea() self.setCentralWidget(self.mdi) # 侧边文件树 self.file_list QListWidget() dock QDockWidget(工程文件, self) dock.setWidget(self.file_list) self.addDockWidget(Qt.LeftDockWidgetArea, dock) # 扫描当前目录的 .txt 文件 self.scan_dir(.) # 动作与菜单 self.create_actions() # 用 QSettings 恢复上次打开的文档 self.restore_last_session() def scan_dir(self, path): self.file_list.clear() for f in os.listdir(path): if f.endswith(.txt): self.file_list.addItem(f) self.file_list.itemDoubleClicked.connect(self.open_from_list) def open_from_list(self, item): self.open_doc(item.text()) def open_doc(self, filename): for sub in self.mdi.subWindowList(): if sub.windowTitle() filename: self.mdi.setActiveSubWindow(sub) return editor QTextEdit() try: with open(filename, r, encodingutf-8) as fp: editor.setPlainText(fp.read()) except FileNotFoundError: editor.setPlainText(文件不存在) sub QMdiSubWindow() sub.setWidget(editor) sub.setWindowTitle(filename) self.mdi.addSubWindow(sub) sub.show()4.2 窗口菜单与子窗口联动QMdiArea有一个很体贴的接口setWindowMenuEnabled(True)。启用后父窗口菜单栏里只要有一个顶层菜单被作为窗口菜单绑定所有子窗口列表就会自动填充到这个菜单里点击任意一项即可激活对应用口。def create_actions(self): file_menu self.menuBar().addMenu(文件) open_act QAction(打开..., self) open_act.triggered.connect(self.open_file_dialog) file_menu.addAction(open_act) # 关键启用 KMdiArea 自动管理的窗口菜单 self.mdi.setWindowMenuEnabled(True) self.window_menu self.menuBar().addMenu(窗口) # 注意设置完 windowMenu 之后不要自行清空菜单 # QMdiArea 会自动填充子窗口项setWindowMenuEnabled是MDI容器的一个内置行为它会在父窗口菜单栏里找到第一个你绑定过的、并且没有显式加QAction的QMenu把它当成窗口切换菜单。如果不想用自动菜单也可以自己遍历self.mdi.subWindowList()手工填充QAction并通过setActiveSubWindow来切换。但说实话能用内置功能就用内置功能自己造轮子只会增加Bug面。4.3 用QSettings记住上次的打开状态桌面应用的用户体验分两个层次第一层是功能能用第二层是下次打开还是上次的样子。QSettings是Qt提供的一个轻量级配置存储不需要数据库直接消费注册表或ini文件很适合存窗口几何信息和上次打开的文档列表。def closeEvent(self, event): # 记录所有打开的子窗口标题 titles [sub.windowTitle() for sub in self.mdi.subWindowList()] settings QSettings(MyCompany, DocCenter) settings.setValue(recent_docs, titles) settings.setValue(geometry, self.saveGeometry()) super().closeEvent(event) def restore_last_session(self): settings QSettings(MyCompany, DocCenter) geometry settings.value(geometry) if geometry: self.restoreGeometry(geometry) titles settings.value(recent_docs, []) if isinstance(titles, list): for title in titles: if title.endswith(.txt): self.open_doc(title)这里判断isinstance(titles, list)是很关键的一步。因为QSettings从不同平台读回来的类型可能不一样有的可能是QVariant有的可能是列表。没有这个判断某些系统上会直接把字符串当列表遍历一个个字符开成文档页面会多出一堆文件名像“a”、“b”、“c”这样的空窗口非常尴尬。5. 进阶玩法子窗口定制、快捷键与焦点陷阱基础功能有了UI也算完整了但实际交付项目时还会碰到一堆细节。这四个进阶点是我觉得最值得关注的。5.1 用setWindowOptions控制子窗口的“权限”QMdiArea里的子窗口默认自带系统菜单、关闭按钮、最小化按钮、最大化按钮。但有些场景你并不想给用户这么多控制权比如日志面板如果可被最大化用户不小心双击标题栏就会全屏日志列表一下子变成满屏反而影响操作。通过setWindowOptions可以按位控制sub.setWindowOptions( QMdiSubWindow.DisableCloseButton | QMdiSubWindow.DisableWindowSystemButton | QMdiSubWindow.DisableMaximizeButton )常量说明DisableCloseButton隐藏关闭按钮子窗口不能通过标题栏关闭DisableMaximizeButton隐藏最大化按钮DisableMinimizeButton隐藏最小化按钮DisableWindowSystemButton隐藏整个窗口控制按钮组RubberBandMove/RubberBandResize用半透明橡皮筋框表示移动和缩放的预览效果注意RubberBand系列选项打开后在低性能机器上会发现移动窗口时有轻微卡顿因为它要实时绘制拖拽预览框。追求流畅度保持默认的RubberBandMove | RubberBandResize关闭状态反而更好。5.2 子窗口的关闭确认重写qmdisubwindow默认情况点掉子窗口的X它就直接消失了。但工程文档场景下用户可能忘记保存。这里有两种方案方案一监听subWindowList()每个子窗口的close信号。这个方法可行但需要维护一个信号绑定列表比较啰嗦。方案二继承QMdiSubWindow重写closeEventclass DocSubWindow(QMdiSubWindow): def __init__(self, file_path, parentNone): super().__init__(parent) self.file_path file_path self.saved True def closeEvent(self, event): if not self.saved: ret QMessageBox.question( self, 未保存, f{self.windowTitle()} 尚未保存确定关闭 ) if ret QMessageBox.Yes: event.accept() else: event.ignore() else: event.accept()然后在open_doc里用DocSubWindow代替普通QMdiSubWindow即可。这样做的好处是子窗口自己管理自己的保存状态主窗口的closeEvent反而不需要逐个遍历所有子窗口做确认。父窗口关闭时只要调用event.accept()所有子窗口会依次触发自身的closeEvent未保存的子窗口自然会拦截关闭流程。5.3 焦点陷阱不要把QMdiArea容器本身设成焦点窗口开发MDI应用时很容易犯一个错误把状态栏信息绑定到当前激活的子窗口title上但忘记考虑“最后一个子窗口被关闭”的情况。QMdiArea在没有任何子窗口时activeSubWindow()返回None。此时如果你的状态栏更新逻辑直接调用sub.windowTitle()就会抛AttributeError。安全写法def on_subwindow_activated(self, sub): if sub is None: self.statusBar().showMessage(就绪) return self.statusBar().showMessage(f当前文档{sub.windowTitle()}) self.setWindowTitle(f工程文档总控台 - {sub.windowTitle()})另一个焦点陷阱是当你在某个子窗口里弹出右键菜单或子对话框时activeSubWindow()会暂时返回None因为焦点已经被弹出框抢走了。所以状态栏更新逻辑里最好只使用“最后已知的子窗口变量”而不是每次都去activeSubWindow()现查。简单做法是维护一个成员变量self.current_sub在信号里更新在状态栏显示逻辑里直接用这个变量。5.4 大量子窗口的加载策略如果你一次打开几十个文件MDI容器会变得非常卡。原因是每个QMdiSubWindow都会立刻创建对应的QTextEdit并且把所有文件内容全部读入内存。这不是MDI的错而是懒加载没做好。推荐的策略是打开文件时不读内容只创建空QTextEdit和正确的窗口标题等子窗口真正被激活时再读取文件内容。这样即便用户同时开50个文件内存也不会瞬间爆炸。class LazyDocWindow(QMdiSubWindow): def __init__(self, file_path, parentNone): super().__init__(parent) self.file_path file_path self.loaded False self.editor QTextEdit() self.setWidget(self.editor) self.setWindowTitle(file_path) def ensure_loaded(self): if not self.loaded: with open(self.file_path, r, encodingutf-8) as fp: self.editor.setPlainText(fp.read()) self.loaded True主窗口在subWindowActivated信号里调用sub.ensure_loaded()。这个模式的收益在大文件场景下特别明显。我曾经对比过不懒加载时打开6个200MB的日志文件QMdiArea卡住要十几秒加了懒加载后启动只需要瞬间切到哪个窗口才读哪个文件整个交互都顺畅了。6. 常见问题排查与“不用MDI”的判断标准6.1 子窗口标题重复导致窗口菜单混乱如果用户打开的文件名重复比如两个目录下都有readme.txt窗口菜单就会出现同名项。此时最好在标题后追加唯一标识。我的做法是sub.setWindowTitle(f{file_path} - {os.path.basename(file_path)})或者用setAttribute(Qt.WA_DeleteOnClose)配合windowTitleChanged信号在内部维护每个子窗口的独一无二的ID。现实里这种重复文件名的情况很常见不加处理你会收到一堆一模一样的页签用户根本分不清哪个是哪个。6.2 关闭最后一个子窗口后菜单栏仍残留旧的窗口列表这个问题只在手动构建窗口菜单时出现用setWindowMenuEnabled自动填充不会踩到。手动构建时必须在subWindowActivated(None)的分支里清空整个菜单否则用户关掉所有窗口后菜单里还是存着一个个失效的Action点击会报异常。6.3 完成MDI子窗口最大化后其他窗口被遮挡这是MDI的固有行为不是Bug。一个子窗口最大化后QMdiArea的视口内只会显示该窗口。想要在这种情况下快速切换仍然可以用窗口菜单或者TabbedView模式。如果你的产品经理非要最大化后还能看到其他窗口的缩略预览那基本和MDI模型矛盾只能自己写自定义控件了。6.4 哪些场景真的不该用MDI再怎么说MDI好用也不能无脑套。我总结了几条硬性判断标准单文档场景你的应用每次只展示一个文档没有多开需求用QStackedWidget或QTabWidget更简单。移动端适配MDI是桌面时代的产品思路触屏下拖拽、悬停并不优雅Android设置默认桌面这种场景跟MDI完全沾不上边。多级嵌套不要让MDI里再嵌MDI。之前遇到有人想做一个“主看板里放多个子看板每个子看板又能展开多个窗口”的工具最后焦点管理和信号传递让人崩溃。项目经理还以为是卡顿问题其实是层级太深导致状态更新miss了信号。页面间强关联表单如果子窗口之间需要频繁、联动地修改同一个数据源MDI会放大并发冲突的概率。这种场景更适合单一列表页加详情抽屉。判断标准很简单同时打开多个文档并且文档间需要互相参照这是MDI的主场如果用户是线性流程地填写表单、点下一步别用MDI。最后分享一个小经验给QMdiArea设置背景水印也是个挺常见的小技巧很多人不知道可以在paintEvent里绘制文字或图标。比如工程名称、版本号画在MDI容器的背景上既不影响子窗口操作又能起到标识作用。但记得子窗口打开后背景水印会被遮挡这是正常现象不用纠结。如果有余力还可以考虑给每个QMdiSubWindow设置独立的图标配合setWindowIcon窗口菜单里的辨识度会高很多。我在实际使用中最大的体会是MDI这套API交付的是“窗口管理”的完整方案而不是一堆需要你拼装的零件。上手时先跑通一个最小Demo再逐步加功能会比一开始就铺开做要稳得多。
