
上个月同事在群里发了个截图一个用 PySide6 写的报表导出工具用户点“导出”按钮后程序直接消失。他在自己机器上试了几十次怎么点都没问题打包发给用户就崩。问了一圈最后的结论是“用户机器上字体加载出了异常导致界面层崩溃而且你程序里没有日志我看不到崩溃前到底干了什么”。我之所以能这么快判断是因为他项目里所有输出都是 print——关键打包用的是窗口模式控制台都没有print 的内容根本不知道进了哪里。其实这不是个案。PySide6 项目写到后面窗口、线程、数据库、报表打印一多起来print 根本撑不住场面。我在这篇笔记里把“日志管理”单独拎出来写不是想讲 logging 模块的 API 手册而是想分享一套我已经在几个工具型应用里跑了一年多的实用方案以 Python 标准 logging 为骨干把控制台、文件、Qt 界面三个输出目标全部打通同时解决编码、滚动、异步写入、退出丢失这些只有长期运行才会真正碰到的问题。这套内容适合已经在用 PySide6 做实际开发、想正式替换掉 print 调试方式的人也适合刚接触框架、想从项目一开始就把日志基础设施铺好的人。下面直接给代码和踩坑记录尽量少说废话。1. 先想清楚PySide6 项目里的日志到底要解决什么问题1.1 GUI 程序没有控制台print 天然存在盲区PySide6 应用最普通的分发形态是 PyInstaller 打包成 exe。默认情况下如果用的是窗口模式Qt 程序不会创建控制台窗口sys.stdout和sys.stderr是无效的。你在本地 IDE 里跑得很欢print 打了一屏但打包后这些输出全部消失。更麻烦的是多线程场景。一个工具型应用里必然有后台线程做耗时任务就算你开了控制台多个线程同时打印时信息混成一团根本分不清先后。我之前在做一个数据采集工具时三个线程同时跑任务print 输出看着像信号打架——后来才意识到这不是程序 Bug是 print 本身的缺陷。1.2 logging 的四件套Logger、Handler、Formatter、Filterlogging 之所以强不是因为它有比 print 更好看的输出格式而是它把日志这件事拆成了四个可以独立插拔的部件Logger负责接收代码里的日志调用决定这条日志够不够级别输出Handler负责把日志送到指定的地方控制台、文件、网络都可以Formatter负责把 LogRecord 转成字符串时间、级别、线程名都能拼进去Filter负责更细粒度的过滤比如只让某个模块的 DEBUG 日志通过用生活化的类比说print 相当于你站在办公室中间大喊能不能被听到取决于有没有人在场logging 是前台登记员每条消息都会分类、盖章然后交给分拣员分别送到不同的信道上。整个链路是解耦的业务代码只需要写一句logger.info至于这行日志去了哪里完全由配置决定。1.3 在设计日志体系时先回答三个输出目标我每次给新项目配日志都会先列出三个必须满足的输出目标对应三种使用场景开发期控制台输出方便在 IDE 里看界面输出方便调 UI 时直接看到交互结果打包后文件输出因为用户机器上没有你的 IDE只有文件能留下证据线上排查文件日志加内存缓存崩溃前最后几秒的操作轨迹可以用来定位这三点想清楚后再落代码就不容易漏。很多教程只告诉你logging.basicConfig完事但basicConfig默认只往 stderr 写对 GUI 应用来说根本不满足上面三个目标中的任何一个。2. 为什么要拿 Python logging 当主干而不是直接上 Qt 的日志体系2.1 Qt 自带日志那套放在 PySide6 里确实尴尬老 C Qt 开发者习惯用qDebug() xxx输出日志Qt 内部也有QtMsgType这样的级别枚举看起来体系完整。但在 PySide6 里这套体系的体验明显打折。首先是字符串格式化C 的流式拼接换成 Python 后依然能用但没有 f-string 方便其次是 Qt 日志默认只输出到 stderr同样面临打包后看不到的问题最后是 PySide6 文档里关于QLoggingCategory的 Python 绑定资料非常少出了问题往往要翻 C 文档自己绕。我并不是说这套机制没用而是它的生态更贴近 C对于以 Python 业务代码为主的 PySide6 项目让 Qt 日志当主力会让日志代码和业务代码割裂成两套风格维护成本反而高。2.2 标准 logging 当主干核心是兼容性和通用性选 Python 标准 logging 当主干理由很朴素它是 Python 生态的标准意味着第三方库requests、SQLAlchemy、pandas 等内部产生的日志也能被统一收到同一个 logger 体系里。举个例子我的一个工具里依赖 requests 调接口requests 自带的 urllib3 logger 默认只在 WARNING 级别才输出。想调试接口异常时我根本不需要改代码只需在配置里加一句logging.getLogger(urllib3).setLevel(logging.DEBUG) logging.getLogger(requests).setLevel(logging.DEBUG)第三方库的日志就全部进入了我的日志文件。换成 Qt 日志体系这套联动能力基本没有。另一个理由是迁移性今天你用 PySide6明天换 PyQt6或者干脆做成命令行服务只要底层是 logging日志配置可以直接搬走业务代码一行都不用改。2.3 用 qInstallMessageHandler 把 Qt 消息桥接进 logging虽然选了 Python logging 当主干但不代表完全忽略 Qt 内部产生的日志。Qt 在底层会遇到一些 Python 捕获不到的警告比如显卡驱动不支持的 OpenGL 警告、字体加载失败、QSS 解析错误。这些消息如果不接住就真的在用户机器上石沉大海了。PySide6 提供了qInstallMessageHandler可以用一个 Python 函数接管所有 Qt 内部消息import logging from PySide6.QtCore import (qInstallMessageHandler, QtDebugMsg, QtInfoMsg, QtWarningMsg, QtCriticalMsg, QtFatalMsg) logger logging.getLogger(qt) def qt_message_handler(mode, context, message): level_map { QtDebugMsg: logging.DEBUG, QtInfoMsg: logging.INFO, QtWarningMsg: logging.WARNING, QtCriticalMsg: logging.ERROR, QtFatalMsg: logging.CRITICAL, } # context.file / context.line 是消息来源的文件和行号 logger.log(level_map.get(mode, logging.INFO), fQt[{context.file}:{context.line}] {message}) qInstallMessageHandler(qt_message_handler)这样 Qt 内部消息也并入了统一日志流到底输出到控制台、文件还是界面由全局配置决定。需要特别提醒QtFatalMsg 对应的通常是致命的库错误此时不要在 Python 层做太多恢复操作记录日志后按版本需求处理即可不要强行吞掉以免掩盖真正的崩溃现场。3. 把日志实时显示到 Qt 界面上信号槽桥接的关键实现3.1 自定义 Handler把日志转换为 Qt 信号界面日志窗是 PySide6 应用里非常常见的需求。但直接在 Handler 的 emit 里操作 QTextEdit 是绝对应该避免的——因为 emit 可能在任何线程被调用而 QWidget 只能在主线程安全操作一旦在非 GUI 线程里调用 append轻则闪烁异常重则随机崩溃。正确做法是让日志先变成 Qt 信号再由信号槽机制把日志消息投递回主线程。定义一个全局的 QObject 作为信号桥import logging from PySide6.QtCore import QObject, Signal class LogBridge(QObject): log_received Signal(str, int) # 消息文本, 日志级别 log_bridge LogBridge() class QtLogHandler(logging.Handler): def emit(self, record: logging.LogRecord) - None: try: msg self.format(record) log_bridge.log_received.emit(msg, record.levelno) except Exception: self.handleError(record)核心逻辑就这么多。这个LogBridge对象在模块导入时创建活在主线程logging 的 emit 被任意工作线程调用时Qt 信号会以跨线程队列连接的方式把消息安全地投递到接收者所在线程的事件循环中。3.2 界面侧怎么消费QPlainTextEdit 的行数上限和级别着色界面侧我用 QPlainTextEdit 而不是 QTextBrowser因为 QPlainTextEdit 对纯文本日志更轻量而且提供了setMaximumBlockCount自动淘汰旧行。如果日志持续输出几万行有行数上限的控件不会让内存无限膨胀滚动也不会越来越卡。from PySide6.QtWidgets import QPlainTextEdit from PySide6.QtGui import QColor, QTextCharFormat, QTextCursor import logging LEVEL_COLORS { logging.DEBUG: QColor(128, 128, 128), logging.INFO: QColor(40, 40, 40), logging.WARNING: QColor(200, 120, 0), logging.ERROR: QColor(200, 0, 0), logging.CRITICAL: QColor(180, 0, 0), } class LogViewer(QPlainTextEdit): def __init__(self, parentNone): super().__init__(parent) self.setReadOnly(True) self.setMaximumBlockCount(2000) log_bridge.log_received.connect(self.append_log) def append_log(self, message: str, levelno: int): cursor self.textCursor() cursor.movePosition(QTextCursor.MoveOperation.End) fmt QTextCharFormat() fmt.setForeground(LEVEL_COLORS.get(levelno, QColor(40, 40, 40))) cursor.insertText(message \n, fmt) self.setTextCursor(cursor) self.ensureCursorVisible()这里有两个细节值得说明用append()方法虽然简单但它会沿用控件的全局默认格式没法按日志级别着色所以我用 QTextCursor 逐条插入并给每条日志单独指定 QTextCharFormat。setMaximumBlockCount会保证块数不超过 2000最早的内容自动被丢弃不需要手动清空。3.3 高频日志下的渲染优化合并刷新才不卡上面这种逐条插入的做法日志量小的时候很干净。但一旦日志频率上来比如一个采集任务每秒输出几百条你会发现界面开始掉帧、交互卡顿。原因是每次 insertText 都触发一次 QPlainTextEdit 的局部重绘高频下重绘开销远大于日志本身。我的做法是加一个 QTimer 做合并刷新日志先攒到 list 里每 100ms 统一倒进界面一次class LogViewer(QPlainTextEdit): def __init__(self, parentNone): super().__init__(parent) self.setReadOnly(True) self.setMaximumBlockCount(2000) self._pending [] self._timer QTimer(self) self._timer.setInterval(100) self._timer.timeout.connect(self._flush_logs) self._timer.start() log_bridge.log_received.connect(self._on_log_received) def _on_log_received(self, message, levelno): self._pending.append((message, levelno)) if len(self._pending) 100: self._flush_logs() def _flush_logs(self): if not self._pending: return pending, self._pending self._pending, [] self.setUpdatesEnabled(False) try: for message, levelno in pending: cursor self.textCursor() cursor.movePosition(QTextCursor.MoveOperation.End) fmt QTextCharFormat() fmt.setForeground(LEVEL_COLORS.get(levelno, QColor(40, 40, 40))) cursor.insertText(message \n, fmt) self.setTextCursor(cursor) finally: self.setUpdatesEnabled(True)setUpdatesEnabled(False)在批量插入期间暂停所有重绘插入完成后一次性恢复效果立竿见影。拿每秒 500 条日志的监控任务实测合并刷新后界面基本不再卡顿。这个优化适合日志量大的工具普通应用日志量不大可以不做。4. 日志落盘细节编码、滚动策略与异步写入4.1 Windows 下的中文日志FileHandler 必须显式指定 utf-8logging 的 FileHandler 如果不指定 encoding在 Windows 上会用系统默认的 ANSI 编码一般是 GBK。如果你的应用日志里含有中文字符可能在某个用户机器上报UnicodeEncodeError。它不是必现的而是取决于那一条日志里有没有 GBK 编码不了的特殊字符排查起来很气人。从创建日志文件那一刻起任何 FileHandler 我都建议显式写上encodingutf-8file_handler logging.FileHandler(app.log, encodingutf-8)这样日志文件本身也是 UTF-8 编码后期用 VS Code、Notepad、编辑器打开都不会乱码。如果用户用 Windows 自带记事本打开可能会看到 UTF-8 内容正常显示新版记事本已支持但这不是你该担心的问题——确保写入过程稳定才是关键。4.2 轮转选择桌面工具优先按大小而不是按时间单文件日志无限增长会爆炸磁盘占满先不说打开和检索日志都会很痛苦。logging.handlers 提供了两种常用轮转方式from logging.handlers import RotatingFileHandler, TimedRotatingFileHandler # 按大小超过 5MB 自动切分保留最近 5 个 size_handler RotatingFileHandler( app.log, maxBytes5 * 1024 * 1024, backupCount5, encodingutf-8, ) # 按时间每天零点切分保留最近 7 天 time_handler TimedRotatingFileHandler( app.log, whenmidnight, backupCount7, encodingutf-8, )维度RotatingFileHandlerTimedRotatingFileHandler切分时机maxBytes 触发按 when 参数如每天零点备份文件名app.log, app.log.1...app.log 加带日期的备份名Windows 稳定性触发时机可控较稳定文件名重命名在某些占用场景可能异常适合场景桌面工具、数据量可控服务器按天归档我的经验是桌面 GUI 工具优先选按大小滚动。TimedRotatingFileHandler在 Windows 上偶尔会遇到“当前日志文件正被另一进程打开导致重命名失败”的情况虽然 logging 内部做了一些处理但实测在某些编辑器占用文件时轮转还是会异常。按大小滚动触发时机由 maxBytes 控制行为更稳定可预期对常驻桌面的应用来说已经足够。4.3 异步落盘QueueHandler QueueListener 让业务线程不再被 IO 拖累到这里日志链路已经能跑通但还有一个性能隐患默认情况下Logger 调用 Handler 的 emit 是同步的文件写入是阻塞操作。如果业务线程里密集写日志一条 write 可能几毫秒几十条下来业务线程的响应时间就被日志拖住了。解决思路是把日志产生和日志写入解耦。用 QueueHandler 把日志放进内存队列立刻返回后台用 QueueListener 监听队列在独立线程里统一做文件写入import queue from logging.handlers import QueueHandler, QueueListener log_queue queue.Queue(-1) queue_handler QueueHandler(log_queue) queue_handler.setFormatter(fmt) root.addHandler(queue_handler) file_handler logging.FileHandler(app.log, encodingutf-8) file_handler.setFormatter(fmt) listener QueueListener(log_queue, file_handler, respect_handler_levelTrue) listener.start()注意用了 QueueListener 之后原来挂在 root 上的 file_handler 要去掉否则同一份日志会被写两次。我在 setup_logging 函数里统一配置把真实 handler 只交给 QueueListener业务代码完全不用改。程序退出时关闭顺序很关键先把 queue_handler 从 root 上移除再调listener.stop()最后调logging.shutdown()。listener.stop()会等待 worker 线程把队列里已有的日志处理完再返回所以只要顺序对落盘日志一般不会丢。5. 日志系统自己也会出问题五类防不胜防的实战坑5.1 重复初始化导致日志翻倍我很早就踩过一次在 MainWindow 初始化里调了一个 setup_logging()后来代码重构窗口创建逻辑被放到一个 ModuleLoader 里初始化执行了两次于是 root logger 上挂了两个相同配置的 Handler。现象就是每条日志在文件里出现两次界面日志窗也重复显示排查了半天才反应过来。对策很简单在 setup_logging 开头加一个幂等判断if logging.getLogger().hasHandlers(): return如果确实需要在运行时调整配置也先清空再重建logging.getLogger().handlers.clear()。5.2 asctime 格式化在日志量上来后的性能消耗日志的 Formatter 里%(asctime)s是最耗性能的字段。因为 logging 默认对每条日志都会调用time.localtime()做一次本地时间转换这个转换本身不便宜。当日志频率到了每秒几百条时间格式化占整个日志处理的时间比例很高会让异步队列的消费速度都跟不上。如果你的日志只是用来排查问题不需要精确定位到毫秒可以把datefmt设为%Y-%m-%d %H:%M:%S去掉毫秒。如果连秒都不需要甚至可以考虑缓存时间。不过对大多数桌面应用来说去掉毫秒这一条优化已经足够。5.3 程序退出时日志丢失与崩溃前的最后轨迹如果程序是正常退出比如用户点关闭按钮你在 closeEvent 里没有做listener.stop()和logging.shutdown()那么 worker 线程可能还在处理队列进程就结束了最后几条日志常常会丢。更麻烦的是异常崩溃。Qt C 层段错误、显卡驱动崩溃Python 的异常钩子根本接不住日志文件里只会突然截断。为了应对这类问题我会额外维护一个内存环from collections import deque recent_logs deque(maxlen200)在 setup_logging 时再加一个只写 deque 的轻量 Handler然后在sys.excepthook里把 recent_logs 全部 dump 到 crash.log。这样即使程序在 Python 层崩溃也能留下最近 200 条日志作为“黑匣子”。5.4 Qt 消息转发过程里的递归风险用 qInstallMessageHandler 把所有 Qt 消息接进 logging 后有一个隐蔽风险如果 logging 的某个 Handler 内部又触发了 Qt 消息就会形成递归。举个例子我在日志窗控件里用 QTextCharFormat 设置颜色如果 Qt 字体引擎对某个字体处理失败发出了 warningwarning 又被桥接回 logginglogging 再调用 Handler 往界面写日志然后又触发字体 warning……这种循环一旦形成程序不是卡死就是栈溢出。我的解决办法是在 Qt handler 转发函数里加一个模块级标志位_in_qt_message False def qt_message_handler(mode, context, message): global _in_qt_message if _in_qt_message: return _in_qt_message True try: logger.log(level_map.get(mode, logging.INFO), fQt[{context.file}:{context.line}] {message}) finally: _in_qt_message False如果你没有在日志 Handler 里调用复杂 Qt 组件这个问题可能永远不会遇到但一旦遇到就是特别难复现的恶性 Bug。加个标志位成本很低建议直接带上。5.5 模块级 QObject 被垃圾回收后信号静默失效这是最隐蔽的一个坑。如果 LogBridge 不是在模块导入时创建而是在某个函数内部直接 new 一个函数执行完如果没有持续引用QObject 会被 Python 垃圾收集器回收。QObject 被回收后信号发射并不会立刻报错而是像什么都没发生一样——日志全部丢失界面静悄悄排查起来根本没方向。所以 LogBridge 这种全局唯一的 QObject 必须放在模块顶层或单例容器里保存确保生命周期和主线程一致。调试这种问题时可以在 connect 之后立刻手动 emit 一条测试日志如果界面没反应先检查这个对象是不是已经不存在了。我自己做 PySide6 项目的习惯是日志基础设施一定排在业务代码之前。每次新建工程先把 log_bridge.py 和 setup_logging() 放进去配置好文件输出、界面桥接和退出清理再开始写窗口。等你某天真的通过一份日志文件定位了一个只在用户机器上出现的随机崩溃你会觉得当初花这几个小时搭日志的时间完全值回票价。