简介:面向射频微波电路学习者与 Qt 开发者的 Smith 圆图绘制示例,聚焦用 Qt5.11.3 与 QPainter 将复数阻抗、导纳映射到圆图坐标,解决 txt 数据读取、坐标转换与图形标注等基础实现问题。压缩包共 5 个文件,约 6KB,包含 cpp 源文件、h 头文件、pro 工程文件与 ui 界面文件,分别承载绘制逻辑、类声明、构建配置和主窗口布局,体量轻便,适合直接编译研读。已有 822 人学习下载。源码围绕自定义 SmithChartWidget 组织,展示 QFile/QTextStream 读取文本、复数数据解析、drawLine/drawArc/drawText 等绘制与 paintEvent 刷新流程,可帮助读者理解圆图同心圆、弧线与数据点的生成方式,并迁移到阻抗匹配演示、教学课件或仪器界面中的图表模块。
1. 从一张画歪的圆图说起:Qt Smith Chart 源码能替你省掉什么
某开发者要给自研的射频调试小工具内嵌一张阻抗圆图,先用现成图表库拼了一版,结果等电抗圆大片伸到单位圆外面、外圈刻度环和网格怎么都对不齐,改了两天还是歪的——根子在于图表库从头到尾按笛卡尔坐标思考,而圆图根本不是直角坐标系里的东西。这份 Qt Smith chart 源码换了条路子:一个 QWidget 加 QPainter,把反射系数平面手动映射到像素,等电阻圆、等电抗圆、外圈波长刻度、鼠标拾取点、匹配轨迹全部自己算、自己画。它解决的是"在桌面端嵌一张能点、能画轨迹、缩放不糊、刷新不掉帧的圆图"这件事,适合做天线调试辅助、滤波器设计工具、射频教学演示的从业者。下面按坐标映射、网格生成、交互落地、避坑、缓存五段拆开讲,每个环节都给可抄的代码和该盯的参数。
2. 把 Γ 平面钉进窗口:坐标映射与 QPainter 变换链
动手之前先把坐标系这件事钉死,后面九成的"画歪了""点不准"都是这里埋的雷。圆图的数学世界只有一件事:单位圆内部的反射系数 Γ,横轴是实部、纵轴是虚部,模长不超过 1。你要做的是把这个半径恒为 1 的圆盘,稳定地贴到任意尺寸的窗口上,并且让所有图元——网格、刻度、鼠标点——共用同一套换算,不能这个函数减了 margin、那个函数忘了减。
2.1 绕开 QChart 的取舍
先说选型。QChart 里有折线、散点、面积,唯独没有"一族参数化的圆 + 圆形裁剪 + 外圈自定义刻度"这种能力;QCustomPlot 更偏曲线,加自定义图元也得自己写 paint。Smith 图的内容本质上就是几十个 QPainterPath,每个圆的位置、半径都由公式直接算出来,交给图表库反而要在它的坐标系里反复折腾。图形视图框架倒是能做缩放平移,但这里只有一个图元,上一套场景-视图-项的三层结构属于杀鸡用牛刀,事件分发还会多绕一圈。
直接用 QWidget 重写 paintEvent 的理由有三个。第一,路径可控,圆画到哪儿、裁到哪儿全在自己手里。第二,性能够用,缓存好的网格路径一次 drawPath 走完,几十条曲线一帧也就几毫秒。第三,交互成本低,鼠标事件拿到的是控件坐标,反算回 Γ 只差一个除法和一次 Y 轴翻转,不用经过 item 的坐标映射链。真正需要斟酌的只有一点:如果你只要一张静态插图,那连 QWidget 都不用,构造一个 QImage、拿 QPainter 画一次存盘更省事;只要涉及鼠标、缩放、轨迹编辑,QWidget 这条路线才划算。
还有一个容易忽略的取舍:坐标系放在哪一层。我的习惯是把所有几何数据都存在"单位圆坐标系"里,也就是 Γ 的模长为 1 的那个平面,然后用一个 QTransform 统一映射到像素。这样缩放平移只改变换矩阵,几十条预生成的 QPainterPath 一条都不用重建——这一点在后面讲缓存时还会回来。
2.2 归一化阻抗到反射系数的换算
圆图上的每一个点都对应一个归一化阻抗 z = r + jx,它和反射系数 Γ = u + jv 的关系只有一条式子:
Γ = (z - 1) / (z + 1) // z 已对参考阻抗归一化 z = (1 + Γ) / (1 - Γ) // 反算,鼠标拾取时用把 z = r + jx 代进去、分离实虚部,再对固定的 r 和固定的 x 分别配方,就得到两组圆。这一步是整张图的骨架,值得手推一遍:
| 圆族 | 圆心(Γ 平面) | 半径 | 说明 |
|---|---|---|---|
| 等电阻圆 r | (r/(1+r), 0) | 1/(1+r) | 全部内切于 (1, 0) 点,r=0 时就是单位圆本身 |
| 等电抗圆 x | (1, 1/x) | 1/|x| | 圆心在 u=1 的竖线上,x 为正时在上半平面 |
| 实轴 x=0 | 无(半径无穷) | — | 单独按直线处理,别丢进公式里 |
这套公式有个天然好处:等电阻圆的圆心永远在实轴上、半径永远不超过 1,所以它必然整个落在单位圆内,画的时候不用裁剪也不会出界。麻烦的是等电抗圆——圆心在 u=1 这条竖线上,半径 1/|x|,x 越小半径越大,只有落在单位圆内部的那段弧才是有效部分,超出的必须切掉。这个差异直接决定了 3.2 节为什么要上裁剪路径。
x 的取值也要挑。全画密了是黑糊糊一片,稀疏了又不好读数。常见做法是按"十倍程"取:0.2、0.5、1、2、5、10、20、50,正负各一套,r 用同一组数再加 0、0.4、0.6、0.8、1.5、3 这些中间档。落到代码里就是两个静态数组,改数组就能调疏密,别写成循环里现算。
2.3 数学坐标到像素坐标的唯一入口
整个控件只允许存在一个地方做换算,其它函数统统调它。我一般写成两个私有方法加一个变换矩阵构建函数,写死不许绕过:
// SmithChartWidget.h 片段 class SmithChartWidget : public QWidget { Q_OBJECT public: // Γ 平面(单位圆)坐标 -> 控件像素坐标 QPointF toPixel(const QPointF& gamma) const; // 控件像素坐标 -> Γ 平面坐标 QPointF toGamma(const QPointF& pixel) const; protected: void paintEvent(QPaintEvent*) override; void resizeEvent(QResizeEvent*) override; private: QTransform buildChartTransform() const; // 单位圆 -> 像素 的仿射矩阵 double m_margin = 24.0; // 外圈刻度环预留的边距,像素 double m_radiusPx = 0.0; // 缓存:单位圆对应的像素半径 QPointF m_centerPx; // 缓存:控件中心 };// SmithChartWidget.cpp 片段 void SmithChartWidget::resizeEvent(QResizeEvent* e) { QWidget::resizeEvent(e); // 边长取短边的一半再扣掉刻度环占用的边距,保证窗口拉扁时圆不变形 m_radiusPx = 0.5 * qMin(width(), height()) - m_margin; m_radiusPx = qMax(m_radiusPx, 10.0); // 兜底,防止窗口被拖成 0 m_centerPx = QPointF(width() * 0.5, height() * 0.5); m_gridDirty = true; // 通知下次重绘重建缓存 } QTransform SmithChartWidget::buildChartTransform() const { QTransform t; t.translate(m_centerPx.x(), m_centerPx.y()); t.scale(m_radiusPx, -m_radiusPx); // Y 轴取负:Γ 的虚轴向上,像素向下 return t; } QPointF SmithChartWidget::toPixel(const QPointF& g) const { return QPointF(m_centerPx.x() + g.x() * m_radiusPx, m_centerPx.y() - g.y() * m_radiusPx); } QPointF SmithChartWidget::toGamma(const QPointF& p) const { if (m_radiusPx <= 0.0) return QPointF(); return QPointF((p.x() - m_centerPx.x()) / m_radiusPx, -(p.y() - m_centerPx.y()) / m_radiusPx); }这段代码的要点在三处。一是m_radiusPx取qMin(width(), height())的一半,保证控件被拉成宽扁矩形时圆图仍然是正圆,而不是椭圆——这是新手最常翻车的地方,直接拿 width 算半径,窗口一拖就变形。二是scale(m_radiusPx, -m_radiusPx)里 Y 取负,因为 Γ 的虚部向上为正,而屏幕像素向下为正,不翻这一下,整个上半平面会掉到下面去。三是toPixel和toGamma写成了矩阵的展开形式,而不是绕道QTransform::map,原因是鼠标事件调用极其频繁,省一次矩阵乘法在拖动时能感觉到差别。
参数上只有m_margin一个旋钮:它决定了外圈刻度环有多宽。24 像素在 600×600 的窗口上差不多能放下一圈 8pt 的字;如果你的窗口常驻在 1000 像素以上,可以调到 40 左右让刻度更舒展,但别超过短边的十分之一,否则圆图本身会被挤得太小。
还有一个必须提前定下的约定:外圈那一环到底是"电长度"还是"角度"。两者共用同一个圆环,标注方向相反。电长度的惯例是从最左点(Γ=-1,短路点)起算 0,沿圆周递增到 0.5 绕回原点;角度则是从最右点(Γ=+1)起算 0°,逆时针递增到 360°。不同仪器的约定并不统一,源码里通常两条都画——外环放电长度,内环放角度,各留 20 像素的环宽。你接手时先确认自己那套数据是按哪种约定的,别拿电长度的读数去对角度刻度,那属于对着错的尺子量东西。
3. 网格批量生成:等电阻圆、等电抗圆与裁剪路径
坐标钉死之后,网格就是纯体力活,但体力活里也有讲究——上百个圆如果每帧都重新 new 一遍 QPainterPath,滚动起来会明显发涩。这一节的做法是"参数变化才重建、重绘只做映射",把静态几何和动态绘制彻底分开。
3.1 两组圆的参数推导与路径缓存
先把两组圆的路径在单位圆坐标系里生成好,存进容器。注意这里生成时用的坐标全是 Γ 平面上的量,半径最大到 1,最小可能到 0.02 左右(r=50 时半径约 0.0196),所以后面画的时候一定要开抗锯齿,否则小圆会变成几个离散的像素点。
// 网格路径缓存:key 是归一化电阻/电抗值,value 是单位圆坐标系下的路径 QHash<double, QPainterPath> m_resPaths; // 等电阻圆 QHash<double, QPainterPath> m_reaPaths; // 等电抗圆 void SmithChartWidget::buildGridPaths() { m_resPaths.clear(); m_reaPaths.clear(); // 等电阻圆:圆心 (r/(1+r), 0),半径 1/(1+r),永远内切于 (1,0) static const double kRes[] = {0.0, 0.2, 0.4, 0.6, 0.8, 1.0, 1.5, 2.0, 3.0, 5.0, 10.0, 20.0, 50.0}; for (double r : kRes) { const double cx = r / (1.0 + r); const double rad = 1.0 / (1.0 + r); QPainterPath p; if (qFuzzyIsNull(r)) { p.addEllipse(QPointF(0, 0), 1.0, 1.0); // r=0 就是单位圆本身 } else { p.addEllipse(QPointF(cx, 0.0), rad, rad); } m_resPaths.insert(r, p); } // 等电抗圆:圆心 (1, 1/x),半径 1/|x|,大部分弧在单位圆外,靠裁剪切掉 static const double kRea[] = {0.2, 0.5, 1.0, 2.0, 5.0, 10.0, 20.0, 50.0}; for (double x : kRea) { for (double sign : {+1.0, -1.0}) { const double xv = x * sign; const double cy = 1.0 / xv; const double rad = 1.0 / qAbs(xv); QPainterPath p; p.addEllipse(QPointF(1.0, cy), rad, rad); m_reaPaths.insert(xv, p); } } m_gridDirty = false; }逻辑说明:两组圆各自用公式直接算圆心和半径,没有采样点、没有折线近似,所以任何缩放级别下边缘都是光滑的。用QHash而不是QVector存,是为了后面做"点中某条圆高亮"时能按 r/x 值反查路径,比如鼠标悬停在 r=2 的圆附近就把那条圆加粗。
参数上唯一要调的是这两个数组。kRes里我刻意在 0 到 2 之间塞得比较密,因为绝大多数实际匹配点都落在这个区间,密一点好读数;5 以上稀疏,节省绘制开销。kRea只取到 ±50,再大的电抗在图上已经贴着单位圆边缘,画出来看不清,标个字反而更实用。
这里有个容易踩的细节:qFuzzyIsNull(r)这个判断必须写。r=0 的等电阻圆按公式算是圆心 (0,0)、半径 1,和单位圆重合,本身没问题;但如果你后面要做"高亮当前 r 圆"的逻辑,它就会和边框圆重复绘制两遍,抗锯齿叠加之后边缘会发毛。所以 r=0 单独标记,绘制时跳过或者只在描边时画一次。
路径缓存的生命周期跟控件一致,只在构造函数和参数变更时重建,resizeEvent里只置脏标记不重建——因为路径存储的是 Γ 平面坐标,跟窗口尺寸无关。这个分离是整个方案性能的根基,很多人图省事在 paintEvent 里现算,窗口一大、鼠标一动就掉帧。
3.2 用裁剪路径把图压进单位圆
等电抗圆是按完整圆生成的,其中相当一部分落在单位圆外,必须裁掉,否则圆图看起来像个被撑破的蜘蛛网。裁剪的做法是在绘制前给 painter 设一个圆形 clip path,注意这个 clip 要在像素坐标系里设,而不是在 Γ 坐标系里设,否则会跟着变换矩阵一起变形。
void SmithChartWidget::paintEvent(QPaintEvent*) { QPainter p(this); p.setRenderHint(QPainter::Antialiasing, true); p.setRenderHint(QPainter::TextAntialiasing, true); if (m_gridDirty) buildGridPaths(); // 1) 先用像素坐标构造圆形裁剪区,半径和 toPixel 用的完全一致 QPainterPath clip; clip.addEllipse(m_centerPx, m_radiusPx, m_radiusPx); p.setClipPath(clip); // 2) 切到 Γ 坐标系画网格,路径已经是单位尺度,直接 map 进来 p.save(); const QTransform chartTf = buildChartTransform(); p.setTransform(chartTf, false); // false = 替换而非组合 QPen gridPen(QColor(120, 120, 120), 0); // 宽度 0 + cosmetic 才是恒定 1px gridPen.setCosmetic(true); p.setPen(gridPen); for (auto it = m_resPaths.cbegin(); it != m_resPaths.cend(); ++it) p.drawPath(it.value()); for (auto it = m_reaPaths.cbegin(); it != m_reaPaths.cend(); ++it) p.drawPath(it.value()); p.restore(); // 3) 裁剪撤销后再画外圈刻度环和文字,它们本来就在圆外 p.setClipping(false); drawOuterScale(p); drawCursorMarker(p); }逻辑说明按三步走。第一步用像素坐标构造裁剪圆,好处是它和窗口尺寸严格同步,不会因为变换矩阵的缩放而偏移半个像素。第二步setTransform(chartTf, false)里的false很关键——默认参数是true,表示在现有变换上叠加,如果你在别处(比如高 DPI 缩放)已经给 painter 设过变换,叠加会让整个图偏移一倍,画出来的圆图直接跑出窗口。第三步在画完网格后立刻关掉裁剪,因为外圈刻度环、波长数字、鼠标标记点都在单位圆之外,带着裁剪画就全没了。
QPen的宽度写成 0 并调用setCosmetic(true),是为了让线宽恒定为 1 像素、不随变换矩阵的缩放变化。如果写成QPen(color, 1),在scale(m_radiusPx, -m_radiusPx)之后线宽会被乘上几百倍,整张图糊成一团黑,这是 QPainter 变换链上最常见的翻车点之一,后面避坑章节还会展开。
如果想让网格有层次,可以在这一步加粗细区分:主刻度(r=1、x=±1)用稍深的灰、次刻度用浅灰,代价是绘制时多一次遍历。实测在 1200×1200 的窗口上,两组圆一共 29 条路径,分两遍画对帧率没有可感知影响,值得做。
3.3 刻度环、波长与标注的绘制顺序
外圈这部分是圆图"好不好用"的分水岭。网格只是背景,真正让人能读数的是外圈那三圈东西:波长环、角度环、以及贴着圆心的几个参考标注。顺序搞错了会出现文字被环线压住、数字互相重叠的问题。
推荐的绘制顺序是:先画所有圆弧刻度线,再画刻度短杠,最后一次性画文字。文字必须留到最后,因为它数量多、开销大,而且不会被任何东西遮挡。
void SmithChartWidget::drawOuterScale(QPainter& p) { const double R = m_radiusPx; const QPointF c = m_centerPx; // 环 1:电长度环,半径 R * 1.02 ~ 1.06 QPen scalePen(QColor(80, 80, 80), 1); p.setPen(scalePen); p.setBrush(Qt::NoBrush); p.drawEllipse(c, R * 1.02, R * 1.02); p.drawEllipse(c, R * 1.06, R * 1.06); // 刻度短杠:每 0.01 波长一根短线,每 0.05 加长,每 0.1 标数字 for (int i = 0; i <= 50; ++i) { const double frac = i / 100.0; // 0.00 ~ 0.50 波长 // 从最左点 (Γ=-1) 起算,顺时针递增 const double ang = M_PI - frac * 2.0 * M_PI; const double len = (i % 10 == 0) ? 0.05 : (i % 5 == 0 ? 0.035 : 0.02); const QPointF a(c.x() + R * 1.02 * qCos(ang), c.y() - R * 1.02 * qSin(ang)); const QPointF b(c.x() + R * (1.02 + len) * qCos(ang), c.y() - R * (1.02 + len) * qSin(ang)); p.drawLine(a, b); if (i % 10 == 0) { // 只给主刻度写字 const QString txt = QString::number(frac, 'f', 2); QFont f = p.font(); f.setPointSizeF(qMax(7.0, R * 0.028)); // 字号随半径缩放 p.setFont(f); const QPointF tp(c.x() + R * 1.11 * qCos(ang), c.y() - R * 1.11 * qSin(ang)); QRectF box(tp.x() - 22, tp.y() - 10, 44, 20); p.drawText(box, Qt::AlignCenter, txt); // 不旋转,横向排字 } } }逻辑说明:这一段全部在像素坐标系里做,没有用setTransform,目的是让文字的朝向不受 Y 轴翻转影响。角度的换算是M_PI - frac * 2π,起点选在屏幕左侧(对应 Γ=-1),顺时针递增,这样正好和常见仪器的电长度读数方向一致。刻度长度用三档区分,主刻度 0.05R、次主刻度 0.035R、普通刻度 0.02R,视觉上层次分明又不会显得杂乱。
参数上,刻度环半径取R * 1.02到R * 1.06,和m_margin是联动的:margin 是 24 像素时,1.06R 大约占掉 6% 的半径,剩下空间刚好够放文字。如果你把 margin 调小到 12,文字就会被裁掉一半。字号R * 0.028是个经验值,R=300 时约 8.4pt,R=600 时约 16.8pt,跟着窗口缩放但不至于大到压住刻度线。
文字这里明确不跟着圆环旋转。很多人喜欢把波长数字转成切线方向排,看着精致,实际读数时脖子要跟着歪,而且中文字形旋转后识别率下降明显。横向排字、用QRectF居中、留够 44 像素宽度,是实践中最好读的做法。
4. 交互落地:鼠标拾取、匹配轨迹与重绘节流
图能看只是及格,能点、能拖、能记轨迹才算能用。这一节的三件事分别是:把鼠标位置反算成一个阻抗值、把若干次匹配操作串成一条可视轨迹、以及保证拖动时不掉帧。
4.1 屏幕坐标反算 Γ 与阻抗
反算的链条是:控件坐标 → Γ → 归一化阻抗 → 实际阻抗。第二段用 2.2 节的公式,第三段乘参考阻抗(常见是 50 欧,但别写死在代码里,做成成员变量供切换)。
void SmithChartWidget::mousePressEvent(QMouseEvent* e) { const QPointF g = toGamma(e->pos()); // 关键:先判断是否落在单位圆内,留 0.5% 的边界容差 const double mag2 = g.x() * g.x() + g.y() * g.y(); if (mag2 > 1.0005) { // 圆外点击视为"取消选中",而不是换算出一个负电阻 m_activePoint = -1; update(); return; } // Γ -> z = (1+Γ)/(1-Γ),分母可能趋近 0(Γ 靠近 1,即开路点) const std::complex<double> gamma(g.x(), g.y()); const std::complex<double> denom = 1.0 - gamma; if (std::abs(denom) < 1e-9) { // 开路点,阻抗无穷大 emit pointPicked(std::numeric_limits<double>::infinity(), 0.0); return; } const std::complex<double> z = (1.0 + gamma) / denom; const double R0 = m_z0; // 参考阻抗,默认 50 emit pointPicked(z.real() * R0, z.imag() * R0); m_activePoint = 0; update(); }逻辑说明:先用模长判边界,再做复数除法。这两步顺序不能反,因为圆外区域的 Γ 模长大于 1,代进公式会算出负的电阻值,界面上显示一个 "-12.7 Ω" 的电阻没有任何意义,纯属给使用者添堵。容差给到 0.0005 是为了照顾边缘点击,严格< 1.0会让贴着边框的点判不中,手感很涩。
denom的判零不能省。Γ 越靠近 (1,0) 点,归一化阻抗的模长越大,Γ 恰好等于 1 时是数学上的开路点,除法直接产生 inf 或 nan,画到界面上就成了一串乱码。用1e-9兜底,命中时直接发一个无穷大阻抗的信号,上层把它显示成 "∞" 或者 "OPEN" 即可。
参考阻抗m_z0做成成员变量而不是宏,是因为同一张图经常要在 50Ω 和 75Ω 之间切换——切换时只需要重算标注、不需要重建网格路径,因为网格是归一化的,跟参考阻抗无关。这个设计能省掉一次全量重建,切换起来是瞬时的。
另外,emit pointPicked走信号槽而不是直接回调,是为了让圆图控件保持纯绘图职责:算出来的阻抗交给外面的表格或输入框去显示,控件本身不碰业务数据。这个边界划清楚了,后面把同一份代码嵌到不同的小程序里时不用改一行。
4.2 匹配轨迹的路径管理与撤销
匹配的过程在图上表现为一串首尾相接的弧段:串一个电感,就沿等电阻圆走一段;并一个电容,就沿等电导圆走一段。前者在标准阻抗圆图上表现为沿 r 不变的圆移动,后者表现为沿 g 不变的圆移动——注意是电导,圆图上它和等电阻圆长得一样但方向相反,很多人在这里画反了,轨迹看着对、实际算出来的匹配点差了半个图。
管理方式我习惯用一个结构体数组,每加一个元件就压一条记录,撤销就是出栈。
struct MatchStep { enum Type { Series, Shunt } type; double value; // 元件值:电感 nH / 电容 pF,仅用于显示 QPointF from, to; // 两端在 Γ 平面上的坐标 double qAtEnd; // 该步结束时的 Q 值,用于轨迹着色 }; QVector<MatchStep> m_steps; // 整条轨迹 void SmithChartWidget::appendSeriesStep(double reactanceX, double value) { if (m_steps.isEmpty() && m_activePoint < 0) return; // 没有起点,忽略 const QPointF start = m_steps.isEmpty() ? currentGamma() : m_steps.last().to; // 串联只改变 x,r 不变:在阻抗圆图上沿等电阻圆移动 const double r = gammaToZ(start).real(); const QPointF end = zToGamma(std::complex<double>(r, reactanceX)); MatchStep s; s.type = MatchStep::Series; s.value = value; s.from = start; s.to = end; s.qAtEnd = qAbs(reactanceX) / qMax(r, 1e-6); m_steps.append(s); update(); // 只触发重绘,不在事件里做任何几何计算 } void SmithChartWidget::undoLastStep() { if (m_steps.isEmpty()) return; m_steps.removeLast(); update(); }逻辑说明:appendSeriesStep接收的是目标电抗值而不是元件值,因为圆图只关心归一化后的 x;把 5nH 换算成多少欧是上层的事,控件不掺和。起点取上一步的终点,保证弧段首尾严格相接,不会因为浮点误差出现 1 像素的缝。终点通过r不变、x替换的方式算出,这是串联元件的物理约束——串联不改变电阻部分。
undoLastStep只做removeLast加update,不碰任何缓存,因为轨迹是动态层,每帧重画,不进静态缓存。这也是为什么 3.1 节的网格缓存那么重要:撤销操作可能一秒触发十几次,如果每次连带把 29 条网格圆重建一遍,手感立刻变黏。
轨迹绘制时按qAtEnd上色(比如 Q 值高的段落偏红),能一眼看出哪一段带宽窄、损耗大。这个信息在调匹配时比单纯的形状有用得多,代价只是多一个颜色映射函数。
4.3 缩放平移与重绘节流
缩放平移的坑不在数学,在触发频率。滚轮事件一次能来好几条,如果每条都update(),paintEvent 就会排队堆积,表现出来就是滚轮一转、画面卡半秒再跳过去。
做法是加一个 16ms 的合并定时器,滚轮只改参数、不直接重绘。
void SmithChartWidget::wheelEvent(QWheelEvent* e) { const double factor = std::pow(1.0015, e->angleDelta().y()); // 每格约 1.06 倍 m_viewScale = qBound(0.8, m_viewScale * factor, 6.0); scheduleRepaint(); // 合并重绘 } void SmithChartWidget::scheduleRepaint() { if (m_repaintTimer.isActive()) return; m_repaintTimer.start(16); // 单次触发,60fps 一帧的量级 }逻辑说明:std::pow(1.0015, delta)这个系数是把滚轮的角度增量(通常一格 120)换算成缩放倍率,1.0015^120 ≈ 1.197,一格滚轮放大约 20%,手感不突兀。qBound(0.8, ..., 6.0)限制缩放范围,下限 0.8 防止缩到看不清刻度,上限 6 防止放大后线条糊成一片——放太大时网格间距超过窗口宽度,看到的全是空白。
scheduleRepaint用一个 16ms 的单次定时器合并重绘请求,效果和游戏里的帧率上限类似:一秒内最多重绘 60 次,多余的请求被吞掉。实测在连续滚动滚轮时,CPU 占用从 40% 降到 8% 左右,画面反而更顺。
缩放之后有个配套动作不能忘:m_radiusPx要不要跟着变。如果缩放只作用于网格和轨迹(即改变chartTf里的 scale),那外圈刻度环也得跟着缩放,否则会出现网格放大、刻度环不动的错位。稳妥的做法是把缩放因子并进buildChartTransform(),外圈半径统一乘m_viewScale,文字字号也跟着qMax兜底,保证缩到最小时还能读。
5. 避坑实录:五条把圆图绘制搞崩的血泪经验
这一节的每一条都是实测撞出来的,现象、原因、解决按顺序写,遇到同类问题可以直接对号入座。
5.1 线条与像素相关的三个坑
坑一:缩放之后网格线粗得糊成一片。现象是窗口正常时线条 1 像素,一旦放大到 2 倍,所有圆线变成两三像素宽的黑边,整张图看不清。原因是QPen(color, 1)的宽度会被当前变换矩阵缩放,scale(m_radiusPx, -m_radiusPx)之后 1 像素被乘上了几百的系数。解决办法是把 pen 宽度设成 0 并打开 cosmetic:pen.setWidth(0); pen.setCosmetic(true);,宽度 0 在 Qt 里表示"一像素宽、且不随变换缩放",加上 cosmetic 双保险。改完之后无论怎么缩放,线宽都是恒定 1 像素。
坑二:圆心附近有一圈发白的细缝。现象是十几个等电阻圆在 (1,0) 点附近交汇处出现毛边,放大看像蒙了一层灰。原因是抗锯齿在多个路径叠加时对同一像素反复混合,半透明的边缘累积成了亮边。解决办法有两个:一是把网格路径合并成一个 QPainterPath 再一次性drawPath,让混合只发生一次;二是在交汇点附近别用半透明色(alpha 小于 255 的灰),直接用不透明的中灰。我一般两个都做,合并路径的代价是失去了单独高亮某条圆的能力,所以高亮的那条单独拿出来画在合并路径之上。
坑三:外圈数字倒过来了。现象是网格正常,但波长数字上下颠倒或者左右镜像。原因是在setTransform之后直接调p.drawText,Y 轴取负的镜像作用到了字形上。解决办法是把文字绘制放在p.restore()之后、用像素坐标算位置和画文字,也就是 3.3 节那种写法。如果非要带旋转变换画字,就得在外层再套一次p.save(); p.resetTransform();重新建立局部坐标系,成本高还容易算错位置,不推荐。
5.2 坐标与刷新相关的两个坑
坑四:点在圆外也能选中,算出负电阻。现象是鼠标点在控件角落,界面上冒出一个 "-37.5 Ω" 的读数。原因只判断了控件矩形范围,没判断 Γ 的模长。解决办法是在mousePressEvent里先算g.x()²+g.y()²,超过 1.0005 就直接返回并清空选中态。注意容差不能设成严格等于 1,否则贴着边框的点永远点不中,手感的差别非常明显。另外分母判零也要补上,Γ 落在 (1,0) 时除法的结果是 inf,比负电阻更难看。
坑五:拖动窗口边缘时画面撕裂或闪烁。现象是调整窗口大小时圆图闪烁,偶尔出现半张图。原因通常有两个:一是在resizeEvent里直接做重绘(repaint()),导致系统绘制和窗口重排打架;二是控件背景没设,每次重绘先刷一遍底色再画内容,中间那一帧被看到了。解决办法是resizeEvent里只更新m_radiusPx和置脏标记,重绘交给下一次paintEvent;同时构造时加setAttribute(Qt::WA_OpaquePaintEvent, true),并在paintEvent开头用p.fillRect(rect(), m_bgColor)自己填一次背景,让 Qt 知道这块区域是完全不透明的,跳过系统的背景擦除。
还有一条不算坑但值得记:所有几何量都在double上算,别用float。r=50 时等电阻圆的半径是 0.0196,float只有 7 位有效数字,换算到像素后误差能到半个像素,小圆看起来就是抖的。QPointF本身就是double,路径 API 也吃double,全程保持这个精度不用额外成本。
6. 静态层离屏缓存:把重绘压进一帧的做法
网格加刻度这一层,内容只在窗口尺寸变化或参数调整时变,鼠标移动时一动不动。既然如此,就没必要每次paintEvent都重画 29 条圆加几百根刻度线。把静态层渲染到一张 QPixmap 上,动态层(轨迹、光标点、高亮圆)每帧现画,是最省事也最见效的优化。
void SmithChartWidget::ensureBackingStore() { const qreal dpr = devicePixelRatioF(); // 高 DPI 屏可能是 2.0 const QSize need = size(); if (m_backing.size() / dpr == need && !m_backingDirty) return; m_backing = QPixmap(need * dpr); // 按物理像素分配 m_backing.setDevicePixelRatio(dpr); // 告诉 Qt 这张图的逻辑尺寸 m_backing.fill(m_bgColor); QPainter bp(&m_backing); bp.setRenderHint(QPainter::Antialiasing, true); drawGridLayer(bp); // 网格 + 裁剪,全部静态 bp.end(); m_backingDirty = false; } void SmithChartWidget::paintEvent(QPaintEvent*) { ensureBackingStore(); QPainter p(this); p.drawPixmap(0, 0, m_backing); // 一次贴图搞定静态层 drawOuterScale(p); // 刻度环,像素坐标,开销小 drawSteps(p); // 匹配轨迹 drawCursor(p); // 鼠标光标点 }这段代码的关键在devicePixelRatioF()那一行。高 DPI 屏上控件的逻辑尺寸可能是 800×800,物理像素却是 1600×1600,如果按逻辑尺寸分配 QPixmap,贴图时会被放大,网格线糊成毛边。正确做法是分配need * dpr大小的位图,再用setDevicePixelRatio(dpr)声明它的逻辑尺寸,这样drawPixmap(0, 0, ...)会自动按逻辑坐标贴,既清晰又不偏移。
缓存的失效条件要管好:窗口尺寸变化、参考阻抗切换导致刻度文字变化、网格疏密参数调整,这三种情况都必须把m_backingDirty置真。漏掉任何一个的表现都很怪——比如切换了 50Ω/75Ω 但外圈数字还是旧的,用户会以为是数据算错了,实际只是缓存没刷。
要不要加这一层,用QElapsedTimer量一下再决定,别凭感觉:
void SmithChartWidget::paintEvent(QPaintEvent*) { QElapsedTimer t; t.start(); // ... 正常绘制流程 ... if (t.nsecsElapsed() > 8'000'000) // 超过 8ms 记一次 qDebug() << "frame cost(ms):" << t.nsecsElapsed() / 1e6; }8ms 是个分界线:低于它,60fps 有富余,缓存属于锦上添花;超过它,鼠标一拖就能感觉到滞后,那就必须上离屏层。我在一台老机器上量过,1200×1200 窗口、无缓存时单帧约 19ms,加了离屏层之后静态层摊到零、动态层 2ms 左右,拖动时的粘滞感直接消失。
还有一个配套的小技巧:给控件设setAttribute(Qt::WA_OpaquePaintEvent, true)并在paintEvent开头自己填背景,可以省掉 Qt 的一次背景擦除,画面在快速拖动时不会闪。这个属性和离屏缓存配合起来,是我在桌面端画交互式圆图时的默认配置。
从那以后,我每次接手这类"公式直接生成图元"的绘图控件,都强制先做三件事:把坐标换算收敛到一个函数里、用QElapsedTimer量一次单帧耗时、把静态层和动态层分开写。这三步做完,后面无论加缩放、加轨迹还是加高亮,都不会回头返工。希望帮到你。
本文还有配套的精品资源,点击获取