温度、压力、电压等设备数据,通常需要通过曲线观察变化趋势。
刚开始实现时,最直接的方式是收到一个点就调用一次 append()。数据量较小时没有问题,但通道数和采样频率增加后,曲线重绘、坐标轴更新和历史数据保存都会带来开销。
更容易控制的方式是:采集时保存数据,界面按固定频率批量刷新。
一、创建曲线和坐标轴
下面使用 Qt Charts,显示两个相同量纲的测量通道。
项目配置:
find_package(Qt6 REQUIRED COMPONENTS Widgets Charts)
target_link_libraries(your_app PRIVATE
Qt6::Widgets
Qt6::Charts
)
曲线组件:
#include <QChart>
#include <QChartView>
#include <QLineSeries>
#include <QValueAxis>
#include <QTimer>
#include <QPainter>
#include <QVector>
#include <deque>
#include <cmath>
#include <algorithm>
class TrendView : public QChartView
{
public:
explicit TrendView(QWidget* parent = nullptr)
: QChartView(parent)
{
auto* chart = new QChart;
setChart(chart);
channel1_ = new QLineSeries;
channel2_ = new QLineSeries;
channel1_->setName("通道 1");
channel2_->setName("通道 2");
chart->addSeries(channel1_);
chart->addSeries(channel2_);
chart->setAnimationOptions(QChart::NoAnimation);
axisX_ = new QValueAxis;
axisY_ = new QValueAxis;
axisX_->setTitleText("采集时间 / s");
axisY_->setTitleText("温度 / ℃");
axisX_->setRange(0, 60);
axisY_->setRange(0, 100);
chart->addAxis(axisX_, Qt::AlignBottom);
chart->addAxis(axisY_, Qt::AlignLeft);
for (auto* series : {channel1_, channel2_}) {
series->attachAxis(axisX_);
series->attachAxis(axisY_);
}
setRenderHint(QPainter::Antialiasing);
connect(&refreshTimer_, &QTimer::timeout,
this, [this] { refresh(); });
refreshTimer_.start(100);
}
void appendSample(double seconds, double value1, double value2);
private:
void refresh();
struct Sample {
double time;
double value1;
double value2;
};
std::deque<Sample> history_;
QLineSeries* channel1_ = nullptr;
QLineSeries* channel2_ = nullptr;
QValueAxis* axisX_ = nullptr;
QValueAxis* axisY_ = nullptr;
QTimer refreshTimer_{this};
bool dirty_ = false;
};
界面刷新频率设为每秒 10 次。采样可以更快,两者不需要相同。
二、保存最近一段时间的数据
这里保留最近 60 秒,同时设置最大点数,避免异常情况下持续增长:
void TrendView::appendSample(
double seconds, double value1, double value2)
{
if (!std::isfinite(seconds) ||
!std::isfinite(value1) ||
!std::isfinite(value2))
return;
if (seconds < 0)
return;
if (!history_.empty() && seconds < history_.back().time)
return;
history_.push_back({seconds, value1, value2});
const double earliest = seconds - 60.0;
while (!history_.empty() &&
(history_.front().time < earliest ||
history_.size() > 20000)) {
history_.pop_front();
}
dirty_ = true;
}
时间参数使用本次采集开始后的秒数,并保证不倒退。每次重新开始采集时,应同步清空历史缓存并重置时间基准。
这个缓存用于实时显示,不等于完整历史记录。采样频率很高时,最大点数限制可能使实际保留时长少于 60 秒。
需要长期追溯的数据,应另外保存到文件或数据库。
三、批量更新多条曲线
刷新时一次替换整批显示数据:
void TrendView::refresh()
{
if (!dirty_ || history_.empty())
return;
QVector<QPointF> points1;
QVector<QPointF> points2;
points1.reserve(static_cast<qsizetype>(history_.size()));
points2.reserve(static_cast<qsizetype>(history_.size()));
for (const auto& sample : history_) {
points1.append(QPointF(sample.time, sample.value1));
points2.append(QPointF(sample.time, sample.value2));
}
channel1_->replace(points1);
channel2_->replace(points2);
const double latest = history_.back().time;
const double right = std::max(60.0, latest);
axisX_->setRange(right - 60.0, right);
dirty_ = false;
}
相比每收到一个点就调整一次坐标轴,这种方式更容易控制刷新次数。
示例固定了纵轴范围,适合已知测量范围的设备。如果需要自动缩放,建议给范围增加一点余量,并避免每次细微变化都重新设置纵轴,否则画面容易上下跳动。
温度和压力等不同量纲的数据,不宜直接共用同一个纵轴,可以分别使用左右坐标轴。
四、采样线程与显示线程如何衔接
曲线属于界面对象,只能在界面线程中更新。
采集线程可以通过信号传递数据:
connect(worker, &Worker::sampleReady,
trendView,
[trendView](double seconds, double value1, double value2) {
trendView->appendSample(seconds, value1, value2);
});
这里提供 trendView 作为回调上下文。跨线程发出信号时,回调会在它所属的界面线程执行。
如果采样频率很高,不建议每个点都跨线程发送一次信号。可以在工作线程中积累一批样本,再一次性交付,减少事件队列压力。
五、数据量增加后怎样优化
批量刷新并不意味着可以无限增加曲线点数。每次 replace() 都需要准备和处理整批点,窗口越长、通道越多,代价越高。
可以按显示区域宽度减少绘图点。例如,曲线区域只有 1000 个像素宽,显示几十万个点通常没有必要。
对于存在尖峰的设备数据,不建议只每隔若干点取一个值。更实用的方式是按时间区间分组,保留每组的最大值和最小值,这样较不容易遗漏短暂异常。
另外可以考虑:
- 关闭动画,减少无意义的过渡效果。
- 适当降低界面刷新频率。
- 缓存完整数据,只对显示数据降采样。
- 需要暂停观察时,冻结显示窗口,但继续采集和保存。
实时曲线首先要让人看清数据变化,没必要让界面刷新次数追上设备采样次数。