输入关键词开始搜索

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++17
  • CMAKE_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 阶段执行。解决方案:

  • 方案 Aadd_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 扫描规则:

  1. 所有 add_executable / add_library 中列出的 .h 文件
  2. 所有被源文件 #include 的同目录 .h 文件
  3. 所有 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 回调都必须:

  1. 先尝试 JSON 解析
  2. 解析失败 → 记录警告并忽略(不崩溃)
  3. 解析成功 → 检查必要字段是否存在(如 typeseq)→ 才能使用

这是防御性编程的基本要求——后续 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 销毁后才析构,所以 & 捕获是安全的

跨任务经验总结

增量验证驱动的核心原则

  1. 先证明连接能建,再证明消息能发——T005 → T006 的顺序不是随意的
  2. 每个 Task 只新增一种能力——不要让一个 PR 同时改 logger + config + ws
  3. 验收条件只检查新能力——T006 不管心跳是否正常,只验证 JSON 能发出
  4. 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 + 重建所有链接者)