CMake 项目搭建与基础设施
专题学习笔记 — Phase 1 (T001–T006)
以下内容是从实际开发过程中提取的技术要点,按任务分组。
T001:CMake 项目骨架
1. FetchContent vs find_package
FetchContent:在 configure 阶段从 Git 下载源码并作为子项目编译。适合没有系统级安装的库(nlohmann/json、spdlog)。
find_package:查找系统已安装的库。适合 Qt 这种通过安装器部署的大型框架。
# FetchContent 模式——零系统依赖
FetchContent_Declare(nlohmann_json GIT_REPOSITORY ... GIT_TAG v3.11.3)
FetchContent_MakeAvailable(nlohmann_json)
# find_package 模式——需要预装 Qt
find_package(Qt6 REQUIRED COMPONENTS Core Widgets)
教训:FetchContent 依赖网络,国内用户必须用 Gitee 镜像否则 configure 卡死。
2. CMake Presets 的 generator 陷阱
"generator": "Ninja" // ← 如果环境没有 Ninja,整个 preset 不可用
去掉 generator 字段让 CMake 自动选择:Windows → Visual Studio,Linux → Unix Makefiles。Qt Creator 自带 Ninja,IDE 内可以指定。
3. CMake 项目最小骨架要点
CMAKE_CXX_STANDARD 17+CMAKE_CXX_STANDARD_REQUIRED ON—— 强制 C++17CMAKE_CXX_EXTENSIONS OFF—— 禁用编译器扩展(提高可移植性)- 先 configure 通过再加子目录——逐步验证,不要一次性全上
T002:Qt 空白窗口
4. QApplication 的最小模型
QApplication app(argc, argv); // 1. 创建 Qt 应用(必须有,管理事件循环)
QWidget window; // 2. 创建窗口
window.show(); // 3. 显示
return app.exec(); // 4. 进入事件循环,阻塞直到窗口关闭
关键点:app.exec() 是阻塞调用,返回后才执行后续代码。Qt 程序在 exec() 中通过事件循环驱动——鼠标点击、键盘输入、信号槽、定时器全部依赖它。
5. 为什么先不放 core 库
在需要抽象之前不要抽象。logger、config_mgr、ws_client 当前都在 app/ 目录下——等两个以上模块真实需要共享它们时,再抽到 core/。
信号:当你发现自己往 app/ 里塞了第三个完全不相关的类,或者你需要给 logger 写第二个使用者时,就该抽离了。不是”可能将来需要”,而是”现在已经有第二个使用者了”。
T003:日志系统
6. qInstallMessageHandler 的设计思想
qInstallMessageHandler(Logger::messageHandler);
// 此后所有 qDebug() / qInfo() / qWarning() / qCritical()
// 全部路由到 messageHandler,不再走默认控制台输出
这是 Qt 的全局钩子——一个进程只有一个 messageHandler。好处:
- 不需要在代码中到处传递 logger 实例
- Qt 框架内部的诊断输出(QWebSocket 连接失败等)也被自动捕获
qDebug() <<是流式语法,比printf("...", a, b)类型安全
7. spdlog vs Qt 原生方案的选择逻辑
| 维度 | 什么时候选 spdlog | 什么时候选 Qt 原生 |
|---|---|---|
| 日志量级 | 每秒 > 1000 条 | 每秒 < 1000 条 |
| 项目依赖 | 纯 C++ 项目(非 Qt) | Qt 项目 |
| 需要捕获 Qt 内部日志 | 不能 | 天然支持 |
| 格式化 | logger->info("x={}", x) | qInfo() << "x=" << x |
本项目是 Qt 项目,日志量 IM 级别(秒级个位数),并且需要捕获 QWebSocket/QNetworkAccessManager 的内部错误——Qt 原生方案是唯一正确的选择。
8. QFile 使用注意
QFile::open()不抛异常,必须检查返回值QIODevice::Text在 Windows 上做\n→\r\n转换QTextStream::flush()保证崩溃前的内容不丢失- 日志文件路径用
QApplication::applicationDirPath()而非相对路径
T004:JSON 配置加载
9. nlohmann/json 的 JSON Pointer 风格取值
// JSON: {"server": {"host": "127.0.0.1", "port": 8080}}
auto host = data["/server/host"_json_pointer]; // 原生 JSON Pointer
但 "_json_pointer" 是 C++11 字面量后缀,需要 using json = nlohmann::json。如果不想引入这个,可以手动逐级取值:
// 手动路径遍历
auto ptr = m_data;
for (auto& part : QString("/server/host").split('/', Qt::SkipEmptyParts)) {
if (!ptr.contains(part)) return fallback;
ptr = ptr[part];
}
优势:任意深度的路径自动处理,中间任何层级缺失都返回 fallback。
10. CMake configure_file 的局限
configure_file(A B COPYONLY) # 只复制,不替换 @VAR@
configure_file 的第二个参数不支持 generator expression($<TARGET_FILE_DIR:...>),因为 generator expression 在 build 阶段求值,而 configure_file 在 configure 阶段执行。解决方案:
- 方案 A:
add_custom_command(POST_BUILD ...)构建后复制 - 方案 B:代码中计算相对路径(本项目采用,简单粗暴)
- 方案 C:Qt Resource System (.qrc) 嵌入小型配置文件
11. 为什么不在 CMake 中复制配置文件
构建目录路径在 configure 时未知(尤其是有 multi-config generator 如 VS/Xcode 时)。add_custom_command + $<TARGET_FILE_DIR> 可以解决,但为一个小配置文件引入构建后步骤不值得。
T005:QWebSocket 基础
12. QWebSocket 的生命周期
QWebSocket m_socket; // 不需要 new,栈上对象即可
m_socket.open(url); // 异步连接,立即返回
// ... 稍后信号触发 ...
m_socket.close(); // 异步断开
关键:QWebSocket 所有操作都是异步的。open() 立即返回,连接结果通过 connected / error 信号通知。
13. Qt MOC 和 AUTOMOC
CMake Error: undefined reference to `WsClient::metaObject()`
Q_OBJECT 宏要求 Qt 的 Meta-Object Compiler (MOC) 生成额外的 .moc 文件。CMake 的 AUTOMOC 自动处理:
set(CMAKE_AUTOMOC ON) # 全局启用
AUTOMOC 扫描规则:
- 所有
add_executable/add_library中列出的.h文件 - 所有被源文件
#include的同目录.h文件 - 所有 target 的
INCLUDE_DIRECTORIES路径中的.h文件
本项目遇到的坑:ws_client.h 没有被 AUTOMOC 扫描到,因为虽然 ws_client.cpp 引用了它,但顶层 CMakeLists 没有显式开启 AUTOMOC。加上 set(CMAKE_AUTOMOC ON) 并确保 .h 文件在 target 源列表中即可。
14. QWebSocket 信号
| 信号 | 触发时机 |
|---|---|
connected() | TCP + TLS + WS upgrade 全部完成 |
disconnected() | 任意一端关闭连接 |
textMessageReceived(QString) | 收到完整 Text 帧 |
binaryMessageReceived(QByteArray) | 收到完整 Binary 帧 |
error(QAbstractSocket::SocketError) | 连接失败或传输错误 |
T006:JSON 消息收发
15. QJsonDocument 的序列化和反序列化
// 序列化:QJsonObject → 紧凑 JSON 字符串
QJsonDocument doc(obj);
QString json = doc.toJson(QJsonDocument::Compact);
// 输出: {"type":"ping"} (无空格、无换行)
// 反序列化:JSON 字符串 → QJsonObject
QJsonParseError err;
QJsonDocument doc = QJsonDocument::fromJson(text.toUtf8(), &err);
if (err.error != QJsonParseError::NoError) { /* 非 JSON 消息 */ }
QJsonObject obj = doc.object();
16. WebSocket 消息验证的必要性
服务端发来的消息不可信任。任何 textMessageReceived 回调都必须:
- 先尝试 JSON 解析
- 解析失败 → 记录警告并忽略(不崩溃)
- 解析成功 → 检查必要字段是否存在(如
type、seq)→ 才能使用
这是防御性编程的基本要求——后续 Task 会给消息加上 type 路由和 seq 校验。
17. Qt 信号槽连接的五种写法
// 1. 函数指针(编译期检查,推荐)
connect(&socket, &QWebSocket::connected, this, &WsClient::onConnected);
// 2. Lambda(灵活但注意生命周期)
connect(&wsClient, &WsClient::connected, [&wsClient]() { /* ... */ });
// 3. 字符串 SIGNAL/SLOT(不推荐,运行时匹配、容易拼错)
connect(&socket, SIGNAL(connected()), this, SLOT(onConnected()));
// 4. QOverload 消歧义(同名重载信号)
connect(&socket, QOverload<QAbstractSocket::SocketError>::of(&QWebSocket::error), ...);
// 5. 函数对象到 Lambda
connect(&wsClient, &WsClient::messageReceived, [](const QJsonObject& msg) { ... });
18. Lambda 捕获的坑
// ❌ 危险:wsClient 可能在信号触发前被销毁
connect(&someObj, &T::sig, [&wsClient]() { wsClient.send(...); });
// ✅ 安全:wsClient 生命周期长于信号源(主函数栈变量,先于 QApplication 销毁)
// 本项目中 wsClient 在 main() 栈上,QApplication 销毁后才析构,所以 & 捕获是安全的
跨任务经验总结
增量验证驱动的核心原则
- 先证明连接能建,再证明消息能发——T005 → T006 的顺序不是随意的
- 每个 Task 只新增一种能力——不要让一个 PR 同时改 logger + config + ws
- 验收条件只检查新能力——T006 不管心跳是否正常,只验证 JSON 能发出
- Mock Server 缺失不阻塞客户端开发——代码就位即可,真正通信留到 T009–T012
文件路径问题的通用解法
Qt Creator 的 working directory ≠ 项目根。三个选项:
| 方案 | 适用场景 |
|---|---|
applicationDirPath() + "../../../../config" | 配置/资源在项目根,快速原型阶段 |
CMake add_custom_command 复制到 build 目录 | 正式项目,自动化构建 |
| Qt Resource System (.qrc) | 图标、QSS、小型 JSON |
本项目当前在原型阶段,用方案一。Phase 3 后应迁移到方案二或三。
什么时候把代码从 app/ 移到 core/
不是”将来可能需要”,而是:
当你发现
app/里的某个类被第二个使用者 import 时,就是抽离的时机。
在那一刻之前,放在 app/ 里毫无问题。过早抽离会导致:
- 接口设计基于猜测而非实际使用
- core 库为了”通用”引入不必要的抽象
- 修改一个类的成本翻倍(改 header + 重建所有链接者)