资讯动态

Qt日志系统实战:同时实现打印保存、窗口显示与网络传输

发布时间:2026/10/14 14:04:58 来源:尧图企业网站定制
简介这是一套基于 Qt 的打印日志系统源码面向需要为桌面应用搭建调试与运行日志模块的 Qt 开发者尤其适合希望摆脱默认输出、实现日志自主管理的初中级工程师。它借助 qInstallMessageHandler 接管 Qt 消息处理把原本输出到标准输出或调试器的打印信息统一收集并支持日志文件保存、过期自动删除、窗口实时显示以及网络传输等多种去向可用于客户端调试、远程排障与运行状态监控等场景。资源包共 17 个文件约 15KB以 6 个 cpp 与 5 个 h 承载日志核心逻辑、配置管理与界面交互3 个 ui 提供日志窗口与配置面板另有 pro 工程文件及若干工程配置结构紧凑、便于直接嵌入现有项目。目前已有 6506 人学习下载。读者可从中获得一套可复用的日志处理框架理解消息拦截、分级输出、文件轮转与过期清理的实现思路并参考界面与网络传输部分完成二次扩展。1. 日志系统不是 printf 堆砌Qt 场景下要同时喂饱窗口、磁盘和网络很多做 Qt 桌面端或工控上位机的朋友日志一开始都是qDebug()满天飞等到现场出问题要查的时候才发现控制台关了、日志没落盘、网络那头的运维平台啥也没收到。标题里这套「打印日志保存 窗口显示 网络传输」的组合本质是把一条日志同时分发给三个消费者本地文件负责事后追溯UI 窗口负责现场盯屏网络通道负责集中汇聚。三者对实时性、吞吐、线程安全的要求完全不同硬用一套qDebug重定向去糊迟早翻车。这套方案适合谁做设备上位机、测试工装、医疗/工业 HMI 的 Qt 开发者尤其是那种「客户现场没人会看命令行、但出了问题必须能远程定位」的场景。下面我按「先立住架构、再动手复现、最后讲坑」的顺序拆开讲代码基于 Qt 5/6 通用写法Qt 6 里个别 API 有差异我会单独标注。2. 三条输出链路怎么分工qInstallMessageHandler 是唯一入口2.1 为什么必须走 qInstallMessageHandler 而不是自己包一层宏自己写MY_LOG()宏当然可以但代价是你丢掉了 Qt 内部所有模块的日志——QNetworkAccessManager的警告、QPainter的报错、插件加载失败的信息这些恰恰是排查现场问题时最值钱的部分。qInstallMessageHandler是 Qt 提供的全局钩子所有qDebug/qInfo/qWarning/qCritical/qFatal都会流经它包括 Qt 框架自己打的日志。代价是它是全局单例一个进程只能装一个 handler而且 handler 里做的事必须足够快否则会拖慢整个 UI 线程。所以正确姿势是handler 里只做「格式化 投递」真正的写文件、发网络全部异步化。// loghandler.cpp #include QDateTime #include QThread #include QFile #include QTextStream static QtMessageHandler g_prevHandler nullptr; void myMessageHandler(QtMsgType type, const QMessageLogContext ctx, const QString msg) { // 1. 组装带时间戳、级别、文件行号的完整行 const char *level INFO; switch (type) { case QtDebugMsg: level DEBUG; break; case QtInfoMsg: level INFO; break; case QtWarningMsg: level WARN; break; case QtCriticalMsg: level ERROR; break; case QtFatalMsg: level FATAL; break; } QString line QString([%1][%2][%3:%4] %5) .arg(QDateTime::currentDateTime().toString(yyyy-MM-dd hh:mm:ss.zzz)) .arg(level) .arg(ctx.file ? ctx.file : unknown) .arg(ctx.line) .arg(msg); // 2. 投递给异步消费者绝不在这里直接写盘 LogDispatcher::instance()-post(line, type); // 3. fatal 必须保留默认行为否则程序不会终止 if (type QtFatalMsg g_prevHandler) { g_prevHandler(type, ctx, msg); } }逻辑说明ctx.file和ctx.line在 Release 构建下默认是空的需要在.pro里加DEFINES QT_MESSAGELOGCONTEXT否则你只能看到日志内容看不到出处这是第一个高频坑。LogDispatcher::post必须是线程安全的后面会讲它怎么实现。参数说明时间格式zzz是毫秒工控场景建议保留因为多设备并发时毫秒级顺序很关键level用固定宽度字符串而不是枚举是为了让日志文件能被grep直接过滤。2.2 三条链路各自的消费者模型链路消费者实时性要求阻塞风险推荐实现文件保存磁盘低可延迟秒级磁盘满/慢盘独立线程 队列窗口显示UI 线程高人眼感知高频日志刷屏信号槽 批量刷新网络传输远端服务中可批量断网/对端慢独立线程 重连队列三条链路共用一个投递入口但各自有独立的缓冲队列。文件链路用QThread跑一个消费者循环网络链路同理UI 链路则通过Qt::QueuedConnection把日志行发回主线程。这样即使磁盘卡住或网络断了UI 也不会卡日志也不会丢在内存允许范围内。2.3 用环形缓冲 批量刷盘把磁盘 IO 压下去直接每条日志flush()一次在机械盘或 eMMC 上每秒几百条就能把 IO 打满。常见做法是攒够 N 条或超过 T 毫秒再刷一次。// logdispatcher.cpp void LogDispatcher::fileLoop() { QFile file(m_logPath); if (!file.open(QIODevice::Append | QIODevice::Text)) { // 落盘失败要退化成 stderr不能静默吞掉 fprintf(stderr, open log file failed: %s\n, qPrintable(m_logPath)); return; } QTextStream out(file); QVectorQString batch; batch.reserve(kBatchSize); while (m_running) { QString line; if (m_fileQueue.tryPop(line, 200)) { // 200ms 超时 batch.append(line); } // 攒够 64 条 或 距上次刷盘超过 1 秒就落盘 if (batch.size() kBatchSize || m_flushTimer.elapsed() kFlushIntervalMs) { for (const auto l : batch) out l \n; out.flush(); file.flush(); batch.clear(); m_flushTimer.restart(); } } }逻辑说明tryPop带超时是为了让线程在没日志时也能周期性检查刷盘条件避免最后几条日志卡在缓冲里。kBatchSize取 64、kFlushIntervalMs取 1000 是经验值SSD 上可以调到 256/2000机械盘建议别超过 128。参数说明QIODevice::Append保证多进程写同一文件时不会互相覆盖但内容可能交错多进程场景建议按 PID 分文件QTextStream默认 UTF-8如果现场是 GBK 环境要在main里QTextCodec::setCodecForLocale。3. 窗口显示日志别让高频日志把 UI 拖死3.1 QPlainTextEdit 而不是 QTextEdit显示日志只有一个选择QPlainTextEdit。QTextEdit是富文本引擎每插一行都要重新布局日志一多直接卡成 PPT。QPlainTextEdit针对纯文本做了优化配合setMaximumBlockCount可以自动丢弃旧行内存不会无限涨。// logwindow.cpp LogWindow::LogWindow(QWidget *parent) : QWidget(parent) { m_view new QPlainTextEdit(this); m_view-setReadOnly(true); m_view-setMaximumBlockCount(5000); // 只保留最近 5000 行 m_view-setLineWrapMode(QPlainTextEdit::NoWrap); m_view-setStyleSheet(font-family: Consolas, monospace; font-size: 12px;); auto *layout new QVBoxLayout(this); layout-addWidget(m_view); // 跨线程投递LogDispatcher 在子线程UI 必须在主线程更新 connect(LogDispatcher::instance(), LogDispatcher::lineReady, this, LogWindow::appendLine, Qt::QueuedConnection); } void LogWindow::appendLine(const QString line, int level) { // 按级别着色用 HTML 会拖慢这里用 QTextCharFormat 更轻 QTextCharFormat fmt; if (level QtCriticalMsg) fmt.setForeground(Qt::red); else if (level QtWarningMsg) fmt.setForeground(QColor(#c07000)); QTextCursor cur(m_view-document()); cur.movePosition(QTextCursor::End); cur.insertText(line \n, fmt); m_view-verticalScrollBar()-setValue( m_view-verticalScrollBar()-maximum()); }逻辑说明Qt::QueuedConnection是关键LogDispatcher的lineReady信号从子线程发出Qt 会自动排队到 UI 线程执行appendLine不需要你手动加锁。setMaximumBlockCount让控件自己管理内存比手动删行靠谱。参数说明5000 行是显示上限不是日志总量磁盘文件不受影响NoWrap避免长日志换行导致滚动条乱跳现场排查时横向滚动比折行好读。3.2 高频日志的合并刷新如果设备每秒打上千条日志逐条insertText依然会卡。做法是在LogWindow里再攒一层收到信号先塞进m_pending用一个 50ms 的QTimer批量插入。void LogWindow::onLineReady(const QString line, int level) { m_pending.append({line, level}); if (!m_flushTimer.isActive()) m_flushTimer.start(50); // 50ms 内的日志合并成一次 UI 更新 } void LogWindow::flushPending() { if (m_pending.isEmpty()) return; QTextCursor cur(m_view-document()); cur.movePosition(QTextCursor::End); cur.beginEditBlock(); // 关键合并成一次撤销记录减少重绘 for (const auto p : m_pending) { QTextCharFormat fmt; if (p.level QtCriticalMsg) fmt.setForeground(Qt::red); cur.insertText(p.line \n, fmt); } cur.endEditBlock(); m_pending.clear(); }逻辑说明beginEditBlock/endEditBlock把多次插入合并成一个编辑操作Qt 只重绘一次这是高频日志不卡的玄学所在。50ms 是人眼感知不到的延迟但能把 1000 次重绘压成 20 次。参数说明m_flushTimer设setSingleShot(true)每次有新日志重新 start保证空闲时不会空转。4. 网络传输日志断网、粘包、对端慢一个都不能少4.1 用 QTcpSocket 还是 QUdpSocket日志传输优先选 TCP。UDP 虽然快但丢包后你根本不知道丢了哪条排查时反而添乱。TCP 的粘包问题在日志场景很好解决每条日志一行用\n分隔接收端按行切分即可。// lognetclient.cpp void LogNetClient::connectToServer(const QString host, quint16 port) { m_socket new QTcpSocket(this); connect(m_socket, QTcpSocket::connected, this, [this]{ m_connected true; // 连上后把断网期间攒的日志补发 while (!m_backlog.isEmpty()) { m_socket-write(m_backlog.dequeue().toUtf8() \n); } }); connect(m_socket, QTcpSocket::disconnected, this, [this]{ m_connected false; m_reconnectTimer.start(3000); // 3 秒后重连 }); // 错误也要触发重连否则断网后永远不恢复 connect(m_socket, QOverloadQAbstractSocket::SocketError::of(QTcpSocket::error), this, [this](QAbstractSocket::SocketError){ m_reconnectTimer.start(3000); }); m_socket-connectToHost(host, port); }逻辑说明m_backlog是断网期间的本地队列上限建议设 10000 条超了就丢最旧的并记一条「日志溢出」——总比内存爆掉强。重连定时器要幂等重复 start 不会叠加。参数说明重连间隔 3000ms 是折中值太短会在对端没起来时疯狂重试太长恢复慢生产环境建议加指数退避第一次 1s、第二次 2s、封顶 30s。4.2 发送端不能阻塞 UI 线程QTcpSocket::write本身是异步的但如果你在 UI 线程里循环写大量日志底层缓冲满了会触发waitForBytesWritten之类的阻塞。正确做法是把网络发送也放到独立线程或者至少用bytesWritten信号做流控。void LogNetClient::onLineReady(const QString line) { if (!m_connected) { if (m_backlog.size() kMaxBacklog) m_backlog.enqueue(line); return; } // 底层缓冲超过 256KB 就先攒着等 bytesWritten 再发 if (m_socket-bytesToWrite() kHighWaterMark) { m_backlog.enqueue(line); return; } m_socket-write(line.toUtf8() \n); } void LogNetClient::onBytesWritten(qint64) { // 缓冲降下来了把积压的补发 while (m_connected !m_backlog.isEmpty() m_socket-bytesToWrite() kHighWaterMark) { m_socket-write(m_backlog.dequeue().toUtf8() \n); } }逻辑说明bytesToWrite是 Qt 内部发送缓冲的字节数超过高水位就停止写入避免内存被日志撑爆。bytesWritten信号在缓冲被内核收走后触发是天然的流控点。参数说明kHighWaterMark取 256KB 适合局域网跨公网建议降到 64KBkMaxBacklog取 10000 条按每条 200 字节算约 2MB 内存可接受。4.3 接收端按行切分的服务端片段服务端用QTcpServer接每个连接维护一个QByteArray缓冲按\n切。void LogServer::onReadyRead() { auto *sock qobject_castQTcpSocket*(sender()); m_buffers[sock].append(sock-readAll()); int idx; while ((idx m_buffers[sock].indexOf(\n)) 0) { QByteArray line m_buffers[sock].left(idx); m_buffers[sock].remove(0, idx 1); if (!line.isEmpty()) handleLogLine(QString::fromUtf8(line)); } // 防止对端不发换行导致缓冲无限涨 if (m_buffers[sock].size() kMaxLineLen) { m_buffers[sock].clear(); sock-disconnectFromHost(); } }逻辑说明粘包的本质是 TCP 是字节流一次readyRead可能收到半条或多条。按\n切分是最稳的前提是发送端保证每条日志以\n结尾且日志内容里不含裸\n有的话要转义。参数说明kMaxLineLen取 64KB超过说明对端协议不对或恶意直接断开。5. 避坑与排查这五个坑我踩过不止一次5.1 日志文件在 Release 下没有文件名和行号现象Debug 构建日志带main.cpp:42Release 构建全是unknown:0。 原因QT_MESSAGELOGCONTEXT在 Release 下默认关闭编译器把ctx.file/line优化掉了。 解决.pro里加DEFINES QT_MESSAGELOGCONTEXTCMake 里加target_compile_definitions(app PRIVATE QT_MESSAGELOGCONTEXT)。注意这会略微增大二进制体积可接受。5.2 程序退出时最后几条日志丢失现象点了关闭按钮日志文件里少了最后几行。 原因LogDispatcher的消费者线程还在跑主线程已经析构了队列或者QCoreApplication::quit后线程被强杀。 解决在main里aboutToQuit信号里调用LogDispatcher::instance()-shutdown()shutdown 里设置m_runningfalse然后m_thread-wait(3000)等消费者把队列排空再退出。别用terminate()。5.3 UI 窗口在日志量大时假死现象设备一报警日志刷屏界面点不动。 原因逐条insertText触发大量重绘或者lineReady信号用了DirectConnection导致子线程直接操作 UI。 解决确认连接类型是Qt::QueuedConnection跨线程默认就是但显式写更保险加 50ms 批量刷新setMaximumBlockCount限制行数。如果还卡检查是不是在appendLine里做了字符串拼接等重活。5.4 网络断开后日志再也不发了现象拔网线再插上服务端收不到新日志。 原因只监听了disconnected没监听error。某些情况下 socket 进入错误状态但不发disconnected。 解决error信号里也启动重连定时器并且重连前先abort()清理旧 socket 状态。重连成功后记得补发m_backlog。5.5 多线程同时写日志导致文件内容交错现象日志文件里出现半行拼接比如两条日志的字符混在一起。 原因多个线程各自持有QFile写同一个文件或者消费者线程没做串行化。 解决所有写盘操作收敛到唯一的消费者线程其他线程只往队列投递。如果确实要多进程写按 PID 分文件或者用系统级文件锁Qt 没直接提供得调平台 API。6. 进阶把日志级别做成运行时可调再给每条日志加个追踪 ID前面搭的是骨架真正让这套系统在现场好用还得加两个东西运行时可调的日志级别和贯穿一次请求的追踪 ID。先说级别。硬编码的qDebug在生产环境要么太多要么太少常见做法是定义一个全局的LogLevel阈值handler 里先比对再决定是否投递。关键是这个阈值要能通过网络或配置文件热更新不用重启程序。// 全局阈值用原子变量保证多线程读写安全 static QAtomicInt g_minLevel(QtDebugMsg); void myMessageHandler(QtMsgType type, const QMessageLogContext ctx, const QString msg) { if (type g_minLevel.loadAcquire()) return; // 低于阈值直接丢零开销 // ... 后续投递逻辑 } // 收到网络下发的配置命令时调用 void setLogLevel(int level) { g_minLevel.storeRelease(level); qInfo() log level changed to level; }逻辑说明QAtomicInt的loadAcquire/storeRelease保证跨线程可见性比加锁轻。阈值判断放在 handler 最前面低于阈值的日志连字符串拼接都省了这是高频日志场景的性能关键。参数说明级别用 Qt 的枚举值QtDebugMsg0到QtFatalMsg4数值越大越严重。生产环境一般设QtInfoMsg排查时临时降到QtDebugMsg。再说追踪 ID。一次设备操作可能触发几十条日志散落在文件、UI、网络三处没有 ID 根本串不起来。做法是用QThreadStorage存一个当前线程的 trace IDhandler 里取出来拼进日志行。static QThreadStorageQString g_traceId; // 在业务入口设置比如收到一条设备指令时 void beginTrace(const QString id) { g_traceId.setLocalData(id); } void endTrace() { g_traceId.setLocalData(QString()); } // handler 里拼接 QString tid g_traceId.localData(); QString line QString([%1][%2][%3] %4) .arg(timestamp).arg(level).arg(tid.isEmpty() ? - : tid).arg(msg);逻辑说明QThreadStorage每个线程独立不会串号。业务代码在入口beginTrace(QUuid::createUuid().toString())出口endTrace()中间所有日志自动带上同一个 ID。服务端按 ID 聚合一次操作的完整链路一目了然。参数说明trace ID 用 UUID 前 8 位就够太长占日志宽度如果业务本身有请求号直接复用请求号更好省得再生成。最后说一个验证方法别等现场出问题才测。本地写个压测小工具开 8 个线程每秒各打 1000 条日志跑 10 分钟然后检查三件事——日志文件行数是否等于预期不丢、UI 是否还能响应点击不卡、服务端收到的行数是否一致不丢包。我一般会在压测时故意拔一次网线、把磁盘写满一次看降级逻辑是否按预期工作。这套自检跑通现场基本就稳了。血泪经验是日志系统本身出问题时最难查因为它没有日志。所以LogDispatcher内部的关键失败文件打不开、队列溢出、重连失败一定要往stderr也打一份别只往自己的队列里塞。这个习惯帮我省过好几次后悔药。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑