PyQt6-第21讲-工程化三件套

发布时间:2026/11/6 3:32:53
PyQt6-第21讲-工程化三件套 第 21 讲 | 工程化三件套配置、日志、单实例本讲目标前面 20 讲都在做功能这一讲开始做产品。配置持久化、日志落盘、单实例锁、全局异常兜底——这四件事不做程序永远是能跑的脚本做了才像一个软件。配套代码cases/c21_app_boilerplate.py完整下载地址请看底部一、什么区别脚本和软件先看两个真实的反馈“你这个工具每次打开都要重新选一遍目录烦。”“它昨天还能用今天打开就闪一下就没了什么提示都没有。”第一条是配置不持久化第二条是日志和异常兜底缺失。这两条是能跑和能用的分水岭。一个工具功能再强如果每次打开都要重新填五个输入框用户第二天就不想开了。本讲四件事能力解决的问题关键 API配置持久化关掉再开一切还在QSettingssaveGeometry日志落盘用户报问题时你有东西可查loggingRotatingFileHandler异常兜底不让程序啪一下消失sys.excepthook单实例连点两次图标不能开两个QLockFile二、QSettings一行搞定跨平台配置classAppBoilerplate(QWidget):ORG,APPDemoCompany,PyQt6Boilerplatedef__init__(self)-None:self.settingsQSettings(self.ORG,self.APP)两个参数是组织名和应用名——它们决定了配置存在哪里平台实际存储位置Windows注册表HKEY_CURRENT_USER\Software\DemoCompany\PyQt6BoilerplateLinux~/.config/DemoCompany/PyQt6Boilerplate.confini 格式macOS~/Library/Preferences/com.DemoCompany.PyQt6Boilerplate.plist你一句平台判断都不用写Qt 帮你处理了三套后端。这是QSettings最大的价值——比自己写个 json 存到用户目录省事得多而且天然规避了程序装在C:\Program Files下没有写权限这种坑。读写value()的第二个参数是默认值defload_settings(self)-None:# value() 第二个参数是默认值 —— 第一次运行时用它的self.edit_work_dir.setText(self.settings.value(work/dir,str(DATA_DIR),typestr))self.combo_theme.setCurrentIndex(self.settings.value(ui/theme,0,typeint))self.spin_rows.setValue(self.settings.value(ui/rows,100,typeint))self.chk_autosave.setChecked(self.settings.value(ui/autosave,True,typebool))三个必须注意的点①type参数一定要给。不给的话value()返回的是QVariant或字符串self.settings.value(ui/rows,100)# ← 可能返回字符串 100self.spin_rows.setValue(100)# ← 直接 TypeError 崩溃②typebool有个历史坑。在 Windows 注册表里布尔值存成字符串true/false而在 ini 里存成true/false。老版本 PyQt 里value(k, True, typebool)拿到字符串false时会被判为True非空字符串为真。PyQt6 已经修好了这个问题但如果你要兼容 PySide 或者老代码别用typebool改用typeint存 0/1自己转。③ 键名用斜杠分组。work/dir、ui/theme、window/geometry——在 Windows 注册表里这会形成子键subkey在 ini 里会形成[work]段。两边都变整齐了。不要写成work_dir、ui_theme这样平铺配置一多就完全没法管理。保存defsave_settings(self)-None:self.settings.setValue(work/dir,self.edit_work_dir.text())self.settings.setValue(ui/theme,self.combo_theme.currentIndex())self.settings.setValue(ui/rows,self.spin_rows.value())self.settings.setValue(ui/autosave,self.chk_autosave.isChecked())ifself.chk_autosave.isChecked():self.settings.setValue(window/geometry,self.saveGeometry())# sync() 强制立刻落盘避免程序被强杀时丢失self.settings.sync()sync()不能省。QSettings为了性能会把写入缓存起来延迟落盘。如果用户点了保存、然后程序被任务管理器强杀缓冲区里的配置就丢了。sync()强制立刻写下去代价是一次磁盘 I/O在这个场景下完全可以接受。恢复窗口一行搞定# ★ 一行恢复整个窗口布局位置 尺寸 最大化状态geometryself.settings.value(window/geometry)ifgeometryisnotNone:self.restoreGeometry(geometry)配self.settings.setValue(window/geometry, self.saveGeometry())一行存、一行取窗口位置、大小、是否最大化全都记住了。saveGeometry()返回的是QByteArray里面打包了窗口的完整几何状态。QSettings能直接序列化QByteArray不需要你做任何编码转换。顺带说如果你只想存位置 大小而不想存最大化状态可以用saveGeometry的替代方案self.settings.setValue(pos, self.pos())。但绝大多数情况下saveGeometry才是用户想要的——用户上次是最大化用的下次当然也希望最大化打开。恢复默认defreset_settings(self)-None:self.settings.clear()self.settings.sync()self.logger.warning(配置已清空界面将恢复默认)self.load_settings()clear()清空整个应用的所有配置。注意清完要立刻sync()否则下次读到的还是缓存里的旧值用户会以为按钮没生效。三、日志为什么不能用printprint的三大问题打包成 exe 后没有控制台print的内容用户永远看不到用户报 bug 时你拿不到任何现场——“我点了那个按钮就崩了”哪个按钮什么错完全没法查没有时间戳、没有级别就算能看到也分不清哪条是哪次运行的换成logging并且同时输出到文件和控制台LOG_FILEDATA_DIR/output/第21讲/app.logdefsetup_logging(level:intlogging.INFO)-logging.Logger:配置日志一份写文件滚动一份留给界面通过自定义 HandlerLOG_FILE.parent.mkdir(parentsTrue,exist_okTrue)loggerlogging.getLogger(pyqt6app)logger.setLevel(level)logger.handlers.clear()fmtlogging.Formatter(%(asctime)s [%(levelname)s] %(message)s,%Y-%m-%d %H:%M:%S)# ① 滚动文件单文件最大 1MB保留 3 个备份永远不会把磁盘写满fhRotatingFileHandler(LOG_FILE,maxBytes1024*1024,backupCount3,encodingutf-8)fh.setFormatter(fmt)logger.addHandler(fh)# ② 控制台开发时看chlogging.StreamHandler()ch.setFormatter(fmt)logger.addHandler(ch)returnlogger三个关键设计①logger.handlers.clear()必须先清。因为logging.getLogger(pyqt6app)在同一个进程里返回的是同一个对象。如果这个函数被调用两次比如窗口重建而你不清旧的 handler每条日志就会被写两遍、三遍。这是个非常经典的 logging 坑。② 用RotatingFileHandler而不是FileHandler。长期运行的程序日志文件会无限增长——几个月后变成几个 GB。RotatingFileHandler在文件超过maxBytes时自动改名轮转app.log ← 当前 app.log.1 ← 上一个 app.log.2 app.log.3 ← backupCount3最多到这儿更老的被删掉磁盘占用有上界永不失控。这是任何会写日志的长期运行程序的标配。③encodingutf-8必须写。不写的话在中文 Windows 上默认用 GBK 编码。日志里出现→、★这类非 GBK 字符或者别的程序写进来的 UTF-8 内容就会抛UnicodeEncodeError——而日志系统抛异常你的程序就在记日志的时候崩了。顺带一提第 18、19 讲那些→箭头的日志就踩过这个。四、把日志接到界面上一个真实的坑工程化做得好日志应该同时出现在文件里和界面上——用户在界面上就能看到“刚才那一步做了什么”不用去翻文件。做法是自定义一个logging.Handler把消息通过 Qt 信号转发classQtLogHandler(QObject,logging.Handler):把 logging 的消息转发到 Qt 界面用信号保证在主线程更新 ★ 关键点**必须同时继承 QObject**。 pyqtSignal 只能定义在 QObject 的子类上只继承 logging.Handler 的话 连接信号时会直接报 cannot be converted to QObject。 两个父类的 __init__ 都要显式调用sip 生成的 QObject.__init__ 不会 自动往后接力漏掉 logging.Handler.__init__ 就没有 self.lock 多线程写日志时会随机丢日志。 messagepyqtSignal(str)def__init__(self,parentNone)-None:QObject.__init__(self,parent)logging.Handler.__init__(self)defemit(self,record:logging.LogRecord)-None:try:self.message.emit(self.format(record))exceptException:pass接进界面self.qt_handlerQtLogHandler()self.qt_handler.setFormatter(logging.Formatter(%(asctime)s [%(levelname)s] %(message)s,%H:%M:%S))self.qt_handler.message.connect(self.txt_log.appendPlainText)self.logger.addHandler(self.qt_handler)为什么一定要走信号这一道因为日志可能来自任何线程。第 16 讲的实时面板、第 8 讲的 Excel 合并后台线程里也可能要写日志。Qt 控件只能在主线程碰这是第 7 讲讲过的铁律。pyqtSignal.emit()是线程安全的从工作线程 emitQt 会自动把槽函数的执行排队到主线程。所以这一道信号转发不是画蛇添足而是跨线程安全地更新界面的唯一正确姿势。顺带一提这也解释了一个常见现象——打包成 exe 后为什么界面日志比print重要一百倍。exe 没有控制台但界面上那一块日志区就是用户的控制台。这里踩到的两个坑都是真实的坑 A只继承logging.Handler。classQtLogHandler(logging.Handler):# ← 漏了 QObjectmessagepyqtSignal(str)连接信号时直接报TypeError: QtLogHandler cannot be converted to PyQt6.QtCore.QObjectpyqtSignal必须定义在QObject的子类上。老老实实写class QtLogHandler(QObject, logging.Handler)。顺序上把QObject放前面。坑 B只调一个父类的__init__。多继承时想当然地写def__init__(self,parentNone):super().__init__(parent)# ← 只会走到 QObject.__init__sip 生成的QObject.__init__不会往后接力调super().__init__()所以logging.Handler.__init__根本不会执行。后果是self.lock、self.level、self.filters这些属性全都没初始化多线程写日志时会随机丢日志或报AttributeError——而且只在特定时序下复现极难排查。正确做法是两个父类都显式调QObject.__init__(self,parent)logging.Handler.__init__(self)规律只要一个类同时继承Qt 类 普通 Python 类两边的__init__都写出来不要依赖super()。五、异常兜底别让程序啪一下消失Python 里未捕获的异常会直接终止程序。在 GUI 程序里用户看到的是窗口瞬间消失没有任何提示——这是最糟糕的用户体验因为用户连该怎么向你描述问题都做不到。sys.excepthook是 Python 的全局异常钩子任何线程外未捕获的异常都会走到它definstall_excepthook(app)-None:loggerlogging.getLogger(pyqt6app)defhook(exc_type,exc_value,exc_tb):logger.critical(未捕获异常,exc_info(exc_type,exc_value,exc_tb))ifheadless()orQApplication.instance()isNone:# 无界面 / 事件循环未就绪只把信息打出来绝不弹窗importtraceback traceback.print_exception(exc_type,exc_value,exc_tb)returnQMessageBox.critical(None,程序异常,f{exc_type.__name__}:{exc_value}\n\n详细信息已写入日志。)sys.excepthookhook两个必须留意的地方①exc_info(exc_type, exc_value, exc_tb)—— 把堆栈完整写进日志。这是排查现场问题的唯一线索。少了它日志里只有一行“未捕获异常”你根本不知道是哪一行炸的。self.logger.error(计算出现除零错误,exc_infoTrue)# 在 except 块里可以简写 True在except块内部可以用简写exc_infoTruePython 会自动取当前异常在except块外面比如这里的 hook就必须传完整的元组。② 别无条件弹QMessageBox—— 这是我踩过的第二个真实的坑。我最初写的是defhook(exc_type,exc_value,exc_tb):logger.critical(未捕获异常,exc_info(exc_type,exc_value,exc_tb))QMessageBox.critical(None,程序异常,f{exc_type.__name__}:{exc_value})结果程序不再崩溃了但也永远不退出——它卡死了。原因如果异常发生在app.exec()之前比如窗口构造函数里出错事件循环还没跑起来。这时候弹模态框Qt 会临时起一个新的局部事件循环——而这个循环里没有任何东西能把它关掉关闭按钮的点击事件需要用户但程序已经卡在显示这个框这一行上了。于是永久挂起。这个 bug 的可怕之处在于它把崩溃换成了卡死看起来好像修复了。原本崩溃至少能让用户报告它闪退了现在用户只能报告它卡住了而且任务管理器里还占着资源。正确做法先判断事件循环是否就绪。ifheadless()orQApplication.instance()isNone:traceback.print_exception(...)# 只打印不弹窗returnQMessageBox.critical(...)# 只有在事件循环已经跑起来时才弹规律在任何比事件循环更早的位置弹模态框都要先确认事件循环已经就绪。启动阶段的错误提示宁可写进日志和 stderr也不要弹窗。六、单实例QLockFile比查进程名靠谱用户双击两次图标开了两个窗口两个都在处理同一批文件——这是灾难。Python 里常见的做法是遍历进程列表找同名进程但这个方案跨平台要写三套代码tasklist/ps/pgrep“同名进程不等于同一个程序”用户可能就想开两个来做不同的事强制结束时可能留下脏状态Qt 给了QLockFile专治这个问题defacquire_single_instance_lock()-QLockFile|None:尝试拿到单实例锁拿不到说明已经有一个在跑lock_pathPath(tempfile.gettempdir())/pyqt6_client_demo.locklockQLockFile(str(lock_path))lock.setStaleLockTime(30_000)# 上一个进程崩溃留下的锁30 秒后自动失效ifnotlock.tryLock(100):returnNonereturnlock原理QLockFile会在临时目录创建一个锁文件并写入当前进程的 PID以及主机名。tryLock()尝试创建如果文件已存在就去检查持有锁的那个 PID 还活着吗——活着 → 返回False确实有另一个实例在跑已经不在了→ 判定为僵尸锁抢过来自己用setStaleLockTime(30_000)是第二道保险如果持有者的状态无法判断比如跨主机、权限问题30 秒后强制认为锁已过期。拿到锁之后锁对象必须活着lockacquire_single_instance_lock()iflockisNone:print([WARN] 检测到程序已在运行本次启动直接退出。)sys.exit(0)lock这个变量一定要在模块级或main()里持有引用不能让它在函数返回后被垃圾回收——对象一被 GC锁文件就被删掉了单实例保护就失效了。这是QLockFile最常见的使用错误。七、正确的启动顺序这是本讲最后一块拼图。一个正经程序的启动顺序不能乱if__name____main__:# ★ 这是一个正经程序的完整启动流程顺序别乱# 建 app → 装异常兜底 → 抢单实例锁 → 建窗口 → 进事件循环appQApplication(sys.argv)install_excepthook(app)lockacquire_single_instance_lock()iflockisNone:print([WARN] 检测到程序已在运行本次启动直接退出。)ifnotheadless():QMessageBox.warning(None,提示,程序已经在运行了请勿重复打开。)sys.exit(0)winAppBoilerplate()win.resize(820,620)win.show()sys.exit(app.exec())为什么必须是这个顺序步骤为什么在这个位置①QApplication(sys.argv)任何 QWidget 之前必须先有它不然后面全都崩②install_excepthook必须在建窗口之前否则窗口构造函数里的异常兜不住③ 抢锁必须在建窗口之前否则已经有两个窗口在跑了才想起来要退出白折腾④ 建窗口 show()到这里所有前置条件都满足了⑤app.exec()最后才进事件循环顺序错了的典型症状把install_excepthook放在建窗口之后 → 窗口构造阶段的异常直接崩兜底完全无用把抢锁放在建窗口之后 → 用户看到窗口闪一下就消失体验极差把QApplication放在QWidget之后 →QWidget: Must construct a QApplication before a QWidget直接终止最后是closeEvent负责在退出时把配置落盘defcloseEvent(self,event)-None:# noqa: N802ifself.chk_autosave.isChecked():self.save_settings()self.logger.info(程序退出)super().closeEvent(event)closeEvent是QWidget提供的虚函数用户点关闭按钮时自动被调用。注意函数名是驼峰式的closeEventQt 的 C 风格别手贱改成close_event——那样它就不再是重写只是一个没人调用的小透明。-八、四个坑坑 1QSettings.value()没给type拿回来的是字符串setValue(100)直接TypeError。坑 2QtLogHandler只继承logging.HandlerpyqtSignal必须在QObject子类上连接信号时报cannot be converted to QObject。多继承时两个__init__都要显式调。坑 3excepthook里无条件弹模态框事件循环还没起来时模态框会自己起一个局部循环并永久挂起——比崩溃更糟。先判断QApplication.instance()和运行模式。坑 4QLockFile对象被垃圾回收局部变量lock函数返回就被 GC锁文件随之删除单实例失效。必须持有引用到程序退出。九、小结QSettings(组织, 应用)一行解决跨平台配置存储键名用a/b分组value()一定带typesync()强制落盘saveGeometry()/restoreGeometry()一行存/取整个窗口布局loggingRotatingFileHandler让日志永不失控logger.handlers.clear()防重复、encodingutf-8防编码崩溃自定义QtLogHandler必须同时继承QObject和logging.Handler两个__init__都显式调用用信号转发保证跨线程安全sys.excepthook兜住所有未捕获异常exc_info记全堆栈弹窗前先确认事件循环就绪QLockFile做单实例靠 PID 判断僵尸锁setStaleLockTime兜底对象必须持有引用启动顺序建 app → 装异常兜底 → 抢锁 → 建窗口 →exec()这四件事做完你的程序就从能跑的脚本变成可以交给同事用的软件了。下一讲最后一步——打包成 exe。让没有 Python 环境的人也能双击运行。会讲PyInstaller的几个关键参数、--onedir/--onefile怎么选以及资源路径那个绕不过去的问题sys._MEIPASS到底是什么。本讲是《PyQt6 客户端编程从入门到实战》专栏的第 21 讲。专栏共 22 讲、22 个可运行案例。完整下载地址git clone https://gitee.com/wuyikui555/pyqt6-client.git。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询