Horse3D 游戏引擎研发笔记(七):Clydesdale------从流式日志到多输出订阅
- [Bilibili 同步视频](#Bilibili 同步视频)
- 一、为什么日志需要单独成模块
- 二、整体架构
- [三、入口层:为什么 Clydesdale.h 里不用宏](#三、入口层:为什么 Clydesdale.h 里不用宏)
-
- [3.1 自然的写法:宏](#3.1 自然的写法:宏)
- [3.2 踩坑:`#define error()` 与 Qt 虚函数冲突](#define error()` 与 Qt 虚函数冲突)
- [3.3 解法:命名空间内联函数 + using 声明](#3.3 解法:命名空间内联函数 + using 声明)
- [3.4 命名约定](#3.4 命名约定)
- 四、流式写入器:HorseLogStream
-
- [4.1 类结构](#4.1 类结构)
- [4.2 RAII 提交](#4.2 RAII 提交)
- [4.3 格式修饰符](#4.3 格式修饰符)
- [4.4 通用模板:让 Qt 数学类型直接流式输出](#4.4 通用模板:让 Qt 数学类型直接流式输出)
- [4.5 移动语义与拷贝禁用](#4.5 移动语义与拷贝禁用)
- 五、Logger:静态管理器
-
- [5.1 线程安全](#5.1 线程安全)
- [5.2 默认输出](#5.2 默认输出)
- [5.3 传统 API 与流式 API 的关系](#5.3 传统 API 与流式 API 的关系)
- [六、输出层:ILogOutput 与两种实现](#六、输出层:ILogOutput 与两种实现)
-
- [6.1 抽象接口](#6.1 抽象接口)
- [6.2 ConsoleLogOutput:转发 Qt](#6.2 ConsoleLogOutput:转发 Qt)
- [6.3 FileLogOutput:带时间戳和级别](#6.3 FileLogOutput:带时间戳和级别)
- [6.4 自定义输出:GUI 控制台面板](#6.4 自定义输出:GUI 控制台面板)
- [七、Hequ:Mongolian 下的轻量替代](#七、Hequ:Mongolian 下的轻量替代)
- [八、CMake 与部署](#八、CMake 与部署)
- 九、设计取舍
- 十、当前成果
- 十一、下一步
- 项目仓库
目标:拆解 Horse3D 的日志子系统 Clydesdale。从一个
info() << "..."调用出发,梳理 RAII 流式写入器、Logger 静态管理器、ILogOutput 输出抽象、ConsoleLogOutput 与 FileLogOutput 两种实现,并交代清楚"为什么 Clydesdale.h 里故意不使用#define宏"这一踩坑点。最后顺带说明 Mongolian/Hequ 模块作为轻量替代的定位。
Bilibili 同步视频
一、为什么日志需要单独成模块
前六篇笔记依次建起了渲染线程、材质系统、多 Pass 管线、光照、编辑器外壳和组件接口。随着模块变多,一个很现实的问题浮现出来:每个模块出问题时,靠什么定位?
qDebug 当然能用,但它有几个明显短板:
- 没有级别抽象 ------
qDebug/qInfo/qWarning/qCritical是四个独立函数,业务代码直接耦合到 Qt API。 - 没有输出分发------想同时打到控制台和文件,要在每个调用点手动写两份逻辑。
- 没有统一格式------时间戳、级别前缀、文件名都靠自己拼字符串。
- 难以后期扩展------未来想加一个"把 Error 推到 GUI 控制台面板"的输出,得满项目改代码。
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
HorseLogStream
RAII 临时对象
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) 里:
cpp
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() 的宏方案:
cpp
#define debug() HorseLogStream(LogLevel::Debug)
#define info() HorseLogStream(LogLevel::Info)
// ...
调用方代码完全一致:info() << "..."。但这个方案在 Horse3D 里翻过一次车。
3.2 踩坑:#define error() 与 Qt 虚函数冲突
Qt 基类 QIODevice / QFileDevice 拥有一个名为 error() 的虚函数:
cpp
// QFileDevice 的真实声明
virtual QFileDevice::FileError error() const;
一旦某段代码先 #include "Clydesdale.h",再 #include <QFile>,预处理器会把 QFileDevice::error() 的声明替换成:
cpp
virtual QFileDevice::FileError HorseLogStream(LogLevel::Error)() const;
MSVC 立刻报 C3254:"类包含显式重写,但并非继承自接口"。这个错误的可怕之处在于:报错位置远离真正的元凶(Clydesdale.h),而是在任何包含 QFile/QIODevice 的 Qt 头文件处。
3.3 解法:命名空间内联函数 + using 声明
把宏改成 Horse 命名空间内的 inline 函数,再用 using 把它们暴露到全局:
cpp
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():
cpp
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。整个日志调用就是一条语句:
cpp
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) 的国际化加载逻辑):
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 还提供了一个模板重载:
cpp
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 显式删除拷贝构造和拷贝赋值,只保留移动版本:
cpp
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 提交并删除,避免漏日志:
cpp
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 是一个纯静态类,承担两个职责:维护输出列表 和分发消息。
cpp
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 调用:
cpp
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:
cpp
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 临时对象:
cpp
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)):
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):
cpp
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():
cpp
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 时间戳和级别前缀:
cpp
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
工程细节上做了三件事:
- 自动建目录 ------
QDir::mkpath(".")在路径不存在时创建,避免日志初始化失败。 - 互斥锁 ------
mutable std::mutex m_mutex保护文件写入,多线程同时打日志不会撕裂同一行。 - Append 模式 ------
QIODevice::Append保证进程重启不覆盖历史日志。 - 路径空检查 ------空路径会打
qWarning提示,而不是默默吞掉。
6.4 自定义输出:GUI 控制台面板
ILogOutput 的扩展性在 Ferghana 编辑器里被用上了。编辑器把控制台面板的 logOutput() 挂到 Logger,让所有日志同时显示在 GUI 上:
cpp
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:
cpp
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 抽象的必要性。
- 可选模块的独立依赖 ------
HORSE3D_BUILD_MONGOLIAN=ON时构建。某些不想拉入整个 Clydesdale 的小工具(例如未来的命令行小工具)可以只链接 Hequ,得到一个非常薄的日志能力。
Hequ 与 Clydesdale 在 API 上不兼容(命名空间不同、Level 枚举不同、没有流式),因此不要在同一模块里混用。需要可扩展日志的项目应直接用 Clydesdale。
八、CMake 与部署
Clydesdale 是 SHARED 库,CMake 配置非常简洁:
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)
三个关键点:
target_include_directories必须是PUBLIC------因为Clydesdale.h提供info()等内联函数,消费方必须能 include 到该头文件,include 路径随 PUBLIC 传播。target_link_libraries是PUBLIC------Clydesdale 内部用QString/QTextStream,消费方也需要 Qt6::Core。CLYDESDALE_LIBRARY仅在 Clydesdale 自己编译时定义 ------用于切换CLYDESDALE_EXPORT在导出/导入之间的方向。
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 面板,统计各级别日志频率 |
项目仓库

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