首页 / 资讯中心 / 文章详情

Qt Smith Chart源码实战:Γ平面坐标映射与网格离屏缓存

Qt Smith Chart源码实战:Γ平面坐标映射与网格离屏缓存 ★ FEATURED ARTICLE
简介面向射频微波电路学习者与 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/(1r), 0)1/(1r)全部内切于 (1, 0) 点r0 时就是单位圆本身等电抗圆 x(1, 1/x)1/|x|圆心在 u1 的竖线上x 为正时在上半平面实轴 x0无半径无穷—单独按直线处理别丢进公式里这套公式有个天然好处等电阻圆的圆心永远在实轴上、半径永远不超过 1所以它必然整个落在单位圆内画的时候不用裁剪也不会出界。麻烦的是等电抗圆——圆心在 u1 这条竖线上半径 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 左右r50 时半径约 0.0196所以后面画的时候一定要开抗锯齿否则小圆会变成几个离散的像素点。// 网格路径缓存key 是归一化电阻/电抗值value 是单位圆坐标系下的路径 QHashdouble, QPainterPath m_resPaths; // 等电阻圆 QHashdouble, QPainterPath m_reaPaths; // 等电抗圆 void SmithChartWidget::buildGridPaths() { m_resPaths.clear(); m_reaPaths.clear(); // 等电阻圆圆心 (r/(1r), 0)半径 1/(1r)永远内切于 (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); // r0 就是单位圆本身 } 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 值反查路径比如鼠标悬停在 r2 的圆附近就把那条圆加粗。参数上唯一要调的是这两个数组。kRes里我刻意在 0 到 2 之间塞得比较密因为绝大多数实际匹配点都落在这个区间密一点好读数5 以上稀疏节省绘制开销。kRea只取到 ±50再大的电抗在图上已经贴着单位圆边缘画出来看不清标个字反而更实用。这里有个容易踩的细节qFuzzyIsNull(r)这个判断必须写。r0 的等电阻圆按公式算是圆心 (0,0)、半径 1和单位圆重合本身没问题但如果你后面要做高亮当前 r 圆的逻辑它就会和边框圆重复绘制两遍抗锯齿叠加之后边缘会发毛。所以 r0 单独标记绘制时跳过或者只在描边时画一次。路径缓存的生命周期跟控件一致只在构造函数和参数变更时重建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 变换链上最常见的翻车点之一后面避坑章节还会展开。如果想让网格有层次可以在这一步加粗细区分主刻度r1、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是个经验值R300 时约 8.4ptR600 时约 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::complexdouble gamma(g.x(), g.y()); const std::complexdouble denom 1.0 - gamma; if (std::abs(denom) 1e-9) { // 开路点阻抗无穷大 emit pointPicked(std::numeric_limitsdouble::infinity(), 0.0); return; } const std::complexdouble 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 值用于轨迹着色 }; QVectorMatchStep 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; // 串联只改变 xr 不变在阻抗圆图上沿等电阻圆移动 const double r gammaToZ(start).real(); const QPointF end zToGamma(std::complexdouble(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 并打开 cosmeticpen.setWidth(0); pen.setCosmetic(true);宽度 0 在 Qt 里表示一像素宽、且不随变换缩放加上 cosmetic 双保险。改完之后无论怎么缩放线宽都是恒定 1 像素。坑二圆心附近有一圈发白的细缝。现象是十几个等电阻圆在 (1,0) 点附近交汇处出现毛边放大看像蒙了一层灰。原因是抗锯齿在多个路径叠加时对同一像素反复混合半透明的边缘累积成了亮边。解决办法有两个一是把网格路径合并成一个 QPainterPath 再一次性drawPath让混合只发生一次二是在交汇点附近别用半透明色alpha 小于 255 的灰直接用不透明的中灰。我一般两个都做合并路径的代价是失去了单独高亮某条圆的能力所以高亮的那条单独拿出来画在合并路径之上。坑三外圈数字倒过来了。现象是网格正常但波长数字上下颠倒或者左右镜像。原因是在setTransform之后直接调p.drawTextY 轴取负的镜像作用到了字形上。解决办法是把文字绘制放在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。r50 时等电阻圆的半径是 0.0196float只有 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() 8000000) // 超过 8ms 记一次 qDebug() frame cost(ms): t.nsecsElapsed() / 1e6; }8ms 是个分界线低于它60fps 有富余缓存属于锦上添花超过它鼠标一拖就能感觉到滞后那就必须上离屏层。我在一台老机器上量过1200×1200 窗口、无缓存时单帧约 19ms加了离屏层之后静态层摊到零、动态层 2ms 左右拖动时的粘滞感直接消失。还有一个配套的小技巧给控件设setAttribute(Qt::WA_OpaquePaintEvent, true)并在paintEvent开头自己填背景可以省掉 Qt 的一次背景擦除画面在快速拖动时不会闪。这个属性和离屏缓存配合起来是我在桌面端画交互式圆图时的默认配置。从那以后我每次接手这类公式直接生成图元的绘图控件都强制先做三件事把坐标换算收敛到一个函数里、用QElapsedTimer量一次单帧耗时、把静态层和动态层分开写。这三步做完后面无论加缩放、加轨迹还是加高亮都不会回头返工。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站