Horse3D 游戏引擎研发笔记(七):Clydesdale——从流式日志到多输出订阅
- Bilibili 同步视频
- 一、为什么日志需要单独成模块
- 二、整体架构
- 三、入口层:为什么 Clydesdale.h 里不用宏
-
- 3.1 自然的写法:宏
- 3.2 踩坑:`#define error()` 与 Qt 虚函数冲突
- 3.3 解法:命名空间内联函数 + using 声明
- 3.4 命名约定
- 四、流式写入器:HorseLogStream
-
- 4.1 类结构
- 4.2 RAII 提交
- 4.3 格式修饰符
- 4.4 通用模板:让 Qt 数学类型直接流式输出
- 4.5 移动语义与拷贝禁用
- 五、Logger:静态管理器
-
- 5.1 线程安全
- 5.2 默认输出
- 5.3 传统 API 与流式 API 的关系
- 六、输出层:ILogOutput 与两种实现
-
- 6.1 抽象接口
- 6.2 ConsoleLogOutput:转发 Qt
- 6.3 FileLogOutput:带时间戳和级别
- 6.4 自定义输出:GUI 控制台面板
- 七、Hequ:Mongolian 下的轻量替代
- 八、CMake 与部署
- 九、设计取舍
- 十、当前成果
- 十一、下一步
- 项目仓库
目标:拆解 Horse3D 的日志子系统 Clydesdale。从一个 info() << "…" 调用出发,梳理 RAII 流式写入器、Logger 静态管理器、ILogOutput 输出抽象、ConsoleLogOutput 与 FileLogOutput 两种实现,并交代清楚"为什么 Clydesdale.h 里故意不使用 #define 宏"这一踩坑点。最后顺带说明 Mongolian/Hequ 模块作为轻量替代的定位。
Bilibili 同步视频
Horse3D 游戏引擎研发笔记(七):Clydesdale——从流式日志到多输出订阅
一、为什么日志需要单独成模块
前六篇笔记依次建起了渲染线程、材质系统、多 Pass 管线、光照、编辑器外壳和组件接口。随着模块变多,一个很现实的问题浮现出来:每个模块出问题时,靠什么定位?
qDebug 当然能用,但它有几个明显短板:
Horse3D 把日志抽到独立的 Clydesdale 模块(位于 Baggage/Clydesdale/),用一套很小的接口解决上述问题。模块名沿用项目"以马种命名"的约定——Clydesdale(克莱兹代尔马)是挽马,承担"拖运日志"的职责再合适不过。
二、整体架构
Clydesdale 的内部结构可以看作三层:入口层 → 管理层 → 输出层。入口层提供 debug()/info()/warning()/error() 四个便捷函数,管理层负责维护输出列表和线程安全,输出层定义抽象接口和具体实现。
#mermaid-svg-d54Hbq8xgt9Q9Mii{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-d54Hbq8xgt9Q9Mii .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-d54Hbq8xgt9Q9Mii .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-d54Hbq8xgt9Q9Mii .error-icon{fill:#552222;}#mermaid-svg-d54Hbq8xgt9Q9Mii .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-d54Hbq8xgt9Q9Mii .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-d54Hbq8xgt9Q9Mii .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-d54Hbq8xgt9Q9Mii .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-d54Hbq8xgt9Q9Mii .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-d54Hbq8xgt9Q9Mii .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-d54Hbq8xgt9Q9Mii .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-d54Hbq8xgt9Q9Mii .marker{fill:#333333;stroke:#333333;}#mermaid-svg-d54Hbq8xgt9Q9Mii .marker.cross{stroke:#333333;}#mermaid-svg-d54Hbq8xgt9Q9Mii svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-d54Hbq8xgt9Q9Mii p{margin:0;}#mermaid-svg-d54Hbq8xgt9Q9Mii .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-d54Hbq8xgt9Q9Mii .cluster-label text{fill:#333;}#mermaid-svg-d54Hbq8xgt9Q9Mii .cluster-label span{color:#333;}#mermaid-svg-d54Hbq8xgt9Q9Mii .cluster-label span p{background-color:transparent;}#mermaid-svg-d54Hbq8xgt9Q9Mii .label text,#mermaid-svg-d54Hbq8xgt9Q9Mii span{fill:#333;color:#333;}#mermaid-svg-d54Hbq8xgt9Q9Mii .node rect,#mermaid-svg-d54Hbq8xgt9Q9Mii .node circle,#mermaid-svg-d54Hbq8xgt9Q9Mii .node ellipse,#mermaid-svg-d54Hbq8xgt9Q9Mii .node polygon,#mermaid-svg-d54Hbq8xgt9Q9Mii .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-d54Hbq8xgt9Q9Mii .rough-node .label text,#mermaid-svg-d54Hbq8xgt9Q9Mii .node .label text,#mermaid-svg-d54Hbq8xgt9Q9Mii .image-shape .label,#mermaid-svg-d54Hbq8xgt9Q9Mii .icon-shape .label{text-anchor:middle;}#mermaid-svg-d54Hbq8xgt9Q9Mii .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-d54Hbq8xgt9Q9Mii .rough-node .label,#mermaid-svg-d54Hbq8xgt9Q9Mii .node .label,#mermaid-svg-d54Hbq8xgt9Q9Mii .image-shape .label,#mermaid-svg-d54Hbq8xgt9Q9Mii .icon-shape .label{text-align:center;}#mermaid-svg-d54Hbq8xgt9Q9Mii .node.clickable{cursor:pointer;}#mermaid-svg-d54Hbq8xgt9Q9Mii .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-d54Hbq8xgt9Q9Mii .arrowheadPath{fill:#333333;}#mermaid-svg-d54Hbq8xgt9Q9Mii .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-d54Hbq8xgt9Q9Mii .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-d54Hbq8xgt9Q9Mii .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-d54Hbq8xgt9Q9Mii .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-d54Hbq8xgt9Q9Mii .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-d54Hbq8xgt9Q9Mii .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-d54Hbq8xgt9Q9Mii .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-d54Hbq8xgt9Q9Mii .cluster text{fill:#333;}#mermaid-svg-d54Hbq8xgt9Q9Mii .cluster span{color:#333;}#mermaid-svg-d54Hbq8xgt9Q9Mii div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-d54Hbq8xgt9Q9Mii .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-d54Hbq8xgt9Q9Mii rect.text{fill:none;stroke-width:0;}#mermaid-svg-d54Hbq8xgt9Q9Mii .icon-shape,#mermaid-svg-d54Hbq8xgt9Q9Mii .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-d54Hbq8xgt9Q9Mii .icon-shape p,#mermaid-svg-d54Hbq8xgt9Q9Mii .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-d54Hbq8xgt9Q9Mii .icon-shape .label rect,#mermaid-svg-d54Hbq8xgt9Q9Mii .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-d54Hbq8xgt9Q9Mii .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-d54Hbq8xgt9Q9Mii .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-d54Hbq8xgt9Q9Mii :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
输出层
流式写入器
管理层(Logger)
入口层(Clydesdale.h)
析构时提交
debug()
info()
warning()
error()
Logger 静态单例
logDebug() / logInfo()logWarning() / logError()返回 HorseLogStream
debug(msg) / info(msg) …传统字符串 API
HorseLogStreamRAII 临时对象operator<< 累积
ILogOutput 抽象接口
ConsoleLogOutput转发 qDebug 系列
FileLogOutput带时间戳与级别
自定义输出例如 GUI 控制台面板
图 1:Clydesdale 三层架构。 入口层返回 RAII 流对象,流对象析构时把完整消息交给 Logger,Logger 遍历所有 ILogOutput 完成分发。传统字符串 API 绕过流对象,直接走 Logger::write。
模块依赖关系非常轻:Clydesdale 只链接 Qt6::Core,被 Baggage 聚合后供所有上层模块使用。
#mermaid-svg-ctG99vtPCxmfKW2D{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-ctG99vtPCxmfKW2D .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-ctG99vtPCxmfKW2D .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-ctG99vtPCxmfKW2D .error-icon{fill:#552222;}#mermaid-svg-ctG99vtPCxmfKW2D .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-ctG99vtPCxmfKW2D .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-ctG99vtPCxmfKW2D .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-ctG99vtPCxmfKW2D .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-ctG99vtPCxmfKW2D .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-ctG99vtPCxmfKW2D .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-ctG99vtPCxmfKW2D .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-ctG99vtPCxmfKW2D .marker{fill:#333333;stroke:#333333;}#mermaid-svg-ctG99vtPCxmfKW2D .marker.cross{stroke:#333333;}#mermaid-svg-ctG99vtPCxmfKW2D svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-ctG99vtPCxmfKW2D p{margin:0;}#mermaid-svg-ctG99vtPCxmfKW2D .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-ctG99vtPCxmfKW2D .cluster-label text{fill:#333;}#mermaid-svg-ctG99vtPCxmfKW2D .cluster-label span{color:#333;}#mermaid-svg-ctG99vtPCxmfKW2D .cluster-label span p{background-color:transparent;}#mermaid-svg-ctG99vtPCxmfKW2D .label text,#mermaid-svg-ctG99vtPCxmfKW2D span{fill:#333;color:#333;}#mermaid-svg-ctG99vtPCxmfKW2D .node rect,#mermaid-svg-ctG99vtPCxmfKW2D .node circle,#mermaid-svg-ctG99vtPCxmfKW2D .node ellipse,#mermaid-svg-ctG99vtPCxmfKW2D .node polygon,#mermaid-svg-ctG99vtPCxmfKW2D .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-ctG99vtPCxmfKW2D .rough-node .label text,#mermaid-svg-ctG99vtPCxmfKW2D .node .label text,#mermaid-svg-ctG99vtPCxmfKW2D .image-shape .label,#mermaid-svg-ctG99vtPCxmfKW2D .icon-shape .label{text-anchor:middle;}#mermaid-svg-ctG99vtPCxmfKW2D .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-ctG99vtPCxmfKW2D .rough-node .label,#mermaid-svg-ctG99vtPCxmfKW2D .node .label,#mermaid-svg-ctG99vtPCxmfKW2D .image-shape .label,#mermaid-svg-ctG99vtPCxmfKW2D .icon-shape .label{text-align:center;}#mermaid-svg-ctG99vtPCxmfKW2D .node.clickable{cursor:pointer;}#mermaid-svg-ctG99vtPCxmfKW2D .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-ctG99vtPCxmfKW2D .arrowheadPath{fill:#333333;}#mermaid-svg-ctG99vtPCxmfKW2D .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-ctG99vtPCxmfKW2D .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-ctG99vtPCxmfKW2D .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ctG99vtPCxmfKW2D .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-ctG99vtPCxmfKW2D .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ctG99vtPCxmfKW2D .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-ctG99vtPCxmfKW2D .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-ctG99vtPCxmfKW2D .cluster text{fill:#333;}#mermaid-svg-ctG99vtPCxmfKW2D .cluster span{color:#333;}#mermaid-svg-ctG99vtPCxmfKW2D div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-ctG99vtPCxmfKW2D .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-ctG99vtPCxmfKW2D rect.text{fill:none;stroke-width:0;}#mermaid-svg-ctG99vtPCxmfKW2D .icon-shape,#mermaid-svg-ctG99vtPCxmfKW2D .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ctG99vtPCxmfKW2D .icon-shape p,#mermaid-svg-ctG99vtPCxmfKW2D .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-ctG99vtPCxmfKW2D .icon-shape .label rect,#mermaid-svg-ctG99vtPCxmfKW2D .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ctG99vtPCxmfKW2D .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-ctG99vtPCxmfKW2D .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-ctG99vtPCxmfKW2D :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
Mongolian(可选)
Ferghana(编辑器)
Sinohorse(引擎核心)
Baggage(基础库聚合)
仅依赖 Qt6::Core
独立实现不依赖 Clydesdale
Clydesdale
Diligencier / Percheron / Mustang
Dragon
Balikun
FerghanaApplication
FerghanaEditor
Hequ轻量日志替代
Qt
图 2:模块依赖关系。 Clydesdale 位于最底层,被引擎核心和编辑器同时使用。Hequ 作为可选模块独立存在,不依赖 Clydesdale,定位见第七节。
三、入口层:为什么 Clydesdale.h 里不用宏
入口层只有四个函数,写在一个不到 40 行的头文件 [Clydesdale.h](file:///d:/WorkSpace/softwarer-horse/Baggage/Clydesdale/Clydesdale.h) 里:
namespace Horse {
inline HorseLogStream debug() { return Logger::logDebug(); }
inline HorseLogStream info() { return Logger::logInfo(); }
inline HorseLogStream warning() { return Logger::logWarning(); }
inline HorseLogStream error() { return Logger::logError(); }
} // namespace Horse
using Horse::debug;
using Horse::info;
using Horse::warning;
using Horse::error;
看似平淡无奇,但这个文件的设计决策值得专门一节来讨论。
3.1 自然的写法:宏
最直觉的实现是参考 qDebug() 的宏方案:
#define debug() HorseLogStream(LogLevel::Debug)
#define info() HorseLogStream(LogLevel::Info)
// …
调用方代码完全一致:info() << "…"。但这个方案在 Horse3D 里翻过一次车。
3.2 踩坑:#define error() 与 Qt 虚函数冲突
Qt 基类 QIODevice / QFileDevice 拥有一个名为 error() 的虚函数:
// QFileDevice 的真实声明
virtual QFileDevice::FileError error() const;
一旦某段代码先 #include "Clydesdale.h",再 #include <QFile>,预处理器会把 QFileDevice::error() 的声明替换成:
virtual QFileDevice::FileError HorseLogStream(LogLevel::Error)() const;
MSVC 立刻报 C3254:“类包含显式重写,但并非继承自接口”。这个错误的可怕之处在于:报错位置远离真正的元凶(Clydesdale.h),而是在任何包含 QFile/QIODevice 的 Qt 头文件处。
3.3 解法:命名空间内联函数 + using 声明
把宏改成 Horse 命名空间内的 inline 函数,再用 using 把它们暴露到全局:
namespace Horse {
inline HorseLogStream error() { return Logger::logError(); }
}
using Horse::error;
这样 error() 仍然是一个真正的标识符(不是预处理符号),不会污染 Qt 头文件。调用方代码 error() << "…" 完全不变,但编译期类型检查正常工作。
这个改动也呼应了 [Clydesdale.h](file:///d:/WorkSpace/softwarer-horse/Baggage/Clydesdale/Clydesdale.h) 文件顶部的注释——“故意不使用 #define 宏”,并把原因写在注释里,避免后续维护者"优化"回宏方案。
3.4 命名约定
根据项目约束(详见 [project_memory.md](file:///c:/Users/Administrator/.trae-cn/memory/projects/-d-WorkSpace-softwarer-horse–p2-9cf0d952531cc0ea9a56/project_memory.md)):
- 日志命名空间必须是 Horse,不是 Clydesdale。
- 宏名必须是 debug/info/warning/error,不加 horse 前缀——horseInfo() 这种写法被否决,因为它破坏了与 Qt 原生 API 的一致性。
- 宏定义位置必须是 Clydesdale.h,且该头文件所在 CMake target 的 target_include_directories 必须为 PUBLIC,否则消费方 include 找不到。
四、流式写入器:HorseLogStream
HorseLogStream 是 Clydesdale 最有特色的部分。它参考 Qt 的 QDebug,用 RAII 临时对象实现"流式累积 + 析构提交"。
4.1 类结构
#mermaid-svg-QWowVLSMZVZHobuU{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-QWowVLSMZVZHobuU .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-QWowVLSMZVZHobuU .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-QWowVLSMZVZHobuU .error-icon{fill:#552222;}#mermaid-svg-QWowVLSMZVZHobuU .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-QWowVLSMZVZHobuU .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-QWowVLSMZVZHobuU .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-QWowVLSMZVZHobuU .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-QWowVLSMZVZHobuU .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-QWowVLSMZVZHobuU .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-QWowVLSMZVZHobuU .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-QWowVLSMZVZHobuU .marker{fill:#333333;stroke:#333333;}#mermaid-svg-QWowVLSMZVZHobuU .marker.cross{stroke:#333333;}#mermaid-svg-QWowVLSMZVZHobuU svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-QWowVLSMZVZHobuU p{margin:0;}#mermaid-svg-QWowVLSMZVZHobuU g.classGroup text{fill:#9370DB;stroke:none;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:10px;}#mermaid-svg-QWowVLSMZVZHobuU g.classGroup text .title{font-weight:bolder;}#mermaid-svg-QWowVLSMZVZHobuU .cluster-label text{fill:#333;}#mermaid-svg-QWowVLSMZVZHobuU .cluster-label span{color:#333;}#mermaid-svg-QWowVLSMZVZHobuU .cluster-label span p{background-color:transparent;}#mermaid-svg-QWowVLSMZVZHobuU .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-QWowVLSMZVZHobuU .cluster text{fill:#333;}#mermaid-svg-QWowVLSMZVZHobuU .cluster span{color:#333;}#mermaid-svg-QWowVLSMZVZHobuU .nodeLabel,#mermaid-svg-QWowVLSMZVZHobuU .edgeLabel{color:#131300;}#mermaid-svg-QWowVLSMZVZHobuU .edgeLabel .label rect{fill:#ECECFF;}#mermaid-svg-QWowVLSMZVZHobuU .label text{fill:#131300;}#mermaid-svg-QWowVLSMZVZHobuU .labelBkg{background:#ECECFF;}#mermaid-svg-QWowVLSMZVZHobuU .edgeLabel .label span{background:#ECECFF;}#mermaid-svg-QWowVLSMZVZHobuU .classTitle{font-weight:bolder;}#mermaid-svg-QWowVLSMZVZHobuU .node rect,#mermaid-svg-QWowVLSMZVZHobuU .node circle,#mermaid-svg-QWowVLSMZVZHobuU .node ellipse,#mermaid-svg-QWowVLSMZVZHobuU .node polygon,#mermaid-svg-QWowVLSMZVZHobuU .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-QWowVLSMZVZHobuU .divider{stroke:#9370DB;stroke-width:1;}#mermaid-svg-QWowVLSMZVZHobuU g.clickable{cursor:pointer;}#mermaid-svg-QWowVLSMZVZHobuU g.classGroup rect{fill:#ECECFF;stroke:#9370DB;}#mermaid-svg-QWowVLSMZVZHobuU g.classGroup line{stroke:#9370DB;stroke-width:1;}#mermaid-svg-QWowVLSMZVZHobuU .classLabel .box{stroke:none;stroke-width:0;fill:#ECECFF;opacity:0.5;}#mermaid-svg-QWowVLSMZVZHobuU .classLabel .label{fill:#9370DB;font-size:10px;}#mermaid-svg-QWowVLSMZVZHobuU .relation{stroke:#333333;stroke-width:1;fill:none;}#mermaid-svg-QWowVLSMZVZHobuU .dashed-line{stroke-dasharray:3;}#mermaid-svg-QWowVLSMZVZHobuU .dotted-line{stroke-dasharray:1 2;}#mermaid-svg-QWowVLSMZVZHobuU #compositionStart,#mermaid-svg-QWowVLSMZVZHobuU .composition{fill:#333333!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-QWowVLSMZVZHobuU #compositionEnd,#mermaid-svg-QWowVLSMZVZHobuU .composition{fill:#333333!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-QWowVLSMZVZHobuU #dependencyStart,#mermaid-svg-QWowVLSMZVZHobuU .dependency{fill:#333333!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-QWowVLSMZVZHobuU #dependencyStart,#mermaid-svg-QWowVLSMZVZHobuU .dependency{fill:#333333!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-QWowVLSMZVZHobuU #extensionStart,#mermaid-svg-QWowVLSMZVZHobuU .extension{fill:transparent!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-QWowVLSMZVZHobuU #extensionEnd,#mermaid-svg-QWowVLSMZVZHobuU .extension{fill:transparent!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-QWowVLSMZVZHobuU #aggregationStart,#mermaid-svg-QWowVLSMZVZHobuU .aggregation{fill:transparent!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-QWowVLSMZVZHobuU #aggregationEnd,#mermaid-svg-QWowVLSMZVZHobuU .aggregation{fill:transparent!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-QWowVLSMZVZHobuU #lollipopStart,#mermaid-svg-QWowVLSMZVZHobuU .lollipop{fill:#ECECFF!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-QWowVLSMZVZHobuU #lollipopEnd,#mermaid-svg-QWowVLSMZVZHobuU .lollipop{fill:#ECECFF!important;stroke:#333333!important;stroke-width:1;}#mermaid-svg-QWowVLSMZVZHobuU .edgeTerminals{font-size:11px;line-height:initial;}#mermaid-svg-QWowVLSMZVZHobuU .classTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-QWowVLSMZVZHobuU .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-QWowVLSMZVZHobuU .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-QWowVLSMZVZHobuU :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
owns
HorseLogStream
-m_stream : Stream
+HorseLogStream(level)
+~HorseLogStream()
+operator=(HorseLogStream&&)
+noquote() : HorseLogStream&
+quote() : HorseLogStream&
+nospace() : HorseLogStream&
+space() : HorseLogStream&
+operator<<(QString) : HorseLogStream&
+operator<<(int) : HorseLogStream&
+operator<<(double) : HorseLogStream&
+operator<<(const void*) : HorseLogStream&
+operator<<(const T&) : HorseLogStream&
-maybeSpace()
Stream
+buffer : QString
+ts : QTextStream
+level : LogLevel
+space : bool
+quote : bool
4.2 RAII 提交
构造时分配一个内部 Stream(持有 QString 缓冲和 QTextStream),析构时把缓冲交给 Logger::write():
HorseLogStream::~HorseLogStream()
{
if (!m_stream)
return;
m_stream->ts.flush();
// 末尾如果多了一个空格,砍掉,避免 "msg " 这种尾巴。
if (m_stream->space && m_stream->buffer.endsWith(QLatin1Char(' ')))
m_stream->buffer.chop(1);
Logger::write(m_stream->level, m_stream->buffer);
delete m_stream;
}
这带来一个非常优雅的特性:调用方完全不需要显式 flush 或 commit。整个日志调用就是一条语句:
info() << "cnt" << cnt << "update" << elapsedMs << "ms";
语句结束时临时对象析构,消息自动提交。
4.3 格式修饰符
参考 QDebug,提供四个链式修饰符:
| noquote() | 字符串不加引号 | 开(不加引号) |
| quote() | 字符串加引号 | |
| nospace() | 关闭自动空格插入 | |
| space() | 写入一个空格并重开自动空格 | 开(自动空格) |
默认配置刻意与 QDebug 默认值不同:QDebug 默认加引号、加空格,而 HorseLogStream 默认 不加引号、加空格。原因是在日志场景下,字符串内容通常是路径、变量名、错误描述,加引号反而降低可读性。
实战例子(来自 [FerghanaApplication.cpp](file:///d:/WorkSpace/softwarer-horse/Ferghana/Ferghana/FerghanaApplication.cpp) 的国际化加载逻辑):
Horse::info() << "i18n" << prefix
<< "loaded from fallback:" << fallbackDir << "/" << filename;
Horse::warning() << "i18n" << prefix << "translation NOT FOUND for" << localeName
<< "(tried:" << primaryDir << "and" << fallbackDir << ")";
Horse::error() << "i18n" << prefix << "installTranslator FAILED";
输出形如:
i18n app loaded from fallback: ./translations/ Ferghana_zh_CN.qm
i18n app translation NOT FOUND for zh_CN (tried: ./i18n and ./translations)
i18n app installTranslator FAILED
4.4 通用模板:让 Qt 数学类型直接流式输出
除了为常见类型(QString、int、double、const void* 等)显式重载 operator<<,HorseLogStream 还提供了一个模板重载:
template<typename T>
HorseLogStream &operator<<(const T &value)
{
if (m_stream)
m_stream->ts << value;
maybeSpace();
return *this;
}
只要消费方链接了 QtGui,QVector2D/QVector3D/QVector4D/QMatrix4x4/QQuaternion 等 Qt 类型就能直接流式输出。这对渲染引擎调试至关重要——光位、相机位置、变换矩阵都能一行打出来。
4.5 移动语义与拷贝禁用
HorseLogStream 显式删除拷贝构造和拷贝赋值,只保留移动版本:
HorseLogStream(HorseLogStream &&other) noexcept;
HorseLogStream &operator=(HorseLogStream &&other) noexcept;
HorseLogStream(const HorseLogStream &) = delete;
HorseLogStream &operator=(const HorseLogStream &) = delete;
原因是 RAII 提交依赖"唯一拥有 Stream 指针"的不变量。如果允许拷贝,两个对象析构时都会尝试 delete m_stream,触发 double-free。移动赋值甚至要在覆盖旧值前先把旧 Stream 提交并删除,避免漏日志:
HorseLogStream &HorseLogStream::operator=(HorseLogStream &&other) noexcept
{
if (this != &other) {
if (m_stream) {
m_stream->ts.flush();
Logger::write(m_stream->level, m_stream->buffer);
delete m_stream;
}
m_stream = other.m_stream;
other.m_stream = nullptr;
}
return *this;
}
info() 等入口函数返回的就是右值临时对象,调用链上的 << 都在临时对象上完成,最后由临时对象析构提交。整个生命周期没有拷贝。
五、Logger:静态管理器
Logger 是一个纯静态类,承担两个职责:维护输出列表和分发消息。
class CLYDESDALE_EXPORT Logger final
{
public:
using Output = std::shared_ptr<ILogOutput>;
static void setOutputs(std::vector<Output> outputs);
static void addOutput(Output output);
static void clearOutputs();
static void write(LogLevel level, const QString &message);
// 传统字符串 API
static void debug(const QString &message);
static void info(const QString &message);
static void warning(const QString &message);
static void error(const QString &message);
// 流式 API(返回 RAII 写入器)
static HorseLogStream logDebug();
static HorseLogStream logInfo();
static HorseLogStream logWarning();
static HorseLogStream logError();
};
5.1 线程安全
输出列表用 std::mutex 保护。write() 在加锁期间拷贝一份 outputs,锁外遍历分发,避免某个慢速 ILogOutput 阻塞其他线程的 addOutput 调用:
void Logger::write(LogLevel level, const QString &message)
{
std::vector<Output> outputs;
{
std::lock_guard<std::mutex> lock(loggerMutex);
outputs = loggerOutputs; // 拷贝
}
for (const Output &output : outputs)
output->write(level, message);
}
这里有一个工程细节值得注意:拷贝的是 shared_ptr,引用计数自增是原子的,因此即使其他线程同时销毁某个 ILogOutput,本线程持有的 shared_ptr 仍然有效。
5.2 默认输出
Logger 在匿名命名空间里持有一个默认输出列表,初始化时就挂了一个 ConsoleLogOutput:
namespace {
std::mutex loggerMutex;
std::vector<Logger::Output> loggerOutputs{
std::make_shared<ConsoleLogOutput>()
};
} // namespace
这意味着即使什么配置都不做,Clydesdale 也会把日志打到 qDebug 系列通道,开发阶段无需任何初始化代码就能用。
5.3 传统 API 与流式 API 的关系
传统 API Logger::info(const QString&) 是流式 API 的语法糖。两者最终都走 Logger::write(),区别只是前者跳过了 HorseLogStream 临时对象:
void Logger::info(const QString &message)
{
write(LogLevel::Info, message);
}
HorseLogStream Logger::logInfo()
{
return HorseLogStream(LogLevel::Info);
}
项目内部两种风格并存:
- 流式:参数较多、需要拼接变量时,例如 info() << "FPS" << fps << "drawCalls" << count。
- 传统:消息本身就是完整字符串(如 tr("Ferghana editor ready")),用 Logger::info(tr(…)) 更简洁。
实战例子(来自 [FerghanaEditor.cpp](file:///d:/WorkSpace/softwarer-horse/Ferghana/Ferghana/FerghanaEditor.cpp)):
Horse::Logger::info(tr("Ferghana editor ready"));
Horse::Logger::info(tr("Run started"));
六、输出层:ILogOutput 与两种实现
6.1 抽象接口
输出层只定义一个极简接口 [ILogOutput](file:///d:/WorkSpace/softwarer-horse/Baggage/Clydesdale/ILogOutput.h):
enum class LogLevel {
Debug,
Info,
Warning,
Error
};
class CLYDESDALE_EXPORT ILogOutput
{
public:
virtual ~ILogOutput() = default;
virtual void write(LogLevel level, const QString &message) = 0;
};
只有一个虚函数。任何想接收日志的目标——控制台、文件、网络、GUI 面板——都只需实现这个接口并向 Logger 注册。
6.2 ConsoleLogOutput:转发 Qt
[ConsoleLogOutput](file:///d:/WorkSpace/softwarer-horse/Baggage/Clydesdale/ConsoleLogOutput.cpp) 直接转发到 Qt 的 qDebug/qInfo/qWarning/qCritical,并显式 .noquote():
void ConsoleLogOutput::write(LogLevel level, const QString &message)
{
switch (level) {
case LogLevel::Debug: qDebug().noquote() << message; break;
case LogLevel::Info: qInfo().noquote() << message; break;
case LogLevel::Warning: qWarning().noquote() << message; break;
case LogLevel::Error: qCritical().noquote() << message; break;
}
}
这样日志会自动走 Qt 的 qSetMessagePattern 和消息处理器,与 qt.network、qt.gui 等模块的原生日志格式一致,方便用统一工具收集。
6.3 FileLogOutput:带时间戳和级别
[FileLogOutput](file:///d:/WorkSpace/softwarer-horse/Baggage/Clydesdale/FileLogOutput.cpp) 把日志写到文件,每行加 ISO 时间戳和级别前缀:
const QString line = QStringLiteral("%1 [%2] %3\\n")
.arg(QDateTime::currentDateTime().toString(Qt::ISODateWithMs),
QString::fromLatin1(levelName(level)),
message);
file.write(line.toUtf8());
输出形如:
2026-08-22T14:32:05.123 [INFO] i18n app loaded from fallback: ./translations/ Ferghana_zh_CN.qm
2026-08-22T14:32:05.124 [WARNING] i18n app translation NOT FOUND for zh_CN
工程细节上做了三件事:
6.4 自定义输出:GUI 控制台面板
ILogOutput 的扩展性在 Ferghana 编辑器里被用上了。编辑器把控制台面板的 logOutput() 挂到 Logger,让所有日志同时显示在 GUI 上:
Horse::Logger::addOutput(m_console->logOutput());
这种"插件式输出"让 GUI 控制台与文件日志、控制台日志完全解耦——ConsolePanel 只需要实现 ILogOutput::write(),把消息追加到 QPlainTextEdit 即可。
七、Hequ:Mongolian 下的轻量替代
[Mongolian/Hequ](file:///d:/WorkSpace/softwarer-horse/Mongolian/Hequ/Logger.h) 提供了一个独立的、与 Clydesdale 并存的 Logger:
namespace Hequ {
enum class Level { Debug, Info, Warning, Error };
class HEQU_EXPORT Logger final
{
public:
static void write(Level level, const QString &message);
static void debug(const QString &message);
static void info(const QString &message);
static void warning(const QString &message);
static void error(const QString &message);
};
} // namespace Hequ
实现极其简单,直接转发到 qDebug/qInfo/qWarning/qCritical,没有流式 API,也没有 ILogOutput 抽象。
它的存在有两个原因:
Hequ 与 Clydesdale 在 API 上不兼容(命名空间不同、Level 枚举不同、没有流式),因此不要在同一模块里混用。需要可扩展日志的项目应直接用 Clydesdale。
八、CMake 与部署
Clydesdale 是 SHARED 库,CMake 配置非常简洁:
add_library(${PROJECT_NAME} SHARED
clydesdale_global.h
ILogOutput.h
HorseLogStream.h
HorseLogStream.cpp
ConsoleLogOutput.h
ConsoleLogOutput.cpp
FileLogOutput.h
FileLogOutput.cpp
Logger.h
Logger.cpp
Clydesdale.h
)
add_library(Clydesdale::Core ALIAS ${PROJECT_NAME})
target_compile_features(${PROJECT_NAME} PUBLIC cxx_std_17)
target_include_directories(${PROJECT_NAME} PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
target_link_libraries(${PROJECT_NAME} PUBLIC Qt${QT_VERSION_MAJOR}::Core)
target_compile_definitions(${PROJECT_NAME} PRIVATE CLYDESDALE_LIBRARY)
三个关键点:
Clydesdale 被 [Baggage/CMakeLists.txt](file:///d:/WorkSpace/softwarer-horse/Baggage/CMakeLists.txt) 聚合到 Baggage INTERFACE 库,任何链接 Baggage 的模块自动获得 Clydesdale。
九、设计取舍
| 入口实现 | namespace 内联函数 + using 声明 | #define 宏 | 避免 #define error() 与 Qt QIODevice::error() 虚函数冲突(C3254) |
| 命名空间 | Horse,与引擎其他模块一致 | Clydesdale | 项目硬约束:所有日志 API 在 Horse 命名空间 |
| API 风格 | 流式 + 传统字符串并存 | 只保留流式 | 完整字符串消息(如 tr(…))用传统 API 更简洁 |
| 流式实现 | RAII 临时对象,析构提交 | 显式 flush() | 调用方零样板代码,语句结束自动提交 |
| 拷贝语义 | 删除拷贝,仅移动 | 允许拷贝并共享 Stream | 避免 double-free,保证 RAII 提交不变量 |
| 输出抽象 | ILogOutput 单虚函数 | 多接口(write/flush/level) | 单函数足够覆盖现有需求,扩展时再加 |
| 输出列表 | shared_ptr<ILogOutput> | 裸指针 / unique_ptr | 支持同一输出被多个 Logger 共享,引用计数自动管理生命周期 |
| 线程安全 | 锁内拷贝列表,锁外分发 | 全程持锁 | 避免慢速 ILogOutput 阻塞 addOutput,代价是极小的拷贝开销 |
| 默认输出 | 自动挂 ConsoleLogOutput | 强制要求显式初始化 | 开箱即用,开发阶段零配置可用 |
| 格式修饰符 | 默认 noquote + space | 默认 quote + space(同 QDebug) | 日志场景下字符串多为路径/变量名,加引号降低可读性 |
| Hequ 模块 | 独立轻量实现,不依赖 Clydesdale | 复用 Clydesdale 代码 | 提供最小对照实现,作为可选模块独立部署 |
十、当前成果
- 入口层 Clydesdale.h 用 namespace + using 替代宏,彻底规避 Qt 虚函数冲突(C3254),同时保持 info() << "…" 的调用形态。
- HorseLogStream 用 RAII 临时对象实现流式累积 + 析构提交,支持 noquote/quote/nospace/space 四个修饰符,模板重载让 Qt 数学类型直接可输出。
- Logger 静态管理器维护 shared_ptr<ILogOutput> 列表,锁内拷贝、锁外分发,兼顾线程安全和性能。
- ConsoleLogOutput 转发 Qt 原生日志通道,FileLogOutput 带时间戳和级别,二者均开箱即用。
- ILogOutput 单虚函数接口支持 GUI 控制台面板等自定义输出,已应用于 Ferghana 编辑器的 ConsolePanel。
- 传统字符串 API 与流式 API 共存,覆盖"完整消息"和"拼接变量"两类场景。
- Hequ 作为 Mongolian 下的可选轻量替代,独立于 Clydesdale。
十一、下一步
| P0 | 日志过滤 | 支持 LogLevel 阈值(如 Release 模式只输出 Info 以上) |
| P0 | 日志归档 | FileLogOutput 按日期切分,避免单文件无限增长 |
| P1 | 结构化日志 | 支持 JSON 格式输出,便于日志收集系统解析 |
| P1 | 异步文件输出 | 文件写入放到独立线程,避免磁盘 IO 阻塞渲染线程 |
| P1 | 上下文信息 | 自动附加文件名、行号、函数名(参考 Q_LOGGING_CATEGORY) |
| P2 | 日志分类 | 引入 LogCategory(如 Render、Editor、i18n),按模块过滤 |
| P2 | 性能统计 | 集成到编辑器 Profiler 面板,统计各级别日志频率 |
项目仓库
- Gitee:https://gitee.com/shendeyidi/softwarer-horse

本系列记录 Horse3D 游戏引擎从零开始的研发过程,欢迎交流。





