Skip to content
正常

LogViewer 成品导览

sourcewidget/log-viewer/ related:只读文本组合控件递进链第 1 环 · 教程层 QPlainTextEdit 入门 / 日志进阶

LogViewer 是个只读滚动日志——后台服务、串口监视、构建输出里那种一刷到底、关键行高亮、永远不把内存撑爆的文本框。QPlainTextEdit 本身已经能塞文本,但「这条是 Info 那条是 Error、要染色、新行来了要跟着滚到底、攒够多少行得自动扔掉旧行」这些它都不主动管。我们用一层壳把这几件事收拢成 append(level, message) 一个入口,就成了一个开箱即用的日志控件。

本件和 status-led 不是一类——那是自绘,本件是组合:不重写 paintEvent,让 QPlainTextEdit 自己画,我们在它外面套壳 + 挂 QTextCursor。和 editable-table 同属组合派,只是换了一颗内核(QPlainTextEdit 替代 QTableWidget)。做日志输出的正确姿势就是让富文本引擎干富文本的活,我们自己只管「这一行该什么颜色、该不该裁掉」。

本篇是「成品导览」

想直接用成品 → 看这里(架构 / 决策 / 踩坑 / 怎么读)。 想自己从零搓出来 → 转 手搓手册

1. 它做什么

一个 AwesomeQt::LogViewer 控件:

  • 三级日志染色Level::Info(默认前景色) / Level::Warning(暗黄 QColor(180,120,0)) / Level::Error(红 QColor(200,0,0)),每条 append 时按级别套前景色,肉眼一眼分级别
  • 自动滚底:append 后默认滚到文档末尾让最新行可见;autoScroll 可关,关了 append 后不滚,方便往上翻看历史
  • 行数上限裁旧:超出 maxLines(默认 1000)时从文档头删旧行,防长时间运行内存无限膨胀
  • 时间戳前缀:默认每行前缀 [HH:MM:SS] [LEVEL] messageshowTimestamp 可关
  • 完整 Q_PROPERTYmaxLines / autoScroll / showTimestamp 三个属性全可被外部 / Designer 驱动

跑起来看一眼比读十行描述管用:

bash
cd widget && cmake -B build && cmake --build build
./build/log-viewer/demo/log_viewer_demo

打开后你会看到一个只读日志区加一排控制按钮。点 Append Info / Warning / Error 三色行依次落下来;点 Burst 200 Info 连发两百条 Info,超过默认 maxLines=1000 时顶部旧行被裁掉,底部始终是最新那条「心跳 #199」,总行数收敛在上限附近、不爆;勾掉 Auto Scroll 再 append,新行虽然加进去了但不滚底,光标停在原处方便往上翻。

2. 架构总览

类关系

LogViewer 是组合而非继承:它拥有一个 QPlainTextEdit 当只读显示内核,自己只持有几个行为开关成员。所有的着色、滚底、裁旧都不重写 view 的方法,而是在 append 里用临时 QTextCursor 直接操纵 view 的 document。

LogViewer 和 view_ 是单向持有关系:view_ 在构造里 new QPlainTextEdit(this),由 this 父对象的对象树托管生命周期,控件销毁时 view 跟着销毁,不需要手动 delete。view 没有反向引用 LogViewer——它根本不知道外面套了壳,这层组合是纯单向的。

文件职责

文件职责
include/log_viewer.h接口:Level 枚举 + Q_PROPERTY 三件套 + 公有 API + 私有着色/裁旧辅助声明
src/log_viewer.cpp实现:只读 view 初始化 + append 染色插入 + 裁旧 + 行为开关
demo/log_viewer_window.cpp演示:三级按钮 + 连发压测 + 清空 + autoScroll 切换 + 行数回显

一次 append 怎么走完着色与裁旧

重点在 append 的三段式:先按 level 套前景色 insertText(着色落进 document),再 trimOldBlocks 按 document 自己的 blockCount 判定要不要裁旧,最后看 auto_scroll_ 决定滚不滚底。着色是写进 document 的富文本格式,不是临时绘制——所以滚动、选中、复制都不丢颜色,view 重绘也不用我们操心。

3. 关键设计决策

① 组合 QPlainTextEdit,不继承也不自绘。 LogViewer 继承的是 QWidget,把 QPlainTextEdit 当私有成员 new 出来挂 parent、塞进零边距 QVBoxLayout。不重写 paintEvent,让 view 自己画(src/log_viewer.cpp:18)。日志控件要的是「能滚、能选中、能复制、列对齐」,QPlainTextEdit 这几样天生齐全,自己重画纯是重复造轮子还容易丢剪贴板行为。代价是少了一层绘制控制权,但对纯文本日志这种需求,没什么是 view 自带的富文本引擎干不了的。

colorForLevel 对 Info 返回无效 QColor,用默认前景色而非硬编码黑/白。 最初的想法是 Info 也写死一个颜色。但硬编码黑或白都很危险:深色主题下白字看得清、浅色主题下黑字才看得清。解法是 Info 让 colorForLevel 返回一个 QColor()(默认构造,isValid()==false),append 里只对有效色调 setForegroundsrc/log_viewer.cpp:47),Info 行就用 view 的默认前景色,跟着主题走。这是让控件在任意配色下都不出错的关键一笔。

③ 着色靠 QTextCursor + QTextCharFormat,按插入设色而非事后批量改。 QPlainTextEdit 支持每次 insertText 带一个 QTextCharFormat,所以着色和写文本是同一步完成的(src/log_viewer.cpp:54)。相比「先 append 普通文本、再回头遍历 setFormat」的方案,这种写一步染一步的方式不需要额外记住行号区间、也不触发额外的文档变更通知。文末非空时先补一个 \n 保证新行独立成块(src/log_viewer.cpp:51),这点和裁旧的「按块删」直接挂钩。

④ 裁旧用 document 原生的 blockCount,不自维护计数器。 QPlainTextEdit 的 document 自带块计数,view_->blockCount() 随时反映当前行数(src/log_viewer.cpp:155)。裁旧就比它和 max_lines_,超了多少从文档头连选删除(src/log_viewer.cpp:163)。这比自己在 append 里维护一个行计数器省心得多——计数器还得和实际 document 同步、clear 之后还得清零,全是 bug 温床。让文档自己当唯一的真相源,我们的逻辑只是它的旁路观察者。

setMaxLines 改上限后立即 trimOldBlocks 一次,当场收敛。 默认上限 1000 行时已经攒了 800 行,这时外部把 maxLines 调到 500——如果不主动裁,旧行要等到下一次 append 才会被裁,期间 document 里会留着超出新上限的内容。解法是 setMaxLines 在 clamp 完、emit 之前先调一次 trimOldBlockssrc/log_viewer.cpp:93),保证上限一改、内容当场符合新约束。这种「setter 即生效」的语义让控件状态始终自洽,外部不用记着「改完还得手动清一次」。

4. 怎么读这份 code

按这个顺序读,最快建立心智:

  1. include/log_viewer.h 的 Level 枚举与 Q_PROPERTY(36-37 行、29-32 行)——先看「对外暴露哪些级别和属性开关」
  2. 构造函数src/log_viewer.cpp:18)——QPlainTextEdit 怎么 new、只读 / 等宽字体 / NoWrap 怎么设、布局怎么挂
  3. appendsrc/log_viewer.cpp:32)——核心入口,盯拼装行、moveEnd 补 \n、setForeground 插入、裁旧、滚底这五段
  4. colorForLevelsrc/log_viewer.cpp:129)——为什么 Info 返回无效色、Warning / Error 的固定色值
  5. trimOldBlockssrc/log_viewer.cpp:154)——裁旧的块级删除逻辑,盯 Start + 连选 NextBlock 的循环
  6. setMaxLinessrc/log_viewer.cpp:85)——clamp + 即时裁旧 + emit 的三段

入口:demo/main.cppdemo/log_viewer_window.cpp 跑起来,对照读。重点把 Burst 200 Info 这个压测点跑一遍,盯着窗口底部的行数回显和顶部旧行的消失,看裁旧到底是怎么收敛的。

5. 踩坑

#现象原因后果解法
cpp 编译报 expected type-specifier before 'QFontDatabase''QFontDatabase' has not been declared构造里用 QFontDatabase::systemFont(QFontDatabase::FixedFont) 设等宽字体,但 cpp 顶部漏引 <QFontDatabase>编译不过cpp 顶部补 #include <QFontDatabase>src/log_viewer.cpp:9)。Q_OBJECT 类的 cpp 不像头文件那样顺手引字体类,新加字体相关代码要单独引
等宽字体没生效,日志列还是参差不齐用了 QFont("Monospace") 这种按名字取字体的写法,系统上未必有这个名字的字体;或干脆没设字体不同长度的时间戳/级别对不齐,阅读体验差QFontDatabase::systemFont(QFontDatabase::FixedFont) 取系统首选等宽字体(src/log_viewer.cpp:23),不依赖具体字体名
Info 行在深色主题下看不见 / 浅色主题下太刺眼colorForLevel 给 Info 也硬编码了一个颜色(黑或白),和主题冲突级别色在某个主题下不可读Info 让 colorForLevel 返回无效 QColor()append 里只对有效色 setForeground,Info 行用 view 默认前景色(src/log_viewer.cpp:129 / src/log_viewer.cpp:47
裁旧删多了行或删错位置自己维护一个行计数器,忘了 clear 之后清零、或和 document 实际块数漂移日志被多删或裁不干净,行数显示对不上不自维护计数器,裁旧直接判 view_->blockCount()src/log_viewer.cpp:155),让 document 当唯一真相源
append 后没滚到底,最新行看不到autoScroll 默认开但滚底代码漏调,或只调了 moveCursor(End) 没调 ensureCursorVisible()用户得手动往下拉才能看到新日志滚底两件套一起调:moveCursor(QTextCursor::End) + ensureCursorVisible()src/log_viewer.cpp:60
新行没独立成块,裁旧时按块删会把两行黏一起删文末非空时直接 insertText,新行没和旧行用 \n 分隔裁旧行数对但内容错乱insertText 前判断 !cursor.atStart() 时先补一个 \nsrc/log_viewer.cpp:51),保证新行独立成块
setMaxLines 调小后旧行没被裁,要等下次 appendsetter 只改了 max_lines_ 没主动裁旧控件状态不自洽,document 里残留超限内容setMaxLines clamp 完、emit 前先调一次 trimOldBlockssrc/log_viewer.cpp:93

6. 官方文档


这套机制(组合 QPlainTextEdit + QTextCursor 着色 + document blockCount 裁旧 + 行为开关 Q_PROPERTY)不是 LogViewer 专属——它就是「一个带格式与上限的只读流式文本控件」的标准范式。后面做带过滤、带搜索高亮、带多通道(stdout/stderr 分流)的高级日志控件时,view 这层原样复用,只是在外面再套过滤与路由逻辑。想自己搓?手搓手册带你从一只只读 QPlainTextEdit 一行行搓到这个成品。

AwesomeQt v0.2.0-58-gde7eeb4-dirty · de7eeb4 · 2026-07-03