Qt 上位机实现实时曲线:多通道数据显示优化

791 阅读4分钟

温度、压力、电压等设备数据,通常需要通过曲线观察变化趋势。

刚开始实现时,最直接的方式是收到一个点就调用一次 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 个像素宽,显示几十万个点通常没有必要。

对于存在尖峰的设备数据,不建议只每隔若干点取一个值。更实用的方式是按时间区间分组,保留每组的最大值和最小值,这样较不容易遗漏短暂异常。

另外可以考虑:

  • 关闭动画,减少无意义的过渡效果。
  • 适当降低界面刷新频率。
  • 缓存完整数据,只对显示数据降采样。
  • 需要暂停观察时,冻结显示窗口,但继续采集和保存。

实时曲线首先要让人看清数据变化,没必要让界面刷新次数追上设备采样次数。