简介:面向需要掌握 QCustomPlot 高级绘图能力的 Qt 开发者,这份代码工程围绕多坐标系核心机制展开,完整演示了如何通过 QCPAxisRect 创建多个坐标系、动态添加坐标轴区域、实现共享 X 轴联动,以及利用双击事件切换坐标系颜色。压缩包共 10 个文件,以 4 个 cpp 源文件与 3 个 h 头文件为主,附带 ui 界面设计文件与 pro 工程文件,整体仅 241KB,结构精简,便于直接打开工程对照学习。作为一份可运行的示例项目,它展示了多坐标系布局的关键 API 调用流程,读者可从中获得坐标轴矩形管理、轴联动配置、双击事件交互处理等实用代码范式,对于需要在一张图表中同时呈现不同尺度数据、对比多组曲线的场景极具参考价值。已有 3880 人学习下载,适合希望在项目中快速搭建复杂 2D 图表界面的中高级 Qt 开发者。
1. 多坐标系不是多画几条曲线,而是把轴的归属想清楚
做数据采集上位机的人大概率遇到过这种场景:同一块面板里要同时显示温度和压力,温度在 0 到 100 之间波动,压力轻松上到几千帕。如果两条曲线挂在同一条 Y 轴上,小温度曲线会被压成一条平线,毛细节都看不到。这时候直觉反应是「用 QCustomPlot 的多坐标系」。qcustomplot 多坐标系真正的含义,不是把一个控件里塞多个绘图区,而是搞清楚轴与图元之间的绑定关系,让每条曲线挂到属于它自己的坐标系上。本文会把这些轴的创建、绑定、布局和联动讲清楚,适合正在做仪器仪表、嵌入式上位机、数据可视化面板的 Qt 开发者。
2. 坐标系是怎么搭起来的:axisRect、轴与曲线的绑定关系
2.1 一个 Plot 能装几个坐标系:从 layout 这张表开始数
QCustomPlot 里,整个绘图控件叫 QCustomPlot,它内部不是一个直接画图的面板,而是有一个排版系统 plotLayout。plotLayout 是一个网格布局,默认只有一行一列,里面放着一个 QCPAxisRect。每次你在代码里直接调用plot->addGraph()然后往默认轴上 setData,数据最终都落到这同一个 axisRect 上。
一个坐标系的最小单位不是 QCustomPlot,而是 QCPAxisRect。一个 QCPAxisRect 可以理解为一块矩形的绘图区域,它自带四条轴线:上、下、左、右。默认情况下只有下轴(xAxis)和左轴(yAxis)是可见的,上轴和右轴虽然对象存在,但默认不显示刻度也不显示轴线。
所以「一个 Plot 里能装几个坐标系」这个问题的答案很明确:数量上没有硬限制,你可以在 plotLayout 里塞多个 QCPAxisRect,每个 rect 都有自己的四条轴和独立的刻度范围;即使只用一个 rect,你也可以通过开启右侧轴来容纳第二条 Y 轴数据。前者叫多绘图区,后者叫单绘图区多轴,两者都是多坐标系应用里的常见形态。
我一般建议按数据的物理意义来选:如果两组数据的横轴完全相同,只是纵轴量级不同,优先用同一个 axisRect 加右侧 Y 轴;如果横轴含义都不同,比如上面是时域波形、下面是频谱,就应该拆成两个 axisRect。这样布局上更容易对齐,图例和网格也能各自独立。
2.2 曲线跟轴走还是轴跟曲线走:keyAxis 与 valueAxis 的绑定逻辑
很多新手在这里绕晕:明明是往一个 Plot 里 addGraph,为什么曲线会出现在另一个坐标系里?原因是 QCustomPlot 里图元(QCPGraph)不是直接挂在 axisRect 上的,而是挂在轴上。每条 graph 有两个重要的轴指针:keyAxis 表示横轴,valueAxis 表示纵轴。默认情况下 addGraph 创建的 graph,keyAxis 指向主 rect 的下轴,valueAxis 指向主 rect 的左轴。
如果你新建了一个 QCPAxisRect,也新建了它内部的轴,但忘记把 graph 的 keyAxis 和 valueAxis 指过去,那么这条曲线还是画在默认坐标系上。这不是 QCustomPlot 自动帮你识别「哪个坐标系合适」,它没有这个智能,绑定关系完全靠你自己维护。
另外还要注意,一条曲线可以横轴用 A 坐标系的轴、纵轴用 B 坐标系的轴,这在语法上是合法的。比如你把 keyAxis 设为上图的下轴,valueAxis 设为下图的上轴,QCustomPlot 会把图元画到以这两条轴交叉定义的坐标系里,形状可能很怪,但不会报错。这种灵活性是双刃剑,排查问题时首先要检查的就是每个 graph 的轴归属。
2.3 先跑一个最小示例:同一面板挂两条不同纵轴的曲线
先把最常见的场景跑通:同一个 xAxis,左边一条 Y 轴显示温度,右边一条 Y 轴显示压力。代码如下:
QCustomPlot *plot = new QCustomPlot(this); QVector<double> x = {1, 2, 3, 4, 5}; QVector<double> temperature = {36.2, 36.5, 36.8, 37.1, 36.9}; QVector<double> pressure = {1024, 2048, 3072, 4096, 5120}; // 第一条曲线:使用默认的左Y轴 QCPGraph *tempGraph = plot->addGraph(); tempGraph->setData(x, temperature); tempGraph->setPen(QPen(QColor("#d32f2f"))); tempGraph->setName("温度"); // 启用右侧Y轴作为第二条值轴 plot->axisRect()->addAxis(QCPAxis::atRight); QCPAxis *rightAxis = plot->axisRect()->axis(QCPAxis::atRight); // 第二条曲线:横轴仍旧是默认下轴,值轴换成右侧轴 QCPGraph *pressureGraph = plot->addGraph(); pressureGraph->setKeyAxis(plot->xAxis); pressureGraph->setValueAxis(rightAxis); pressureGraph->setData(x, pressure); pressureGraph->setPen(QPen(QColor("#1976d2"))); pressureGraph->setName("压力"); plot->legend->setVisible(true); plot->rescaleAxes(); plot->replot();这段代码的关键是第二处 addGraph 之后的 setKeyAxis 和 setValueAxis。如果不做这两步,pressureGraph 会与 tempGraph 共用左轴,压力数据会瞬间把温度曲线压成一条平线。addAxis(QCPAxis::atRight)的作用是在默认 axisRect 上创建或激活右侧轴,axis(QCPAxis::atRight) 拿到的是该轴的指针,后续设置范围、标签、颜色都靠它。rescaleAxes()会遍历当前 plotLayout 中所有可见坐标轴并统一调整范围,让所有曲线都完整显示,这在初次跑通时很有用,但后续做精细布局时往往要换成手动 setRange。
3. 同一个面板里建多坐标系:共享 X 轴多 Y 轴的写法与参数
3.1 右侧加一条值轴前,先想好三件事:显示、标签、范围
用addAxis(QCPAxis::atRight)把右侧轴拉出来后,右侧轴默认是不显示的,很多人在这里翻车:代码写了,轴也加了,但界面上没有任何变化。原因很简单,QCustomPlot 的默认轴(xAxis、yAxis)在创建时就设为可见,而右侧轴对象存在但 visible 属性默认为 false。你需要主动设置:
QCPAxis *rightAxis = plot->axisRect()->axis(QCPAxis::atRight); rightAxis->setVisible(true); rightAxis->setLabel("压力(Pa)"); rightAxis->setTickLabelColor(QColor("#1976d2")); rightAxis->setLabelColor(QColor("#1976d2"));这段代码里 setVisible(true) 是最容易漏的一行。右侧轴显示出来后,建议同时设置轴标签和刻度颜色,并且与曲线颜色保持一致。这样看图的人扫一眼就知道蓝色刻度对应蓝色曲线,红色刻度对应温度曲线,不需要额外做图例说明。
范围也要提前想好。如果数据是实时采集的,压力可能从 1000 跳到 6000,轴范围如果写死,曲线很容易冲出视野。我一般分两种情况处理:固定量程的仪表类场景,用 setRange(min, max) 写死;采集类场景,在数据更新时调用 pressureGraph->rescaleValueAxis(true),让纵轴跟随当前数据自动缩放,横轴则保持时间窗口滚动。
3.2 刻度、网格与曲线颜色:三个地方必须同步
多 Y 轴共用一个绘图区时,最容易出现的视觉混乱是:左右两条 Y 轴的刻度都显示在面板里,网格线叠在一起,分不清哪条刻度线属于哪条曲线。默认情况下,右侧轴一旦可见,也会像左轴一样绘制自己的网格线,两条轴的刻度值大小完全不同,网格在图上会形成两层交错的水平线,非常干扰读图。
解决方案是把右侧轴的网格关掉,只保留左轴的网格。网格是绘图区背景上的东西,不是轴本身的数据,所以关掉右侧轴的 grid 不会影响任何曲线:
rightAxis->grid()->setVisible(false);如果你希望右侧轴也有网格,只是换个颜色区分,也可以设置 grid 的 pen。但根据我的经验,同一绘图区里网格画两套基本没有正面效果,关掉右轴网格是常态做法。
刻度颜色、轴标签颜色、曲线颜色三处要保持一致,这个习惯能在后期省很多事。颜色同步这件事没有 API 能一键完成,需要在代码里维护一个「曲线参数表」之类的结构体,每次新增曲线时把 pen、tickLabelColor、labelColor 一起设置。不要让颜色散落在各个 setter 调用里,后面维护起来会非常痛苦。
3.3 对数轴与线性轴并存:坐标类型切换的几个关键设置
多坐标系里还有一种常见需求:同一面板的右轴用对数刻度,左轴用线性刻度。温度变化是线性的,压力可能跨两三个数量级,线性轴画出来小数值段完全看不见。QCustomPlot 切换到对数坐标比较简单,但有几个边界条件必须处理:
rightAxis->setScaleType(QCPAxis::stLogarithmic); QSharedPointer<QCPAxisTickerLog> logTicker(new QCPAxisTickerLog); logTicker->setLogBase(10.0); logTicker->setSubTickCount(8); rightAxis->setTicker(logTicker);第一,setScaleType 必须在创建 ticker 之前或之后都可以,但要保证 ticker 类型与 scaleType 匹配。用线性 ticker 配对数轴,刻度位置会乱。第二,对数轴的范围下限必须是正数,也就是说 setRange(0, 10000) 在这种轴上会失效,因为 0 的对数没有定义。第三,subTickCount 控制每个主刻度之间的细分刻度数量,对数轴的子刻度不是等距的,数量设置要看你的数据跨度,跨度大就多设几个。
对数轴上线后,rescaleAxes 的行为也要留意。QCustomPlot 的自动缩放会对对数轴取对数后计算范围,如果你的数据里有 0 或负值,rescaleAxes 会得到非法范围,整条曲线可能直接不显示。实时数据场景下,我通常会在数据更新的回调里判断最小值是否大于 0,再做 rescale。
4. 多个面板才是真多坐标系:分栏 axisRect 的布局与独立缩放
4.1 上下叠两个坐标系:addAxisRect 与 plotLayout 行分配
当横轴含义不同、上下两幅图需要独立滚动时,就要往 plotLayout 里加新的 QCPAxisRect。默认的 plotLayout 是一行一列,加入第二个 axisRect 后需要把它放到第二行。写法如下:
QCPAxisRect *mainRect = plot->axisRect(); QCPAxisRect *subRect = new QCPAxisRect(plot); plot->plotLayout()->addElement(1, 0, subRect); plot->plotLayout()->setRowStretchFactor(0, 3); plot->plotLayout()->setRowStretchFactor(1, 1);关键在于 setRowStretchFactor。如果不设,两行会等高,主坐标系和子坐标系平分上下空间。如果上方是主波形,下方是辅助频谱,把比例设为 3:1 更符合阅读习惯。addElement(1, 0) 的意思是放入第一行第零列,行号从 0 开始,0 行已经放着默认 axisRect,所以新增的一定从 1 开始。
subRect 在创建时默认也带四条轴,和主 rect 一样,只有左右下三条可见。对这个坐标系里的曲线做绑定,基本逻辑和第 2 章的 graph 绑定完全一致,只是指向的轴要换成 subRect 自己的:
QCPAxis *subX = subRect->axis(QCPAxis::atBottom); QCPAxis *subY = subRect->axis(QCPAxis::atLeft); QCPGraph *fftGraph = plot->addGraph(); fftGraph->setKeyAxis(subX); fftGraph->setValueAxis(subY); fftGraph->setPen(QPen(QColor("#388e3c")));从这段代码能看到,addGraph 仍然作用在 plot 上,所以 QCustomPlot 的图元列表是全局的,坐标系只是图元的绘制宿主。addGraph 的次数不受 axisRect 数量限制,同一时刻你可以在一个 graph 里绑定任意 rect 的任意轴。
4.2 每个坐标系独立缩放:setRange 与 rescaleAxes 的作用域
多个 axisRect 并存后,「独立」两个字是核心诉求。上下两幅图的数据量级和关注点不同,缩放时必须各管各的。这里有三个层级的作用域,一定要分清楚:
plot->rescaleAxes() 作用于整个 plot 内所有可见轴,不分 rect。这条命令在多坐标系下基本不要用,因为你大概率只希望下幅图重新适应数据,上幅图保持用户手动缩放的位置。graph->rescaleValueAxis(true) 只作用于该 graph 绑定的值轴,横轴不动。最精确的是直接对轴调用 setRange。
鼠标交互层面,QCustomPlot 的默认行为是:鼠标在哪个 axisRect 上拖动,就缩放哪个 rect 的轴。这个行为在单 rect 时感觉不明显,多 rect 之后才会意识到它设计得挺合理。但要注意,如果你设置了 axisRect->setRangeDragAxes 或 setRangeZoomAxes,把两个 rect 的轴混在了一起,交互就会串。这些 setter 接收的是 QCPAxis 指针,不是 rect 指针,所以完全可以把主坐标系的 X 轴和子坐标系的 X 轴绑到同一个拖拽行为上,实现两个坐标系横向同步滚动。
4.3 坐标系之间的联动与取消联动:margin 处理与拖拽冲突
有时我们希望两个坐标系的上限对齐,或者纵轴范围同步,这需要显式连接轴信号。QCPAxis 有一个 rangeChanged 信号,在用户拖动或缩放该轴时触发,可以把它转发给另一个坐标系的轴:
connect(mainRect->axis(QCPAxis::atBottom), &QCPAxis::rangeChanged, this, [subRect](const QCPRange &range) { subRect->axis(QCPAxis::atBottom)->setRange(range); });这种联动的代价是:一旦连接建立,两个坐标系的下轴会互相锁定,用户在下幅图横向拖动时,上幅图也会跟着动。这不是 bug,是联动本身的设计。如果你想做「单向联动」,比如只允许主坐标系带动子坐标系,子坐标系反过来不能带动主坐标系,那就要在 rangeChanged 信号里加一个标志位,或者把子坐标轴的交互关掉。
还有一个 margin 的坑:新增 axisRect 之后,默认不会给第二个轴留出足够的左边距,子坐标系的 Y 轴标签可能会被裁剪。遇到这种情况,需要手动调整 rect 的 margins:
subRect->setAutoMargins(QCP::msLeft | QCP::msRight); subRect->setMargins(QMargins(55, 0, 5, 0));我一般用 setAutoMargins 保留左右方向的自动计算,再额外给左边加固定边距,确保多位数的轴标签不会跟图形边缘重叠。
5. 多坐标系避坑:我每次都会踩的 5 个坑与现场处理
5.1 曲线显示在另一个坐标系里:axisRect 归属没设
现象:明明新建了 subRect,也把 graph 的 valueAxis 指向了 subRect 的左轴,但曲线还是画在主坐标系上。
原因:graph 的 keyAxis 没有设置,默认仍指向主坐标系的下轴。keyAxis 和 valueAxis 必须成对设置,只改 valueAxis 会让图元的两条轴分别属于两个坐标系列,绘制位置会落在以这两条轴为边界的坐标系里,外观上经常表现为曲线出现在主 rect 的奇怪位置。
解决:绑定 graph 时写完 setValueAxis 紧接着写 setKeyAxis。我习惯把绑定写成两行连着的代码,并注释「轴必须显式指定,默认值只对第一个 rect 有意义」。
5.2 缩放一个坐标系导致另一个也跟着动:range 拖拽的交叉响应
现象:在子坐标系上滚轮缩放,主坐标系的横轴也跟着缩放,两个 rect 的画面同步跳跃。
原因:最常见的是你不小心用了 plot->xAxis 作为子坐标系的 keyAxis,主坐标系的下轴和子坐标系的下轴是同一个指针,缩放当然联动。另一个原因是 rangeChanged 联动连接过多,形成了双向绑定。
解决:第一步检查每个 graph 的 keyAxis 是否真的属于 subRect,用 qDebug 打印 axis 指针地址,主轴的地址和子轴地址必须不同。第二步检查 connect 的联动是否只建立了单向。多坐标系里指针复用是最隐蔽的坑,因为代码编译能过、运行时也不报错,只有行为异常。
5.3 两套刻度线与网格错位:tick 与 subTick 没调
现象:主轴的网格线密度正常,右侧轴或子坐标系的网格线特别疏或特别密,与曲线数据看上去对不上。
原因:每个轴有自己独立的 ticker,子坐标系的 ticker 不会自动跟随主轴的步长。量级相差大的数据,默认 ticker 生成的步长可能相差十倍以上,网格线当然对不上。
解决:为每个轴显式创建 QCPAxisTicker 并设置 tickStep。如果子坐标系只是辅助参考,直接把子坐标系的网格关掉,只保留主轴网格,视觉上更干净。网格只是背景参考,不允许它在多坐标系下喧宾夺主。
5.4 隐藏坐标系后图例还占着位置:legend 与 axisRect 的绑定
现象:把子坐标系通过 setVisible(false) 隐藏后,主坐标系下方多出一块空白,或者图例跑到看不到的位置。
原因:legend 默认只对应主 axisRect。QCustomPlot 的 legend 本质上是一个 QCPLayoutGrid 里的条目容器,它显示在哪个 rect 区域由所属 layout 决定。隐藏 rect 时如果 legend 仍然挂在这个 rect 里,它不会跟着隐藏。
解决:隐藏子坐标系前,先把 legend 切到主 rect 上。更稳妥的做法是多坐标系场景下干脆使用继承的 QCPLegend 对象,并显式设置 legend 所属的 axisRect 后,再统一控制可见性。涉及多个 rect 的布局操作,不要依赖默认归属,所有属性都显式指定。
5.5 析构时崩溃或悬挂指针:axis 的生命周期归属
现象:程序退出时崩溃,或者 removeGraph 后再次访问 axis 指针报野指针错误。
原因:QCustomPlot 对轴和图元的管理是「谁创建谁拥有」。axisRect 属于 plotLayout,轴属于 axisRect,graph 属于 QCustomPlot 的 plottable 列表。直接 delete 一个轴,但 graph 的 valueAxis 还指向它,replot 时就会访问已释放的内存。
解决:删除轴的顺序必须先解除 graph 对它的引用。常见安全顺序是:先 plot->removeGraph(graph),再 delete axis。删除 axis 用 plot->axisRect()->removeAxis 或直接 delete,取决于 QCustomPlot 版本。最保险的析构习惯是只 delete 顶层的 QCustomPlot 对象,让所有子对象按归属链自动析构,不要手动逐个 delete 轴和 rect。
6. 坐标系统一缩放的技巧:把主坐标系的 range 传给其余坐标系
多坐标系做多了之后,会发现真正高频的需求是:主坐标系横向滚动时,子坐标系跟着滚动;主坐标系手动缩放时,子坐标系同步缩放。第 4 章提过可以用 rangeChanged 信号转发,但实际上有一个更稳的写法,能避免信号递归。代码如下:
bool syncingRanges = false; connect(mainRect->axis(QCPAxis::atBottom), &QCPAxis::rangeChanged, this, [this, subRect, subX](const QCPRange &range) { if (syncingRanges) return; syncingRanges = true; subX->setRange(range); syncingRanges = false; }); connect(subX, &QCPAxis::rangeChanged, this, [this, mainRect](const QCPRange &range) { if (syncingRanges) return; syncingRanges = true; mainRect->axis(QCPAxis::atBottom)->setRange(range); syncingRanges = false; });亮点是 syncingRanges 标志位。两个轴互发 rangeChanged 时,如果不加标志位,A setRange 触发 B 的信号,B setRange 再触发 A 的信号,会形成无限递归。加了标志位后,信号回调里主动 setRange 引起的后续信号会被忽略,数据不会死循环。
这个技巧解决了多坐标系里最烦人的信号风暴问题。但要注意,QCPAxis 的 rangeChanged 是在轴范围实际变化时发出的,如果两个坐标系的数据长度和比例不同,同步后子坐标系可能显示得很空。所以我一般只同步横向范围,纵向范围各坐标系独立自动缩放——模拟量波形和频谱分析这两种场景,纵轴同步反而会让辅助图失去意义。
关于 QCustomPlot 版本差异,我也想提一句:2.x 之后信号机制的细节和 1.x 有些不一样,rangeChanged 的递归行为在 1.x 上更严重,务必在目标版本上实测一次信号风暴。不要在理论上觉得自己想清楚了就跳过验证,这是我在这上面交过学费的地方。希望帮到你。
本文还有配套的精品资源,点击获取