1. 把 Qt 当总装车间:这套合集为什么按“能力层”而不是按“库”来切分
多数人初学 Qt,会下意识把它当成“画界面的库”:拖几个按钮、连几根线,窗口跑起来了,就算入门了。但只要项目里开始接入树莓派、接上摄像头、跑起 OpenGL 或 OSG 的三维视图、再挂一路视频解码,你很快会发现,Qt 真正值钱的不是那些控件,而是它提供的那套“总装能力”——跨平台事件循环、对象树与内存管理、线程模型、元对象反射、模块化的设备与多媒体接口。这套技术集合之所以把 Qt 放在主干位置,原因就在这里:它不是某一层的东西,它是把所有层粘起来的那层胶水。
我长期维护一个面向工程落地的技术笔记合集,内容横跨 Qt 实用技术、树莓派、三维渲染、OpenCV、OpenGL、ffmpeg、OSG、单片机以及软硬结合。名字听起来很杂,但实际上这些板块之间有极强的依赖关系,并不像把不相干的库拼在一起。一个典型的上位机项目:单片机负责采集、树莓派负责汇聚并做初步处理、OpenGL/OSG 负责可视化、OpenCV 负责图像算法、ffmpeg 负责视频链路、Qt 负责把这些东西串成一个能交付给人用的程序。你把这几个板块单独拿出来看都是独立的技术栈,但放到一个真实项目里,它们是同一根链条上的不同环节。
之所以按“能力层”划分而不是按“库”划分,是因为按库划分的目录结构,在第三次改需求的时候就崩了。你建一个opencv/目录、一个ffmpeg/目录,写着写着发现一半的笔记同时涉及两者,比如“怎么把解码出来的帧直接喂给 OpenCV 做检测”,这条笔记你放哪?放哪都不对。按能力划分就不一样了:
| 能力层 | 解决的核心问题 | 典型入口 | 踩坑密度 |
|---|---|---|---|
| 框架层 | 事件、线程、对象生命周期、跨平台构建 | QObject、QThread、QMetaObject | 高 |
| 交互层 | 界面组织、数据绑定、自定义绘制 | QWidget、QQuickItem、QPainter | 中 |
| 图形层 | 三维场景、着色器、渲染循环 | QOpenGLWidget、osgViewer::Viewer | 极高 |
| 数据层 | 图像处理、编解码、格式转换 | cv::Mat、AVFrame、sws_scale | 高 |
| 设备层 | 串口、总线、上下位机协议、供电与电平 | QSerialPort、I2C/SPI 封装 | 高 |
这个表格的“踩坑密度”一列不是拍脑袋写的。“图形层”给到极高,是因为图形相关的错误大多以“黑屏”“花屏”“帧率莫名其妙掉一半”的形式出现,错误信息极少,排查成本远高于编译期就能发现的类型错误。设备层的坑则集中在“软件完全正确但硬件就是不响应”这类现象上,比如共地没接、电平不匹配、上拉电阻缺失。这些内容在官方文档里几乎不会提,因为文档假设你已经具备硬件常识。
合集存在的意义,说白了就是把“文档不写但你必须知道”的部分补上。官方文档告诉你QSerialPort::open()返回bool,但不会告诉你连续快速开关串口在某些 USB 转串口芯片上会导致句柄泄漏,最终表现为“程序跑一晚上之后串口再也打不开”。这类经验的获取成本极高,往往需要两三次通宵;记下来,就能省掉后面所有人的两次通宵。这也是我对这个合集最核心的定位:它不是教程,它是事故档案加解决方案集合。
1.1 从“会用 API”到“知道边界”的认知转换
我一直认为,判断一个人是否真的掌握某个技术栈,标准是看他知不知道这个技术栈的边界在哪。API 怎么调用,搜索引擎三分钟就能给出答案;但“这个东西在什么情况下不该用”,只有踩过坑的人才知道。以 Qt 的信号槽为例,几乎所有入门材料都会告诉你它是“松耦合的通信机制”,用起来很舒服。但很少有人告诉你,跨线程的队列连接每一次触发都涉及参数的类型注册、内存分配、事件投递,在每秒几万次的高频场景下,它的开销是普通函数调用的几十倍。
所以合集里每一条技术笔记我都尽量设置三个要素:用在哪、不该用在哪、出问题时怎么判断。这个结构听起来朴素,但执行起来很难,因为“不该用在哪”需要真实的失败案例支撑。比如 OpenGL 的QOpenGLWidget,它在 Qt 5 里是默认推荐的,但它内部会额外创建一个 FBO 做重定向,这意味着某些依赖默认帧缓冲的旧代码(尤其是直接调用glDrawBuffer(GL_BACK)的老代码)会失效。这种细节如果你没被坑过,写不出来。
1.2 板块之间的真实依赖链
合集里的板块之间并非平级关系,它们有明确的上游下游。理解这张依赖链,能帮你在学的时候排出合理顺序,也能帮你判断一个新需求该落在哪一层。
- 最底层是 C++ 与构建系统:CMake 或 qmake 都行,但你得知道怎么把一个第三方库的 include 路径、库路径、链接顺序写对。链接顺序错了,报的是一堆
undefined reference,看着像代码问题,其实是构建问题。 - 往上是 Qt Core 与线程模型:这一层没打牢,上面每一层你都会写出隐藏的竞态条件。
- 再往上是设备层与数据层:树莓派、单片机、串口协议、OpenCV、ffmpeg。这两块可以并行学,互不依赖。
- 最上面是图形层与交互层:OpenGL、OSG、自定义控件。它们依赖数据层提供的数据,也依赖框架层提供的渲染时机。
我在整理这个合集时,刻意让每条笔记都标出它属于哪一层。这件事的价值在半年后体现得特别明显:当你要查“为什么我的 OSG 视图在拖动窗口时卡顿”时,你不需要翻二十篇无关笔记,只需要在图形层的索引里找。分类的目的从来不是为了整齐,而是为了检索。
2. 信号槽、事件循环与线程亲和性:Qt 实用技术里最容易被写错的三件事
Qt 实用技术这个板块是整个合集里条目最多的,因为它几乎每条都来自真实的调试现场。如果说三维和音视频是“难学”,那 Qt 基础就是“易错”——难点不在理解,而在于它错得很安静。程序不崩、不报错、编译通过,但行为就是不对。下面这三件事,是我见过出错频率最高的。
2.1 队列连接背后的三次内存动作
信号槽有五种连接方式,日常用到的主要是Qt::AutoConnection(同一线程走直连、跨线程走队列)、Qt::DirectConnection和Qt::QueuedConnection。很多人知道“跨线程要用队列连接”,但不知道为什么,于是写出这样的代码:
// 错误示范:在子线程里给主线程的控件发一个自定义结构体 struct FrameInfo { int width; int height; QByteArray raw; }; // 没有 Q_DECLARE_METATYPE,也没有 qRegisterMetaType emit frameReady(info);编译能过,运行时会打印一行QObject::connect: Cannot queue arguments of type 'FrameInfo',然后信号就静默地不投递了。你看到的现象是“界面不刷新”,第一反应是去查绘制逻辑,越查越远。
队列连接在触发时至少要做三件事:把参数拷贝进一个QMetaCallEvent、通过QCoreApplication::postEvent投递到目标线程的事件队列、在目标线程的事件循环里取出并按索引还原参数。第二步要求这个类型被元对象系统认识,所以必须注册:
Q_DECLARE_METATYPE(FrameInfo) // 在 main 里、创建任何线程之前 qRegisterMetaType<FrameInfo>("FrameInfo");这里还有第二个坑:QByteArray是隐式共享的,拷贝本身很便宜,但如果你传的是一个自己实现的、内部持有裸指针的类,队列连接会做浅拷贝,等到接收端处理时,发送端的对象可能已经析构了。跨线程传参的安全前提是:参数要么是值语义的自包含类型,要么内部用隐式共享/智能指针管理生命周期。我在合集里专门开了一条笔记记录这个,因为它在压力测试下才暴露,平时跑十分钟一点问题没有,跑两小时开始随机崩溃。
至于性能,我做过一个不太严谨的对照测试:在同一台机器上,每秒投递十万次带两个 int 参数的信号,直连大概在毫秒级完成,队列连接需要多花一个数量级的时间,而且目标线程的事件队列会明显积压。这不代表队列连接不能用,而是说高频数据传输不要走信号槽。正确做法是用环形缓冲或无锁队列传数据,用信号槽只做“有新数据了”这种低频通知。这个原则我在音视频板块反复强调,因为解码线程每秒产出几十帧,每帧几百 KB,走信号槽传帧是典型的性能自杀。
2.2 事件循环被阻塞的四种典型症状
Qt 的一切交互都建立在事件循环上。一旦你在主线程里做了耗时的同步操作,事件循环就停止转动,表现出来不是“卡”,而是一连串看似无关的异常现象。我把常见症状列在这里,方便你下次遇到时能快速对上号:
| 症状 | 本质原因 | 快速验证方式 |
|---|---|---|
| 窗口拖动时界面完全不重绘,变成白块 | paintEvent无法被派发 | 在耗时函数前后打时间戳,看是否长达数百毫秒 |
QTimer触发间隔严重漂移 | 定时器事件排在队列里等不到执行 | 打印相邻两次触发的实际间隔 |
| 按钮点一次响应两次 | 第一次点击引发的阻塞导致事件堆积,恢复后连发 | 在槽函数里打印调用栈深度或计数 |
| 程序关闭时卡住几秒才退出 | 退出流程要等主线程的阻塞操作结束 | 观察closeEvent到进程真正结束的时间差 |
第二行特别值得展开。很多人以为QTimer是“准时”的,其实它只保证“不早于设定时间触发”,实际精度完全依赖事件循环的空闲程度。你设一个 16ms 的定时器想做 60 帧刷新,只要中间有一次 200ms 的阻塞,接下来会看到定时器“追赶”式地连续触发好几次。如果你的刷新逻辑里带着状态推进,这种追赶会造成动画瞬间跳跃。
正确的处理方式分两类:如果耗时操作能拆,就拆成小块,用QTimer::singleShot(0, ...)或者把任务切片后逐片处理,让事件循环有机会喘气;如果不能拆,就老老实实放线程里。这里有个我常用的判断标准:任何可能超过 50ms 的同步操作都不该出现在主线程。50ms 是人眼能感知到卡顿的临界值附近,比这个更长的阻塞,用户一定能感觉到。
2.3 线程亲和性:moveToThread的三个前提
QThread的用法是这个板块里争议最大的话题。老式的“继承 QThread 并重写 run”依然能用,但它把“线程控制对象”和“在线程里执行的任务”混在了一起,容易出错。我更推荐 worker-object 模式:
class Worker : public QObject { Q_OBJECT public slots: void start() { // 这里的代码运行在 worker 线程 while (!m_stop.load()) { /* 干活 */ } emit finished(); } signals: void finished(); private: std::atomic_bool m_stop{false}; }; // 使用侧 QThread* thread = new QThread(this); Worker* w = new Worker; // 注意:不能给 w 指定 parent w->moveToThread(thread); connect(thread, &QThread::started, w, &Worker::start); connect(w, &Worker::finished, thread, &QThread::quit); connect(thread, &QThread::finished, w, &Worker::deleteLater); thread->start();这段代码看起来标准,但有三个前提经常被忽略。第一,worker 对象不能有 parent,因为moveToThread会失败并打印警告——一个有 parent 的对象,它的线程亲和性由 parent 决定,你不能单独搬走它。第二,deleteLater必须连到QThread::finished上,因为只有线程退出后,事件循环才会处理这个延迟删除;如果你在run里直接delete w,而w还有未处理的事件,就会崩。第三,worker 内部的槽函数只能被队列连接调用,如果有人图省事写了个Qt::DirectConnection,那这段代码就实际跑在调用者线程里,线程亲和性形同虚设。
还有一个更隐蔽的问题:QThread::quit()只是请求退出事件循环,它不会中断你正在执行的while循环。所以像上面那样的死循环必须自己检查停止标志。我在合集里看到过太多“程序退出时卡死”的案例,根因都是这个。解决方案要么是用std::atomic_bool加轮询,要么把长任务切成多次QTimer驱动的小任务,让事件循环有机会处理退出请求。
3. 树莓派接单片机:软硬结合链路里那些板子和协议教你的东西
软硬结合这个板块的笔记更新频率最低,但单条的价值密度最高。原因很简单:软件错误可以复现、可以断点、可以打日志;硬件错误往往表现为“什么都没发生”,你连从哪查都不知道。这一节我想讲清楚两件事:一是硬件链路上最常见的几个物理层问题,二是上位机侧该怎么设计协议和线程模型,才能让这条链路在连续运行几个月之后还不崩。
3.1 供电、共地与电平:软件工程师最容易忽略的三件事
先讲一个真实案例(细节做了模糊处理)。某项目用一块单板计算机通过串口连一个控制板,实验室里跑了一周都正常,装到现场后每天随机丢数据。代码改了三天没找到问题,最后发现是两块板子各自用了独立的电源适配器,地线没有连在一起。没有共地,串口的参考电平就是浮动的,信号判据不稳定,误码率随环境噪声波动。
这个案例的三个教训,我写进了合集的置顶笔记:
第一,共地是必须明确确认的事,不能默认。只要两块板子之间有信号线,它们的地就必须连起来。如果你的系统里有多路电源,还要注意“单点接地”,避免形成地环路引入干扰。
第二,电平匹配要查数据手册,不能靠猜。3.3V 的器件直接接 5V 的输出,短期可能“能用”,长期是在损伤输入级。反过来的情况更常见:5V 器件接收 3.3V 信号,很多时候能识别为高电平,但余量很小,温度一变化就出错。稳妥的做法是用电平转换芯片,别用电阻分压凑合。
第三,上拉电阻不是可选项。I2C 总线如果没有上拉,某些模块能工作是因为模块自己带了,换个模块就不行了。这类问题最消耗时间,因为它表现为“换了个传感器就不通信”,你会怀疑驱动,怀疑地址,怀疑时序,就是不怀疑电阻。
对应的还有一个软件侧的习惯,我强制自己在所有涉及硬件的示例里加上:上电后先做一次链路自检。发一个固定的握手帧,等待固定格式的应答,超时就重试三次,三次失败在界面上给出明确提示而不是静默重连。这个习惯的来源是一次现场调试,设备日志里一片空白,因为程序在等一个永远不来的数据,而它连“我在等”都没有记录。
3.2 帧协议设计与粘包处理的标准写法
串口是字节流,不是消息流。你发两次 8 字节,接收端可能一次收到 16 字节,也可能分三次收到。这不是 bug,这是物理事实。所以协议必须有明确的边界标识,我一般用“帧头 + 长度 + 负载 + 校验 + 帧尾”的结构:
| 字段 | 长度 | 说明 |
|---|---|---|
| 帧头 | 1 字节 | 固定值,用于快速定位起始位置 |
| 长度 | 1 字节 | 负载长度,不含头尾和校验 |
| 负载 | 变长 | 命令字 + 数据 |
| 校验 | 1 字节 | 对长度和负载做异或或累加和 |
| 帧尾 | 1 字节 | 固定值,辅助确认 |
解析逻辑必须写成状态机或用缓冲区循环消费,绝不能假设“一次readAll就是一帧”:
void DeviceLink::onReadyRead() { m_buf.append(m_port.readAll()); // 循环消费,直到不足以构成一帧 while (m_buf.size() >= 5) { if (static_cast<quint8>(m_buf.at(0)) != 0xAA) { m_buf.remove(0, 1); // 丢掉无效字节,重新对齐 continue; } const int len = static_cast<quint8>(m_buf.at(1)); const int total = len + 4; // 头1 + 长度1 + 负载 + 校验1 + 尾1 if (m_buf.size() < total) return; // 半帧,等下一次数据 const QByteArray frame = m_buf.left(total); if (static_cast<quint8>(frame.at(total - 1)) != 0x55) { m_buf.remove(0, 1); // 帧尾不对,从下一个字节重新找 continue; } if (checksum(frame.mid(1, len + 1)) != static_cast<quint8>(frame.at(total - 2))) { m_buf.remove(0, total); // 校验错,整帧丢弃 emit frameError(); continue; } dispatch(frame.mid(2, len)); m_buf.remove(0, total); } }这段代码里有个容易写错的细节:校验失败时丢整帧,帧头不匹配时丢一个字节。前者是因为长度字段可能是对的,丢整帧效率高;后者是因为此时你还不知道真正的帧头在哪,只能逐字节往前挪。如果把这两种情况的处理写反了,会出现“一旦错位就永久错位,直到缓冲区被撑爆”的问题。这类问题在长时间运行的设备上特别致命,因为它不会立刻表现出来,而是在跑了几天之后突然全部数据失效。
3.3 断线重连与看门狗:让链路能自愈
真实设备运行环境里,线缆松动、电源波动、电磁干扰都是常态。指望“一次连上永不掉线”是不现实的,所以上位机必须有自愈能力。我在合集里推荐的做法是三层保护:
串口层重连:定时检测端口状态,异常时关闭并重新打开。这里有个坑,某些 USB 转串口设备在物理断开后,系统里的设备节点不会立刻消失,QSerialPort::open会返回成功但读写全部失败。判断依据应该是“连续 N 秒没有收到任何有效数据”,而不是“端口是否打开”。
应用层心跳:上位机定时发心跳帧,下位机必须应答。连续 3 次无应答,判定链路失效,进入重连流程。心跳间隔要结合业务场景定,太短会占用带宽,太长会延长故障发现时间。我一般用 1 秒,三次容错,即 3 秒内发现故障。
设备侧看门狗:这是唯一能救“下位机跑飞”的手段。无论软件写得多稳,都不能排除电磁干扰导致的程序异常。硬件看门狗是最可靠的,软件看门狗次之,但两者都必须在主循环里定期“喂狗”,而不是在中断里喂——在中断里喂等于没喂,因为主循环卡死了中断照样能进。
顺便提一个部署层面的实际做法:树莓派这类设备通常需要开机自启,用 systemd 管理比rc.local可靠得多,因为它支持崩溃自动重启、日志集中收集、启动顺序依赖。写一个最简单的 unit 文件,把Restart=always和RestartSec=3加上,能省掉大量“设备重启后程序没起来”的问题。
4. OpenGL 与 OSG 落到 Qt 窗口上:上下文、句柄和帧率的三重门
图形层是这个合集里笔记最零散的部分,因为它的知识高度依赖具体版本和具体驱动。同样是QOpenGLWidget,在不同显卡驱动上的行为差异足以让你怀疑人生。但抛开这些不确定性,有几条主线是稳定的,理解了它们,绝大多数图形问题都能自己推出来。
4.1 上下文才是 OpenGL 的真主角
新手看 OpenGL,注意力都在“怎么画三角形”;做过项目的人看 OpenGL,注意力都在“当前上下文是谁”。因为 OpenGL 的函数调用全部作用于线程当前绑定的上下文,上下文没绑对,你画的三角形就跑到别的窗口去了,或者什么都不显示。
在 Qt 里,QOpenGLWidget会在initializeGL之前自动为你makeCurrent,在paintGL之后自动doneCurrent。所以你在三个虚函数里直接调 GL 函数是安全的。但一旦你开了第二个线程做后台渲染,或者想用QOffscreenSurface做离屏渲染,就必须自己管上下文:
// 后台线程渲染的标准开头 m_context->makeCurrent(m_surface); // ... GL 调用 ... m_context->doneCurrent();这里最常见的错误是“只在初始化时makeCurrent一次”,然后在后续循环里直接调 GL。这种做法在某些驱动上能用,换一台机器就崩。makeCurrent的成本很低,别省这一步。另一个错误是跨线程共享纹理或缓冲区却不设置共享上下文——QOpenGLContext::setShareContext必须在create()之前调用,创建之后再设置是无效的,而 Qt 不会报错,你只会看到“纹理在线程 B 里是黑的”。
还有一点容易被忽略:离屏渲染必须用QOffscreenSurface,不能拿QWidget当渲染目标。因为 widget 的底层表面属于 GUI 线程,跨线程访问它是未定义行为。这个规则我在合集里用加粗标了三遍,因为它是“程序偶发崩溃且崩溃点五花八门”的经典源头。
4.2 把 OSG 塞进 Qt 的两种方案与选择依据
OSG 是一个自成体系的场景图引擎,它自己管理渲染循环和窗口。把它嵌进 Qt,本质上是解决“谁来管窗口”的问题。我实践过两种方案,各有适用场景。
方案一,用osgViewer::GraphicsWindowEmbedded,让 OSG 在一个由 Qt 提供的原生窗口句柄上渲染。核心代码如下:
// 初始化阶段 osg::ref_ptr<osgViewer::GraphicsWindowEmbedded> gw = new osgViewer::GraphicsWindowEmbedded(0, 0, width(), height()); m_viewer = new osgViewer::Viewer; m_viewer->setThreadingModel(osgViewer::Viewer::SingleThreaded); m_viewer->getCamera()->setGraphicsContext(gw); m_viewer->getCamera()->setViewport(0, 0, width(), height()); m_viewer->setCameraManipulator(new osgGA::TrackballManipulator); m_viewer->setSceneData(buildScene()); // paintGL 里 m_viewer->frame(); // resizeGL 里,两个调用都不能少 gw->getEventQueue()->windowResize(0, 0, w, h); gw->resized(0, 0, w, h);这里有两个必须强调的点。第一,setThreadingModel一定要设成SingleThreaded。OSG 默认会开多线程渲染,那些线程和 Qt 的 GUI 线程抢上下文,结果是随机崩溃或者黑屏。第二,windowResize和resized是两件事,前者是给事件队列塞一个窗口大小变化事件(影响相机操作器的响应范围),后者是真正改变渲染视口。只调一个,会出现“画面比例变形”或者“鼠标操作区域和画面不匹配”。这两个坑我各踩过一次,每次都是查了大半天。
方案二,把 OSG 渲染到 FBO,再把结果作为纹理贴到 Qt 控件上。这种方案的好处是渲染完全解耦,OSG 可以在独立线程跑,界面刷新用 Qt 的合成机制;坏处是多一次纹理拷贝,高分辨率下有性能损失,而且拾取(鼠标点击选中物体)需要自己做坐标映射。我一般在“需要多视图并存”或者“三维视图只是界面一小块”的场景下选这个方案。
选择依据我总结成一句话:如果三维视图占满整个窗口,且交互简单,用方案一;如果三维视图要和大量 Qt 控件混排,或者需要多个三维视图,用方案二。别想着找一个“通用最优解”,这两种方案的复杂度分布在不同地方,选哪个取决于你的交互需求。
4.3 帧率掉了,按这个顺序查
帧率问题是最难查的一类,因为原因可能在任何一层。我在合集里维护了一份排查清单,按“从便宜到昂贵”的顺序排列,实践中能覆盖八成情况:
| 顺序 | 检查项 | 判断方法 | 常见结论 |
|---|---|---|---|
| 1 | 是否开了垂直同步 | 看帧率是否稳定卡在 60 或 30 | 被 vsync 限速,不是性能问题 |
| 2 | 是否每帧都在重建资源 | 检查paintGL里有没有new、compile、loadTexture | 资源创建移出渲染循环 |
| 3 | 绘制调用次数 | 统计每帧glDraw*的调用量 | 上千次说明该做合批 |
| 4 | 状态切换次数 | 统计glBindTexture、glUseProgram | 频繁切换是典型瓶颈 |
| 5 | 是否在做同步读回 | 找glReadPixels | 同步读回会强制管线刷新 |
| 6 | 三角形数量与过度绘制 | 用性能分析工具看填充率 | 高分辨率下过度绘制非常致命 |
| 7 | 驱动与格式 | 换一台机器对比 | 特定驱动的兼容问题 |
第 2 项是最常见也最容易改的。很多示例代码为了简洁,把纹理加载写在paintGL里,跑起来看着没问题,因为示例的模型很小。一旦换成真实数据,帧率直接掉到个位数。第 5 项则非常隐蔽,glReadPixels在代码里可能只有一行,藏在截图功能或者某个数据回读逻辑里,但它会强制等待 GPU 完成所有已提交的工作,等于把流水线打断了。
还有一个非技术的经验:先确认“慢”是真的慢,还是体感慢。我遇到过好几次“用户反馈卡顿”,最后发现是帧间隔不均匀导致的视觉抖动,平均帧率其实有 55 帧。这种情况优化渲染没用,得做帧率平滑,或者把渲染和界面刷新解耦。判断方法是记录每帧的时间戳,看方差而不是看均值。
5. OpenCV 与 ffmpeg 进工程:像素格式、时钟和部署体积
数据层这个板块的笔记,几乎全部围绕“转换”这个词展开。图像在不同库之间流转,格式换了一次又一次:摄像头出来是 YUV,解码器吐出来是AVFrame,算法库要cv::Mat,界面要QImage,GPU 上传又要 RGB 或 BGRA。每换一次都可能出错,而错误的表现形式高度相似——颜色不对、图像错位、程序崩溃。所以我在合集里把这一块的笔记组织成“转换矩阵”,每种转换单独一条,附上正确的写法和失败现象。
5.1cv::Mat与QImage互转:三个必错点
先看正确的写法:
QImage matToImage(const cv::Mat& src) { if (src.empty()) return QImage(); if (src.type() == CV_8UC3) { // Format_BGR888 需要 Qt 5.14 及以上 QImage img(src.data, src.cols, src.rows, static_cast<int>(src.step), QImage::Format_BGR888); return img.copy(); // 关键:深拷贝,切断对 cv::Mat 内存的依赖 } if (src.type() == CV_8UC1) { QImage img(src.data, src.cols, src.rows, static_cast<int>(src.step), QImage::Format_Grayscale8); return img.copy(); } cv::Mat tmp; cv::cvtColor(src, tmp, cv::COLOR_BGRA2BGR); return matToImage(tmp); }第一个必错点是bytesPerLine必须传src.step,不能传cols * channels。因为 OpenCV 的矩阵行末可能有填充字节以保证内存对齐,尤其在你对矩阵做过 ROI 裁剪之后,step会大于cols * 通道数。传错了,图像会出现斜向的撕裂。
第二个必错点是QImage的构造函数不拷贝数据,它只是包装了指针。所以那个copy()不能省。很多示例代码省略了它,在简单场景下也能跑,因为cv::Mat的生命周期刚好覆盖了显示过程。但只要QImage被存进了某个缓存、或者被传给了另一个线程,源数据一释放,你拿到的就是野指针。
第三个必错点是通道顺序。OpenCV 默认 BGR,Qt 的Format_RGB888是 RGB。如果你用Format_RGB888包装 BGR 数据,红色和蓝色会互换,人脸会变成蓝脸。这个问题肉眼可辨,但如果你在黑白场景下测试,就完全看不出来,直到接上彩色摄像头才发现。所以我建议干脆用Format_BGR888(Qt 5.14+),让格式和数据对齐,避免手工交换通道带来的性能和正确性成本。
5.2 解码线程与播放时钟:为什么你的视频总是在抖
用 ffmpeg 做播放,最难的环节是音视频同步。这里不展开完整实现,只讲那些“看了文档也想不明白”的地方。
解码本身是两条独立的流水线:视频流按帧率出帧,音频流按采样率出采样。它们的节奏天然不一致,必须有一个“主时钟”来仲裁。常见策略有三种:以音频为主时钟、以视频为主时钟、以外部时钟为准。实践中绝大多数场景应该选音频为主时钟,因为人耳对音频断续和变速极其敏感,几毫秒的偏差就能听出来;而人眼对视频帧的微小延迟容忍度高得多。
实现上的核心是一个“等待”逻辑:每解码出一帧视频,计算它相对于主时钟的显示时间,如果还没到时间,就等;如果已经超时太多,就丢帧。这里的坑在于“等”的方式。用QThread::msleep阻塞解码线程是最简单的,但它会导致解码和显示耦合,缓冲区永远填不满。更好的做法是把解码和显示分成两个线程,中间用有界队列连接,队列满了就阻塞解码线程,队列空了就阻塞显示线程。
另一个常见问题是时间基换算。ffmpeg 里AVFrame::pts是以AVStream::time_base为单位的,换算成秒是pts * av_q2d(time_base)。很多人直接用pts做比较,在不同容器格式下得到的结果完全不同,表现为“某些文件播放正常,某些文件快进或慢放”。这个错误我在合集里单独列了一条,因为它的现象太有迷惑性——你会以为是解码器的问题,实际上是单位没换。
还有内存管理。av_read_frame拿到AVPacket之后必须av_packet_unref,avcodec_receive_frame拿到的AVFrame必须av_frame_unref或av_frame_free。漏掉任何一个,内存会缓慢增长,跑几小时之后 OOM。这种问题在短时间测试里发现不了,我一般会用循环播放同一段视频跑一整夜来验证。
5.3 依赖打包:从 200MB 到能接受的体积
工程做完要交付,这时候才发现 ffmpeg 的动态库一堆,OpenCV 的库更大。不处理的话,安装包轻松上到几百 MB。合集里记录了几条实测有效的做法:
- 只链接用到的模块。OpenCV 用 CMake 配置时关掉不需要的模块,比如不做视频分析就不带
dnn、video。ffmpeg 自己编译时关掉不需要的编解码器,这一步收益最大,能砍掉一半以上。 - 检查符号依赖。Linux 下用
ldd,Windows 下用依赖查看工具,找出真正被加载的库,把没被引用的删掉。经常能发现某个体积很大的库其实只是编译时链上了,运行时根本没用到。 - Qt 部署用官方工具。Windows 用
windeployqt,Linux 用对应的部署脚本,它们会把插件目录一起带上。这里有个坑:windeployqt默认会把所有翻译文件和不需要的插件都拷过去,加--no-translations之类的参数能省不少空间。 - 注意插件路径。Qt 的平台插件、图像格式插件如果没被正确部署,程序启动会直接报“could not find or load the Qt platform plugin”。这个问题在开发机上永远遇不到,因为开发环境里 Qt 装好了,插件都在。
6. 合集能被自己三年后复现:目录、最小示例与版本矩阵
写技术笔记最大的陷阱是“写给当下的自己看”。当下你记得所有上下文,所以笔记可以写得很简略;三年后你回头看,那些简略的笔记等于一堆无法执行的符号。这个合集能长期维护下来,靠的是一套近乎刻板的规则,而不是靠记性。
6.1 目录按“问题”命名,不按“库”命名
我早期的目录是这样的:qt/、opengl/、ffmpeg/。用了一年之后彻底失控,因为一条笔记经常横跨两三个目录。后来改成按问题命名,比如“窗口拖动时三维视图卡顿”“串口连续开关后打不开”“视频播放音画不同步”。这样改的好处是,你搜索的时候用的是“现象”,而不是“库名”——而人回忆问题的时候,想起的永远是现象。
命名规则上我定了几条硬性要求:文件名以动词或现象开头,不用缩写,不带日期(日期放正文里)。比如排查-三维视图拖动卡顿.md比osg_p1.md有用一百倍。另外每篇笔记开头必须有四行元信息:适用版本、环境、现象、结论。这四行让未来的你能在三十秒内判断“这篇跟我现在的问题是不是一回事”。
6.2 示例工程的最小可运行原则
合集里所有代码示例都遵守一个原则:能用一个main.cpp加一个工程文件跑起来的,绝不拆成多个文件。原因很实际——多文件示例看着规范,但复现成本高,你要建目录、建多个文件、配好 include,中间任何一步错了,跑不起来,然后就放弃了。
为了做到单文件可运行,我付出的代价是示例代码里会看到一些“不优雅”的东西,比如所有类都写在一个文件里、命名空间省略、错误处理简化。这是刻意的取舍:示例的目的是验证一个点,不是展示工程规范。真正的工程规范应该放在另一类笔记里,两者混在一起,两边都做不好。
配套的还有一条:每个示例必须写明“预期输出”。比如“运行后应看到一个红色三角形,控制台每秒打印一次帧率”。这条看似多余,实际极其重要。因为如果你不知道正确结果长什么样,跑出任何结果你都会觉得“大概是对的吧”。我吃过这个亏,一个 OpenGL 示例因为着色器编译失败而黑屏,我以为是环境问题,查了两小时,最后发现着色器里少了个分号,而当时的笔记里只写了“运行示例”,没写应该看到什么。
6.3 版本矩阵:比代码更容易过期的东西
技术笔记里,代码往往不是最先失效的,环境才是。同一个QOpenGLWidget示例,在 Qt 5.9 和 Qt 5.15 上行为可能不同;同一个 OpenCV 调用,3.x 和 4.x 的默认参数可能变了;ffmpeg 的 API 更是几个大版本就废弃一批。所以合集的每一篇都必须记录当时的版本信息,我在实践中固定成一张小表:
| 项目 | 记录内容 | 为什么必须记 |
|---|---|---|
| 语言标准 | C++11 / 14 / 17 | 影响auto、结构化绑定、std::filesystem可用性 |
| 框架版本 | Qt 具体小版本 | 大量 API 在小版本间新增或废弃 |
| 第三方库 | OpenCV / ffmpeg / OSG 版本号 | 接口签名与默认行为随版本变化 |
| 构建系统 | CMake 或 qmake 及版本 | 语法差异会导致构建失败 |
| 运行平台 | 操作系统 + 硬件架构 | 图形和硬件相关问题强依赖平台 |
| 特殊依赖 | 驱动版本、编译选项 | 图形问题经常是驱动层的 |
这张表看起来繁琐,但它救过我很多次。有一次我需要复现一个三年前写的三维示例,代码一行没改,就是跑不起来,查了半天发现是当时的笔记里写了“需要开启 MSAA 4x”,而新环境默认关闭。如果没记录,这个信息就永久丢失了。
还有一条经验值得单独说:升级依赖时,不要一次性全升。我一般一次只升级一个库,升完把相关示例全跑一遍,确认没问题再动下一个。全部一起升,出了问题根本不知道是谁的锅。这个习惯来自一次惨痛经历:同时升了 Qt、OpenCV 和编译器,结果程序崩溃,花了两天才定位到是某个库在新编译器下的 ABI 不兼容。
最后分享一个我坚持了很久的小习惯:每篇笔记写完之后,隔一周再回来看一遍,只看“能不能照着跑起来”。一周的间隔刚好能让你忘掉当时写代码的思路,还原成“陌生读者”的视角。凡是这一遍看不懂、跑不通的地方,就是需要补充的地方。这个习惯让合集的可用性提高了不止一个档次,也让我少了很多“当时我到底在想什么”的困惑。