QCustomPlot 的动态时间坐标,最容易出事的地方不在绘图本身,而在“这一秒”到底该用秒还是毫秒来表示。我在三个项目里反复栽在同一个坑上:数据采集侧给的是毫秒时间戳,坐标轴侧的 ticker 按秒解释,两条线一交叉,X 轴要么定格在 1970 年附近,要么标出几万年的刻度,图上一片空白。这篇就把从“毫秒”到“秒”这一层换算的来龙去脉、ticker 内部职责、亚秒刻度的处理方式、实时滚动的性能取舍,连同我实际踩过的十几个坑,一次讲清楚。适合刚开始用 QCustomPlot 做实时监控界面的同学,也适合已经在用、但发现放大后时间标签乱跳、毫秒精度对不上、缩放后刻度突然变稀疏的老手对照排查。
1. 时间轴对不上的根子:key 的单位被搞乱了
1.1 QCustomPlot 里时间 key 到底是什么
先把最容易模糊的概念钉死。QCustomPlot 的 X 轴本质上是一条连续的实轴,它自己的类型是 double,内部没有“日期”这个概念,只有数值大小和相对次序。所谓的时间轴,是把某条数值轴上的数字交给 QCPAxisTickerDateTime 去解释成日期时间。因此在时间轴上,一个点横坐标的数值,等于“从 1970-01-01 00:00:00 UTC 到该时刻经过了多少秒”,是个 double 型的秒数。
这个定义带来的第一个硬约束是:时间轴上的所有 key 必须处在同一个量级上。坐标轴的取值范围(QCPRange)和往里塞的数据点,用的是同一把尺子。很多人出问题,本质上是把两把尺子混着用了——数据用毫秒尺子量,轴用秒尺子摆。
而 QCPAxisTickerDateTime 提供了两个静态工具函数,通常也是大家唯一会用到的两个:
// QDateTime -> 秒级 key static double dateTimeToKey(const QDateTime dateTime); // 秒级 key -> QDateTime static QDateTime keyToDateTime(double key);从名字就能看出问题:这两个函数是“秒进秒出”的。如果传入的 QDateTime 是一个合法的时刻,dateTimeToKey 返回的数值大约是 1.7e9 这个量级(以近几年的时刻为例)。一旦你在某个地方算出 1.7e12,那就说明这条链路上有人偷偷乘了 1000。
1.2 两种错法的症状对照
我在排查别人的代码时,看到最多的两类错法,症状完全不同,但根子是同一个。
第一类:把毫秒时间戳直接当 key 塞进去,坐标轴范围却仍然按秒来设。表现出来就是数据完全落在可视区之外,图上一根线都没有,但曲线对象明明存在,图例也显示着。你把鼠标悬停到空白处,可能连拖拽都感觉不到任何东西,因为坐标轴范围和数据范围差了三个数量级。
第二类:先按秒算 key,后来又因为“要显示毫秒”而整个乘了 1000,连坐标轴范围一起乘。表现出来是能看见线,但 X 轴刻度标签显示成几万年后的某个日期,或者干脆显示成一段问号。这是因为 ticker 把 1.7e12 当成了秒数,换算成年份早就溢出了人类历史。
| 错法 | 数据 key 量级 | 轴范围量级 | 现象 |
|---|---|---|---|
| 毫秒直接当 key,轴按秒设 | 1.7e12 | 1.7e9 | 图空白,看不到数据 |
| 轴也乘 1000 | 1.7e12 | 1.7e12 | 能看见线,刻度标签年份离谱 |
| 一半点秒一半点毫秒 | 两种混排 | 不定 | 曲线出现巨大跳变或折回 |
| 轴范围没设,自动 rescale | 点本身正确 | 单点退化 | 刻度全是同一个值 |
第三类更阴险:一批历史数据是用毫秒存的,新采的数据用秒存的,代码里没有统一。这种曲线表面上像是“中间断了一截”,实际上是两簇点被拉开了三个数量级,中间那段空白就是量级差本身。
1.3 三步定位法
遇到“时间轴不对”,我一般按下面三步走,基本能在几分钟内把问题圈死。
第一步,看数据本身。在 addData 那一行打个断点,看 x 的数值。十几亿,是秒;一万多亿,是毫秒;一千多亿,可能是微秒。这一步是唯一可靠的判断依据,别靠猜。
第二步,看坐标轴范围。在 setRange 之前把上下限打出来。如果上下限和数据差三个数量级,问题就定位在“两边用了不同的换算方式”。
第三步,看 ticker 拿到的范围。写一个 QCPAxisTickerDateTime 的子类临时重写 getTickStep,把传入的 range 打印出来。如果这里和第二步打印的数值不一致,说明中间有人又改了一次范围——比如自动 rescale 或者 setRange 之后又被别的地方覆盖。
三步走完,问题基本跑不掉。我踩过的坑里,90% 都停在第一步和第二步,剩下 10% 是 ticker 自己因为范围太小或者零点退化而算不出刻度。
2. ticker 内部怎么算刻度:看懂它才能改对
2.1 QCPAxisTickerDateTime 取刻度的逻辑
要改对,得先知道它怎么干活。QCPAxisTickerDateTime 继承自 QCPAxisTicker,但它重写了两个关键虚函数:getTickStep 和 getTickLabel。前者负责“这一步多大”,后者负责“标签写什么”。
getTickStep 的骨架大致是先算出平均步长,也就是 range.size() 除以(mTickCount + 1),然后按数值大小分流处理。步长小于 1 秒时,交给基类按“漂亮数字”规则取整,得到 0.5、0.2、0.1 这种;步长落在 1 到 60 之间时向上取整到整秒;再大就依次切到分钟、小时、天、月、年的自然边界上。它还会做一次“对齐”,让刻度落在整分、整点、整天的位置上,而不是从范围下限开始的等分点。
这个设计带来的直接后果是:你设的 setTickCount 只是一个“期望密度”,不是硬性数量。窗口拉得很大时,ticker 会自己跳到天甚至月为单位,这时候屏幕上可能只有三四根刻度线;窗口缩到毫秒级时,它会切到亚秒步长。
另一个非常容易踩的坑是:setTickStep 在 DateTime ticker 上基本不生效。基类确实有个 mTickStep 和 setTickStep,但它只对 QCPAxisTickerFixed 有意义,QCPAxisTickerDateTime 的 getTickStep 是自己重新算的,压根不看这个值。我见过有人为了“每隔 500 毫秒画一根线”去调 setTickStep(0.5),调完发现毫无变化,然后开始怀疑人生。控制密度只有两条路:调 setTickCount,或者自己写子类。
QSharedPointer<QCPAxisTickerDateTime> ticker(new QCPAxisTickerDateTime); ticker->setTickCount(6); // 期望 6 根刻度,不是精确值 ticker->setDateTimeFormat("hh:mm:ss"); // 标签格式 ticker->setDateTimeSpec(Qt::LocalTime); // 显示时用的时区语义 plot->xAxis->setTicker(ticker);2.2 时区、DateTimeSpec 与标签显示
第二个高频误会是时区。key 本身永远是 UTC 秒,这一点不会因为你把 DateTimeSpec 设成什么而改变。DateTimeSpec 只影响“把 key 翻译成人类可读字符串时,按哪个时区来解释”。
默认值是 Qt::LocalTime,也就是界面上显示的是本机时区的墙上时间。这时候如果你对比另一路数据(比如服务端日志里的 UTC 时间戳),会发现相差整小时数,很容易误判成“数据延迟”。把 DateTimeSpec 显式设成 Qt::UTC 之后,两边的数字才对得上。
麻烦的地方在于混用。我在一个项目里见过这样的代码:先用 dateTimeToKey 算出 key 设范围,又从另一处用 QDateTime::fromSecsSinceEpoch 手动换算出一个本地时间塞给 setRange,两个值之间刚好差一个时区偏移。因为差值固定且不大(几小时),窗口拉到几分钟时根本看不出来,只有把窗口拉到一整天才会暴露——那天正好是切图排查,才发现整条曲线平移了。
我的做法是:整个工程只允许存在一个“时间到 key”的函数,其它地方一律调它,谁都不许自己乘除 1000,也不许自己调 fromSecsSinceEpoch。
// 全局唯一换算入口 inline double toPlotKey(const QDateTime &dt) { return QCPAxisTickerDateTime::dateTimeToKey(dt); } inline double nowKey() { return QDateTime::currentMSecsSinceEpoch() / 1000.0; }注意 nowKey 里用的是 currentMSecsSinceEpoch 除以 1000.0,不是 currentSecsSinceEpoch。后者的整秒截断会让所有采样点都落在整秒上,看起来像是“毫秒数据被吃掉了”。这个细节很不起眼,但一旦出现,你会误以为是 ticker 精度问题,其实是源头就丢了精度。
2.3 亚秒刻度为什么要单独处理
秒级以下的显示,是 DateTime ticker 里最别扭的一块。原因有两条。
第一,标签格式是全局固定的。你设成 “hh:mm:ss”,步长 0.2 秒时相邻刻度标签会完全一样,屏幕上出现一串重复的 “10:23:45”,看起来像是渲染错乱。设成 “hh:mm:ss.zzz” 又走向另一个极端:窗口拉到 10 分钟时,每根刻度后面都跟着 “.000”,非常占位置,在窄控件里直接挤成一团。
第二,QCPAxisTickerDateTime 的 getTickStep 在亚秒区间是“借”基类的漂亮数字规则,它不知道自己在处理时间,所以可能给出 0.2、0.5 这类步长,而这些步长和秒的进位关系不算整齐。刻度对齐又是在整秒上做的,最终结果是:前半屏刻度落在 .2 .4 .6 .8,后半屏因为范围变化跳到 .25 .5 .75。
这两个问题只能靠自定义 ticker 解决。思路不复杂:继承 QCPAxisTickerDateTime,在 getTickStep 里记录下本次算出来的步长,在 getTickLabel 里根据步长切换格式。
class AdaptiveTimeTicker : public QCPAxisTickerDateTime { public: explicit AdaptiveTimeTicker(QObject *parent = nullptr) : QCPAxisTickerDateTime(parent) {} protected: double getTickStep(const QCPRange &range) override { mLastStep = QCPAxisTickerDateTime::getTickStep(range); return mLastStep; } QString getTickLabel(double tick, const QLocale &locale, QChar formatChar, int precision) override { const QDateTime dt = QCPAxisTickerDateTime::keyToDateTime(tick); if (!dt.isValid()) return QString(); QString fmt; if (mLastStep < 1.0) fmt = QStringLiteral("hh:mm:ss.zzz"); else if (mLastStep < 60.0) fmt = QStringLiteral("hh:mm:ss"); else if (mLastStep < 3600.0) fmt = QStringLiteral("hh:mm"); else if (mLastStep < 86400.0) fmt = QStringLiteral("hh:mm\ndd.MM"); else fmt = QStringLiteral("dd.MM\nyy"); return locale.toString(dt, fmt); } private: double mLastStep = 1.0; };用起来跟原生 ticker 一样:
QSharedPointer<AdaptiveTimeTicker> ticker(new AdaptiveTimeTicker); ticker->setTickCount(6); ticker->setDateTimeSpec(Qt::LocalTime); plot->xAxis->setTicker(ticker);这段代码我用了大概两年,覆盖了从 100 毫秒窗口到 30 天窗口的所有场景,没有出现重复标签和格式错位。要留意的只有一个:getTickLabel 里的 locale 参数最好直接用传来的那个,别自己 new 一个 QLocale,否则在切换系统语言时会出现数字格式不一致。
3. 从零搭一条毫秒级的实时滚动时间轴
3.1 坐标轴与 ticker 的初始化配置
先说初始化。这段代码看着简单,但顺序不能乱:先设 ticker,再设范围,最后加数据。如果先加数据再设 ticker,添加数据那一刻触发的自动 rescale 会用数值轴的方式处理,把范围拉到 1.7e9 附近,后面你设的窗口就被顶掉了。
QCustomPlot *plot = new QCustomPlot(this); QSharedPointer<AdaptiveTimeTicker> ticker(new AdaptiveTimeTicker); ticker->setTickCount(6); ticker->setDateTimeSpec(Qt::LocalTime); plot->xAxis->setTicker(ticker); plot->xAxis->setLabel(QStringLiteral("时间")); plot->yAxis->setLabel(QStringLiteral("幅值")); plot->setInteractions(QCP::iRangeDrag | QCP::iRangeZoom); plot->axisRect()->setRangeDrag(Qt::Horizontal); plot->axisRect()->setRangeZoom(Qt::Horizontal); plot->addGraph(); plot->graph(0)->setPen(QPen(QColor(0, 120, 215))); plot->graph(0)->setLineStyle(QCPGraph::lsLine); plot->graph(0)->setScatterStyle(QCPScatterStyle::ssNone); const double now = nowKey(); plot->xAxis->setRange(now - 30.0, now); plot->yAxis->setRange(-1.0, 1.0); plot->replot();这里有几个取舍值得说。拖动和缩放只开水平方向,是因为纵轴通常有业务含义(比如 ±1 的归一化幅值),开了纵向拖拽之后画面会被拖飞,用户很难拖回来。另外,setRange 一次性传 QCPRange 而不是调 setRangeLower / setRangeUpper 两次,能少触发一次重绘,在 30 FPS 的刷新节奏下这点开销不算小。
3.2 数据喂入:时间戳到 key 的正确换算
数据入口是整条链路上最容易出问题的地方。我给自己定的规矩是:传感器、网络、文件读出来的原始时间戳,一律在入口处转成秒级 key,之后任何环节都不再出现毫秒数。
// 反面教材:直接把毫秒当 x 用 void onSampleBad(qint64 msEpoch, double value) { plot->graph(0)->addData(static_cast<double>(msEpoch), value); } // 正确做法:入口处就换算 void onSample(qint64 msEpoch, double value) { const double key = msEpoch / 1000.0; plot->graph(0)->addData(key, value); }如果数据源是 QDateTime,那就更直接:
void onSample(const QDateTime &dt, double value) { plot->graph(0)->addData(toPlotKey(dt), value); }但这里有个细节:当数据不是严格递增时,addData 会做一次有序插入,时间复杂度是 O(log n) 查找加 O(n) 搬移。如果数据源有抖动或者网络乱序,插入位置经常落在中间,几百个点每秒的速率下 CPU 就开始发热了。缓冲区足够小的场景下感受不明显,缓冲区到几万个点就会卡。
QCPGraph 提供了批量接口,能一次性替换全部数据并声明“已经有序”:
plot->graph(0)->setData(keys, values, /*alreadySorted=*/true);第三个参数默认是 false,会触发内部排序。既然我们的数据本来就是按时间顺序追加的,直接传 true 能省掉一次完整的排序。这个参数我在早期版本里不知道,每次都让它在 60000 个点上排一遍,光这一项就吃掉了 5~8 毫秒。
3.3 滑动窗口与数据裁剪
实时曲线不可能无限增长,必须裁剪。QCPGraph 自带 removeDataBefore,用起来最省事:
void onSample(qint64 msEpoch, double value) { const double key = msEpoch / 1000.0; plot->graph(0)->addData(key, value); static int counter = 0; if (++counter % 100 == 0) // 每 100 个点裁一次,避免每帧 O(n) plot->graph(0)->data()->removeBefore(key - kKeepSeconds); }这里的关键是“别每帧都裁”。removeBefore 内部是遍历容器删除,复杂度 O(n),如果按 30 FPS 调,每秒就是 30 次全量遍历。改成每 100 个点裁一次,代价立刻降下来,代价是最多会多留 100 个点的内存,对现代机器毫无压力。
另一个方案是自己维护缓冲区,配合 setData 整体替换。这个方案在点数极多、要求严格定长时更合适:
class RingBuffer { public: explicit RingBuffer(int capacity) : mCap(capacity) { mKeys.reserve(capacity); mValues.reserve(capacity); } void push(double key, double value) { mKeys.append(key); mValues.append(value); if (mKeys.size() > mCap) { const int drop = mKeys.size() - mCap; mKeys.remove(0, drop); mValues.remove(0, drop); } } const QVector<double> &keys() const { return mKeys; } const QVector<double> &values() const { return mValues; } private: int mCap; QVector<double> mKeys; QVector<double> mValues; };QVector::remove(0, drop) 本身也是搬移,但 drop 通常很小(就是个位数),比每帧全量遍历便宜得多。如果连这点搬移都想省,可以上环形下标加定期重建,不过那是三万点以上的优化,一般监控界面用不上。
3.4 刷新节奏与性能控制
数据和刷新要解耦。数据来了就存,刷新按固定节拍走。这是所有实时绘图的基本盘,QCustomPlot 也一样。
QTimer *refreshTimer = new QTimer(this); refreshTimer->setInterval(33); // 约 30 FPS connect(refreshTimer, &QTimer::timeout, this, [this]() { const double key = nowKey(); mBuffer.push(key, mCurrentValue); plot->graph(0)->setData(mBuffer.keys(), mBuffer.values(), true); plot->xAxis->setRange(key - kWindowSeconds, key); plot->replot(QCustomPlot::rpQueuedReplot); }); refreshTimer->start();rpQueuedReplot 是 2.0 之后提供的一个很实用的开关:把重绘请求排队合并,同一帧内多次调用 replot 只会真正绘制一次。如果代码里散落着若干个可能触发重绘的地方(比如报警颜色切换、标注移动),这个参数能显著减少无效绘制。
另一个经常被忽略的开关是 OpenGL 加速。setOpenGl(true) 在点数多、线条密的时候确实能提帧,但它跟某些平台的驱动有兼容性问题,而且一旦开启,就不能再混用 QCPLayer 的自定义绘制。我的经验是:常规监控界面(几千点以内)别开,开了收益不明显还多一层风险;只有在 5 万点以上、且确认目标机器显卡驱动稳定的时候再考虑,并且做好开关配置,出问题能立刻关掉。
4. 精度这笔账:double 到底扛不扛得住毫秒
4.1 ULP 计算与两个量级的对比
“double 存秒级时间戳,毫秒会不会丢”这个问题我被问过很多次,答案是不但不会丢,而且用秒比用毫秒更安全。这里把账算清楚。
IEEE 754 双精度有 52 位尾数,加上隐含的 1 位,有效位是 53 位。一个数值 x 的相邻可表示值间隔(ULP)约为 x 乘以 2 的 -52 次方。
近几年(2020 年代)的秒级时间戳大约在 1.7e9,落在 [2^30, 2^31) 这个二进制区间。这个区间内 ULP = 2^(30-52) = 2^-22 秒,约等于 2.4e-7 秒,也就是 0.24 微秒。毫秒是 1e-3 秒,比 ULP 大了 4000 多倍,绰绰有余。
再看毫秒时间戳,大约是 1.7e12,落在 [2^40, 2^41) 这个区间。ULP = 2^(40-52) = 2^-12 秒,约等于 2.44e-4 秒,也就是 0.244 毫秒。这就麻烦了——毫秒级的采样间隔和 ULP 处在同一量级,相邻 1 毫秒的两个时间戳,在 double 里可能因为舍入落到同一个可表示值上,或者被推成 1.2 毫秒、0.8 毫秒这样的抖动值。排序看起来没错,但放大到毫秒级去看,点与点之间的间距是抖的。
| 时间表示 | 典型值 | 二进制区间 | ULP | 毫秒分辨能力 |
|---|---|---|---|---|
| 秒级 double | 1.7e9 | [2^30, 2^31) | 约 0.24 微秒 | 充裕 |
| 毫秒级 double | 1.7e12 | [2^40, 2^41) | 约 0.244 毫秒 | 临界抖动 |
| 微秒级 double | 1.7e15 | [2^50, 2^51) | 约 0.244 秒 | 完全不可用 |
结论很明确:想保留毫秒精度,就该用秒级 double,而不是毫秒级 double。这个结论跟直觉相反,很多人以为“用毫秒单位就更精确”,实际上恰好把有效位数浪费在了整数部分。
4.2 相对时间基准的折中方案
有些场合确实需要一个绝对意义上的毫秒数,比如从外部设备拿到的就是毫秒计数,而设备又不是从 1970 年开始计时的。这种时候可以用相对基准:选一个会话开始的时刻作为零点,之后的 key 都是“距零点多少秒”。
class RelativeKey { public: RelativeKey() { mBaseMs = QDateTime::currentMSecsSinceEpoch(); } double fromMs(qint64 msEpoch) const { return (msEpoch - mBaseMs) / 1000.0; } qint64 toMs(double key) const { return mBaseMs + static_cast<qint64>(key * 1000.0 + 0.5); } double baseKeyForTicker() const { return mBaseMs / 1000.0; } private: qint64 mBaseMs; };用相对 key 之后,数值范围从几十万秒起步,量级小很多,ULP 也更精细。代价是 ticker 不能直接吃这个 key 去显示绝对时间,需要在 getTickLabel 里加上基准偏移:
QString getTickLabel(double tick, const QLocale &locale, QChar formatChar, int precision) override { const double absoluteKey = tick + mBaseOffset; const QDateTime dt = QCPAxisTickerDateTime::keyToDateTime(absoluteKey); return locale.toString(dt, QStringLiteral("hh:mm:ss.zzz")); }这个方案我从一个长时间运行的采集系统里学的,那边单次会话跑几十个小时,采样间隔 2 毫秒,用绝对秒 key 其实也够,但加上基准之后 ULP 会随运行时长慢慢变大,从长期稳定性考虑做了相对化。实际测试下来,两种方案在 24 小时窗口内肉眼无差别,属于可选优化,不是必选项。
4.3 采样与显示精度不一致时的处理
还有一种情况容易被忽略:数据本身的精度和界面显示的精度不一致。
设备给的采样间隔标称是 10 毫秒,但实际打点可能落在 9.8~10.3 毫秒之间抖动。你按 100 Hz 去画,看起来点间距是均匀的,实际上有轻微的时间抖动。这时候如果把窗口缩到 200 毫秒,抖动会变成肉眼可见的“点疏密不均”。很多人第一反应是怀疑绘制引擎,其实是源数据就这样。
处理思路有两种。一是接受抖动,把窗口下限限制住,不让用户缩到能看清抖动的程度:
connect(plot->xAxis, QOverload<const QCPRange &>::of(&QCPAxis::rangeChanged), this, [this](const QCPRange &range) { const double minSpan = 0.2; // 最小可视窗口 200 毫秒 if (range.size() < minSpan) { const double center = range.center(); plot->xAxis->setRange(center - minSpan / 2.0, center + minSpan / 2.0); } });二是重采样。按固定的 10 毫秒网格做线性插值,输出均匀时间戳,抖动就消失了。但要注意重采样会引入相位延迟,实时性要求高的场合慎用,而且重采样之后再和原始数据对比排查问题会很麻烦。
我个人的偏好是第一种,加窗口下限。理由是:抖动是真实存在的物理现象,把它藏起来容易误导使用者,不如限制可视范围,同时在界面上注明采样精度。
5. 踩坑实录与速查表
5.1 常见现象、原因与处理
下面这张表是我这几年攒下来的,每次出问题先扫一遍,绝大多数能直接命中。
| 现象 | 最可能的原因 | 处理方式 |
|---|---|---|
| 曲线完全不显示,图例正常 | 数据 key 是毫秒,轴范围是秒 | 统一在入口处除以 1000.0 |
| 刻度标签年份离谱(几万年) | 轴范围被乘了 1000 | 去掉多余的乘 1000 |
| 标签显示同一秒重复多次 | 亚秒步长配了秒级格式 | 自定义 ticker 按步长切格式 |
| 时间标签整体平移几小时 | 时区语义不一致 | 统一 DateTimeSpec 为 LocalTime 或 UTC |
| 放大后刻度忽然变稀疏 | ticker 自动切到分钟/小时单位 | 调大 tickCount 或缩范围 |
| 缩放后最初几帧刻度闪跳 | 范围非整数导致步长计算抖动 | 设最小窗口 + 对齐范围 |
| 数据点稀疏不均 | 源时间戳抖动 | 限制最小可视窗口 |
| 长时间运行后界面越来越卡 | 未裁剪历史数据 | removeBefore 或环形缓冲 |
| 拖动时卡顿 | replot 调用过于频繁 | 用 rpQueuedReplot |
| 双击自动缩放到空白 | 空数据触发 rescale | 数据为空时跳过 rescale |
这张表里,前三行占了所有问题的八成。写代码的时候把这三条守住,时间轴基本不会出大岔子。
5.2 几个更隐蔽的坑
第一坑:setRange 传入了零宽度范围。当窗口拉到一个极限,或者某次计算出错导致上下限相等时,QCPRange 的尺寸为 0,ticker 在算步长时会拿 0 去除,得到 0 或 NaN,刻度要么没有,要么分布得离奇。防护很简单:
void setTimeWindow(double centerKey, double spanSeconds) { const double minSpan = 0.01; // 10 毫秒下限 if (spanSeconds < minSpan) spanSeconds = minSpan; plot->xAxis->setRange(centerKey - spanSeconds / 2.0, centerKey + spanSeconds / 2.0); }第二坑:跨天之后标签只显示时分秒。默认格式 “hh:mm:ss” 在窗口拉到 12 小时以上时,屏幕上所有标签都是 00:00:00 到 23:59:59 的循环,根本分不清是哪天。这个在长时间运行的监控界面上特别致命,尤其是故障回溯的时候,你以为看到的是当天凌晨,其实是前一天。前面那段 AdaptiveTimeTicker 里的格式分支已经处理了这个,步长超过 3600 秒就自动带上日期。
第三坑:clearData 之后没有重设范围。有些场景需要清空重来(比如切换设备、重新开始采集),清空数据之后 x 轴范围还停在旧的时间段,新数据进来时会发现范围还在过去,曲线看不见。清空的时候顺手把范围推到当前时刻:
void resetAll() { plot->graph(0)->data()->clear(); const double now = nowKey(); plot->xAxis->setRange(now, now + 30.0); plot->replot(); }第四坑:自动 rescale 和手动范围打架。在数据量少的时候(比如刚开始采集,只有两三个点),rescaleAxes 会把范围压到极窄,ticker 算出来的步长远小于 1 毫秒,标签全变成同样的毫秒值。这种情况可以给 rescale 加个保护,只在点数超过阈值时才自动缩放。
if (plot->graph(0)->dataCount() > 10) plot->rescaleAxes();第五坑:QCPGraph 的 setData 传了 alreadySorted=true,但数据其实没排好。这个不会报错,只会让绘制出来的曲线出现来回折返的尖刺,一旦出现极难排查,因为数据本身看着是对的,只是顺序错了。我的做法是:只有在明确用了追加式缓冲、且缓冲本身保证单调时才传 true;数据来源不确定时,宁可让它多排一次。
5.3 上线前自检清单
最后把我自己每次上线前会过一遍的检查项列出来,都是踩过之后才想起来的。
- 全工程搜索 “1000”,确认没有遗留的时间乘法;除了精度说明的注释,任何一处出现都要过一遍脑子。
- 全工程搜索 “currentMSecsSinceEpoch”,确认每一处都跟着除以 1000.0。
- 全工程搜索 “fromSecsSinceEpoch” 和 “fromMSecsSinceEpoch”,确认时区语义一致,没有一处手动换算覆盖了 ticker 的换算。
- 把窗口从 100 毫秒拖到 24 小时,观察每个量级下标签是否可读、是否有重复、是否带日期。
- 断开数据源一分钟再恢复,确认曲线衔接处没有断层(断层通常意味着某处用了本地时间做了差)。
- 连续挂机 4 小时以上,观察内存曲线是否平稳,确认裁剪逻辑真的生效。
- 在最小化窗口、恢复窗口、切换 DPI 之后各看一眼时间轴,这几个操作最容易暴露范围被意外重置的问题。
这套检查流程走下来大概要二十分钟,但能省掉后面几天的返工。时间轴的问题是典型的“上线后才发现”类型,在开发机上数据源稳定、时区单一、跑几分钟就关了,绝大部分问题都藏得住。
我个人在实际项目里的体会是,QCustomPlot 的时间轴难不在 API,难在这套“秒为单位的 double 约定”是隐式的,编译器不会提醒你,运行期也不会报错,它只会安安静静地把图给你画歪。所以真正管用的不是记住几个函数,而是把换算收敛到一两个函数里,让整条链路只有一个地方可能出错。等你哪天接手一个别人写的、到处都在乘 1000 的工程,就会明白这个约定有多值钱。
最后再分享一个小技巧:调试时间轴的时候,临时在图的右上角加一个文本标注,把当前 x 轴的上下限原样打印出来(不用格式化,直接 toDouble 转字符串,保留 6 位小数),再对比数据点的 x 值。两个数字摆在一起看,量级差、时区差、单位差全都藏不住,比打断点加单步快得多。这个标注我通常留着不删,改成只在调试构建里显示,后面每次遇到时间相关的怪问题,第一眼就能看见。