简介这套资源提供基于C和Qt编写的雷达图与摇杆图自定义控件示例面向需要在Qt界面中实现数据可视化或交互控件的开发者也适合入门CMake工程管理的读者参考。工程使用VS2019与Qt5.15.2编译未引入第三方库通过CMake组织源码能够保证编译通过。压缩包内共10个文件包含5个C源文件、4个头文件以及1个说明文档源码中带有注释便于理解雷达图绘制与摇杆方向增加等关键实现。目前已有521人在线学习或下载后续作者还计划提供配套介绍文章若遇到编译环境问题也可联系博主协助排查。对于正在搭建监控界面、游戏摇杆或教学演示场景的开发者这套轻量代码可快速复用并在此基础上扩展方向控制能力。1. 一个雷达图和一个摇杆图为什么用 C 自绘而不是贴图雷达图和摇杆图这两个控件我拆过好几轮。做 C 上位机或机器人离线调试软件的人大概率也会撞上同样的需求雷达图刷传感器目标摇杆图做手动控制输入方向档位还不能锁死四向、八向、十六向说加就加。很多团队一开始走捷径找位图当底盘或直接拖第三方控件等到业务刷新一快、用户要求改方向数量时才发现图片缩放发虚、if-else 串成一片。与其等到那一步不如把两个控件拆成可复用的自绘组件坐标换算、档位配置、信号输出都捏在自己手里后面改起来才不至于处处受制。2. 绘制底座选型QPainter 坐标系、重绘机制与控件接口自绘控件的底座选错后面全是返工。在 Qt/C 里画这两个图通常有三个候选QPainter、QOpenGLWidget、QML。我自己的结论是纯业务上位机场景优先 QPainter只有单屏目标数量到了万级才考虑 OpenGL。对比项QPainterQOpenGLWidgetQML / Quick2D 绘制能力圆、线、文字、抗锯齿、半透明都齐全大量点是强项文字和图例反而费劲Item 绘制方便但要在换一套异步模型里处理坐标与事件和 QWidget 坐标系天然一致要做 DPI 和视口换算事件模型和 QWidget 差异较大与既有 C 业务集成直接调用数据结构需要 GL 资源管理常用的还是走 C 后端开发成本低一个 paintEvent 就能跑起来中高要处理上下文、缓冲、失效区域中高适合新工程不适合嵌进老界面所以我下面所有代码都按 QPainter QWidget 讲这也是把这个插件做成 C 软件内嵌控件最省心的一条路。2.1 控件基类先把中心、半径和重绘规范定下来雷达图和摇杆图都依赖“控件中心点 半径”这套几何。基类不一定要抽象成纯虚类但至少应该把中心点和半径算好让两个子类共用。我一般会定义一个带QWidget基础能力的中间类子类只需要关心画什么、响应什么事件。class RadialDialWidget : public QWidget { Q_OBJECT public: explicit RadialDialWidget(QWidget *parent nullptr) : QWidget(parent) { setAttribute(Qt::WA_OpaquePaintEvent, false); setAttribute(Qt::WA_TranslucentBackground, false); } void setMarginRatio(qreal ratio) { m_marginRatio ratio; update(); } protected: void paintEvent(QPaintEvent *) override { QPainter painter(this); painter.setRenderHint(QPainter::Antialiasing, true); // 子类在各自的 paint 函数里基于 m_center / m_radius 绘制 } void resizeEvent(QResizeEvent *) override { const qreal w width(); const qreal h height(); m_center QPointF(w / 2.0, h / 2.0); m_radius qMin(w, h) / 2.0 * m_marginRatio; update(); } QPointF m_center; qreal m_radius 0.0; private: qreal m_marginRatio 0.85; };这段代码有几个关键点。setAttribute(Qt::WA_OpaquePaintEvent, false)是让 Qt 知道控件可能透明也避免在某些混搭窗口里出现黑底。resizeEvent里重新算中心点和半径而不是在 paintEvent 里每次重算因为缩放窗口时重算开销虽然不高但在高频刷新雷达画面时能少算一次是一次。m_marginRatio默认 0.85意思是控件实际画布只占最短边的 85%剩下的边距留给刻度文字和图例。如果是摇杆图这个值建议调到 0.9因为摇杆旋钮一般比雷达外圈离控件边缘更近。修改这个值后触发update()下一次绘制自然用新比例。2.2 重绘节流update 和 repaint 的边界自绘控件最容易翻车的地方不是画不好而是拼命画。paintEvent里一旦涉及圆环、渐变、半透明叠加调用的次数直接决定 CPU 占用。常见误区是一有业务数据就调用repaint()这个方法会立刻进入绘制流程而且不允许 Qt 合并同一批事件。雷达目标每秒刷新几十帧时repaint()会在定时器线程和界面线程之间来回催促界面卡顿不说扫描线动画还会一帧快一帧慢。正确做法是攒着改所有 setter、数据更新接口都只调用update()。Qt 会把多次update()合并成一个绘制事件在下一轮事件循环里统一处理。它对时间不敏感的业务比如目标列表刷新能自然压掉一半重复绘制。真正需要考虑立即重绘的只有摇杆拖拽这种强交互场景。但即便拖拽只要事件循环没有卡死update()的延迟通常也在一次鼠标事件内肉眼完全感知不到。我用update()演过 60fps 的摇杆动画结果是稳的反而是早期用repaint()时拖拽过程中经常出现线条残影。2.3 DPI 与坐标系不要在 paintEvent 里手算物理像素高分屏是自绘控件绕不过去的坎。Qt 在高 DPI 模式下paintEvent拿到的坐标系是逻辑坐标字体和基础控件会自动缩放。但我们画QPainter圆环、扫描线时如果混用了物理像素比如QPen(QColor(...), 1)在 150% 缩放下会细到发虚。我的处理习惯是所有绘制代码只用逻辑坐标不主动读取devicePixelRatioF()。QPainter 在 QWidget 上绘制时设备变换已经处理了缩放关系。真正需要关心 DPI 的是离屏缓存比如雷达背景缓存创建 QPixmap 时要按width() * devicePixelRatioF()和height() * devicePixelRatioF()创建再调用setDevicePixelRatio。这件事放在第 5 章避坑里细说这里只强调一个原则绘制逻辑和物理像素隔离别在一个函数里来回切。3. 雷达图落地极坐标换算、扫描线动画与离屏缓存雷达图的核心不是画一个圆而是把一个极坐标系下的小目标稳定、平滑地画到控件上。这里面最值得抠的是三个点数据模型、极坐标转直角坐标、以及动画绘制时的缓存策略。3.1 雷达目标数据模型把业务数据先归一到控件尺度雷达图的数据源可能是毫米波、激光或模拟目标类型千差万别。我的做法是先定义一份中间结构体所有业务数据都先转换成这份结构再交给控件。struct RadarTarget { int id; // 目标唯一 ID做轨迹跟踪时用 double bearingDeg; // 方位角正北为 0 度顺时针增加 double distance; // 距离归一化到 0.0 ~ 1.0 int strength; // 强度 0~255用于分档显示 QColor color; // 类别色比如友军/未知/障碍 };这里要注意distance我存放在0.0 ~ 1.0而不是像素或者米。原因是雷达控件的半径会随窗口变化如果数据层直接存像素窗口一变整份数据就要重算。归一化之后绘制时只需要乘当前半径窗口拉伸也不会破坏比例。实际场景里传感器的原始距离可能是 0 到 50 米也可能 0 到 200 米。外部接入时只要做一次distance / maxDistance的归一化雷达控件就能认为它看到的是半径 1 的圆。这个边界条件要在接口文档里写清楚否则后面接新传感器的人会忘记归一化把 200 米直接传进来目标全飞出绘制区。3.2 极坐标转直角坐标方向约定和屏幕 y 轴雷达图最常见的坐标约定是“正北为 0 度顺时针为 90 度”也就是正上方看 0 度正右侧 90 度。但 QWidget 的坐标系是 y 轴向下的如果直接拿标准数学公式里的cos/sin画出来的目标位置会上下颠倒。正确换算方法是用方位角减 90 后再做正弦余弦。方位角 0 度对应-90度弧正弦结果为 -1目标落在 y 轴负方向也就是 Qt 屏幕里向上。QPointF radarPoint(const RadarTarget t, const QPointF center, qreal radius) { const qreal bearingRad qDegreesToRadians(t.bearingDeg - 90.0); const qreal dist qBoundqreal(0.0, t.distance, 1.0) * radius; return QPointF( center.x() dist * std::cos(bearingRad), center.y() dist * std::sin(bearingRad)); }关键参数说明放在这里。bearingDeg - 90.0是把“正北为 0 度”的坐标系旋转到屏幕坐标系。因为屏幕 y 轴向下std::sin在角度为 -90 度时返回 -1才能让目标画到上方。如果你将来想改成“正右为 0 度逆时针为正”去掉这个 90 度偏移再把角度取负即可。改错方向时最典型的现象是量出一个正前方目标却显示在左上角或右下角。绘制时我会在 paintEvent 里分三层画背景网格、目标点、扫描线。背景网格包括半径 0.25/0.5/0.75/1.0 的四个圆环和 0 度、90 度、180 度、270 度四条径向线目标点按强度分档强度高用 7px 实心圆中等 5px低 3px扫描线用一条从圆心出发的半透明射线表示当前扫描角度。3.3 扫描动画与离屏缓存别在 paintEvent 里画全套雷达图如果每秒刷新 20 次每次重画网格、圆环、所有目标、扫描线性能会很难看。项目里目标一多paintEvent 轻松超过 20ms动画就开始掉帧。我一般会把静态背景和目标点做离屏缓存动态层只有扫描线和关联的余晖效果。目标数据变化时更新缓存扫描角度变化时只重绘最上层。void RadarWidget::rebuildSceneCache() { m_cache QPixmap(size() * devicePixelRatioF()); m_cache.setDevicePixelRatio(devicePixelRatioF()); m_cache.fill(Qt::transparent); QPainter p(m_cache); p.setRenderHint(QPainter::Antialiasing, true); drawBackground(p); drawRings(p); drawRadialLines(p); drawTargets(p); } void RadarWidget::paintEvent(QPaintEvent *) { QPainter painter(this); painter.drawPixmap(0, 0, m_cache); drawScanline(painter); drawFadedTargets(painter); }这段代码的重点是m_cache创建时乘了devicePixelRatioF()。如果不乘高分屏下缓存 pixmap 只有逻辑分辨率一半大画出来会糊。drawTargets画到缓存后目标点不会因为扫描线在动而每帧重画拖拽窗口时的性能会好很多。扫描线动画本身不依赖 QTimer 精度关键是频率。常见做法是控件内部起一个 50ms 的定时器外部也可以直接调用setSweepAngle(double)来驱动。我建议雷达控件不要自己吞数据时钟把扫描角度当作一个普通属性交给外部更新这样视频回放、仿真加速、实时传感器都能共用同一个控件。4. 摇杆图落地拖拽事件、角度离散化与方向数量可配置摇杆图比雷达图多一块交互逻辑用户拖旋钮控件要输出归一化坐标和方向档位。方向数量可配置这句话要落到代码里不是落在一串 if-else 上。4.1 摇杆交互模型固定底座、半径上限与两种回中模式先确定交互语义。摇杆一般有两种底座形态固定底座和浮动态底座。固定底座是旋钮围绕控件中心旋转底座位置不变浮动态是手指按到哪底座跟到哪旋钮以按下点为中心跑。雷达图和摇杆图配套的时候固定底座更好用因为它输出的是相对控件中心的绝对向量业务层容易理解。交互参数上第一步是限制旋钮位移不超过半径。鼠标事件的pos()减去中心点得到向量如果向量的模长超过半径就按比例缩到半径上。这样用户拖到控件外也不会让旋钮飞出底座。void JoystickWidget::updateFromMouse(const QPointF pos) { QPointF delta pos - m_center; const qreal len std::hypot(delta.x(), delta.y()); if (len m_radius) { delta * m_radius / len; } if (len m_radius * 0.03) { delta QPointF(); } m_knobDelta delta; update(); }这里的小阈值是必须的。触摸或鼠标在中心附近轻微抖动时如果没有任何死区方向输出会在几个相邻档位之间跳变。0.03倍的半径死区是我在八方向摇杆上调出来的经验值太小会飘太大会让用户感觉“没反应”。松手后的行为要单独做成开关不要写死在事件里。默认模式是回中也就是松手后旋钮回到中心输出停止。但有些业务需要的不是回中而是保持最后的方向和力度比如云台速度控制。常见做法是设置一个枚举在mouseReleaseEvent里根据模式决定是否把m_knobDelta清零。4.2 角度计算与方向档位映射从 0 度到 360 度不出边界摇杆输出角度要有一个明确的参考系。我采用“正右为 0 度正上为 90 度正左为 180 度正下为 270 度”顺时针增加。因为在屏幕上走鼠标轨迹顺时针方向的直觉与屏幕坐标 y 轴向下是一致的。double angleFromVector(const QPointF v) { double deg -qRadiansToDegrees(std::atan2(v.y(), v.x())); if (deg 0.0) { deg 360.0; } return deg; } int directionIndexFromAngle(double deg, int count) { const double sector 360.0 / count; int idx static_castint(std::floor((deg sector / 2.0) / sector)); idx % count; return idx; }angleFromVector里对atan2的结果取负是因为屏幕 y 轴向下鼠标向量在屏幕坐标里的“视觉角度”是数学角度的反向。加 360 度是为了让结果落在 0 到 360 之间。directionIndexFromAngle是方向扩展的关键。它把 360 度均匀切成count个扇区每个扇区中心是对应方向。deg sector / 2.0做的是“中心对齐”再用 floor 取当前落在哪个扇区。比如 8 方向时扇区是 45 度22.5 度作为 0 方向和 45 方向之间的边界这个公式会把 22.4 度归到 022.5 度归到 45。边界归到下一个扇区代码行为是确定的放在单元测试里很好验证。要特别提醒的是directionIndexFromAngle不要直接写成idx int(deg / sector)那样会在角度接近 360 度时直接得到count索引越界。% count必须保留它把 360 度附近的角度折回到 0 方向。4.3 信号输出把摇杆语义交给业务层摇杆控件没必要也不要替业务层做决策。它有四个信号就够用旋钮位移归一化坐标、连续角度、离散方向索引、松手事件。class JoystickWidget : public RadialDialWidget { Q_OBJECT public: enum ReturnMode { ReturnToCenter, HoldLastPosition }; void setReturnMode(ReturnMode mode) { m_returnMode mode; } signals: void moved(QPointF normalizedDirection, double angleDeg); void directionChanged(int directionIndex); void released(); private: ReturnMode m_returnMode ReturnToCenter; };moved信号里的normalizedDirection是旋钮相对中心的向量除以半径范围在 -1 到 1 之间。它保留力度信息适合做速度控制。directionChanged只发离散方向索引适合做八方向移动。两个信号同时发上层想要哪个都行不用再让控件去猜。松手时released单独发回不回中由return mode决定。如果业务层想“松手后沿最后方向继续走”它监听released并保存最后的directionIndex如果想“松手立即停止”它只需要监听回中模式下moved(QPointF(), 0.0)。摇杆控件的工作到信号这里就结束了剩下的决策权完全属于上层。5. 避坑记录五个“看着正常一用就出问题”的常见案例雷达图和摇杆图单独画出来都不难难的是它们在真实业务里能稳定跑。以下五条都是我在实际项目里踩过或帮别人排查过的坑每条都按现象、原因、解决来写。5.1 高分屏下雷达圈线变细文字发虚现象同一套代码在 1080p 下正常换到 2K 高分屏并开启 150% 缩放后雷达外圈和刻度线细得几乎看不见角度文字也小一号。原因应用没有开启高 DPI 缩放属性或者绘制代码里使用固定像素宽度画线。高分屏的物理像素密度高了如果不做缩放处理1 像素线在视觉上就是原来的 2/3 甚至 1/2 宽。文字类问题则来自 Qt 版本对devicePixelRatioF的传递差异。解决入口处加上QApplication::setAttribute(Qt::AA_EnableHighDpiScaling)和QApplication::setAttribute(Qt::AA_UseHighDpiPixmaps)绘制线条时不要把 QPen 宽度写成1 strength / 255这种依赖物理像素的公式改用逻辑单位qMaxqreal(1.5, strength / 100.0)。离屏缓存必须按第 3 章的方式乘devicePixelRatioF()。5.2 目标一多就卡顿画面糊成一团现象雷达图目标数量从 200 个增加到 2000 个后paintEvent 耗时超过 30ms旋转窗口直接卡死大量目标重叠在一起看不清层次。原因所有目标点都在每次 paintEvent 里重画没有做场景分层。而且目标点全部用同一尺寸重叠后视觉上变成一块亮斑看不出密度和强度差异。解决把目标点画进缓存扫描线单独叠上层目标点分三档强度绘制强度低的不画文字如果单屏目标超过 5000 个改用QPainter::drawPoints批量绘制或者按距离做最近邻聚类后再显示。5.3 摇杆松手后 UI 回中了业务还在往前跑现象摇杆旋钮松手后回到中心但画面里的角色或云台仍然按最后方向继续运动用户怀疑控件输出有 bug。原因控件回中只是重绘了旋钮位置业务层没有收到“停止”通知。常见做法是只监听moved松手后moved信号发出的归一化坐标变成了 0但没有业务层真正处理这个零向量。解决把松手事件和方向输出分开。控件在mouseReleaseEvent里根据回中模式决定是否把m_knobDelta清零同时发射released()。业务层必须在released()里决定停止、保持还是启动安全归位逻辑。从这以后我养成的习惯是摇杆控件接业务第一件事验证松手后最后一帧信号用日志把released和moved的顺序打出来。5.4 扫描定时器设到 10msCPU 飙高还掉帧现象为了追求雷达扫描平滑把 QTimer 间隔设为 10ms结果 CPU 占用直接到 30%扫描线反而不平稳一块快一块慢。原因界面刷新受系统合成器限制通常 60Hz 对应 16.6ms 一帧。10ms 定时器比屏幕刷新还快多余的重绘请求被丢弃白白消耗 CPU。更麻烦的是每次重绘都走完整的 paintEvent背景圆环和目标点全画一遍代价被放大。解决把 QTimer 间隔放到 20ms 或 30ms配合update()让 Qt 合并且丢弃过期绘制请求。另一种更省的做法是让雷达扫描由外部数据时钟驱动业务数据到了才推进一个扫描角度没有数据时安静待着。5.5 方向数量写死成 if-else加一个方向牵一发动全身现象第 4 章之前的方向判断长这样if (angle 315 || angle 45) { dir 0; } else if (angle 90) { dir 1; }。从 8 方向改成 16 方向时要改好几处边界条件还容易漏掉 180 度附近的负角度情况。原因方向判断逻辑和具体方向数耦合了边界条件硬编码在业务函数里。每次改方向数都要重新组织 if 顺序测试用例也要跟着重写。解决用扇区公式替换 if-else。方向数只作为参数传进directionIndexFromAngle返回索引后查表拿方向名或图标。业务层永远只处理索引不接触角度边界。这个方案在第 6 章落地成配置表和边界用例。6. 把方向扩展彻底改造方向档位表配置与边界用例验证方向数量可配置这件事不能只做到“代码能跑”还要做到“改一个数字其他全自动”。我把摇杆的方向档位拆成一张配置表配合第 4 章的角度映射函数加方向就变成了改count。struct DirectionConfig { int count 8; QStringList names; void rebuild() { names.clear(); for (int i 0; i count; i) { names QStringLiteral(%1°).arg(i * 360 / count); } } };rebuild()的作用是让方向名列表和角度一一对应。这里的方向名可以是“右上”“上”“左上”这类语义词也可以是角度数值。真实产品里我见过把方向和操作按钮绑定的摇杆输出 index再通过这张表映射到按键动作整体解耦得很干净。光有配置表还不够边界角度必须用用例锁住。下面这组是我每次改动摇杆控件都要跑一遍的边界验证输入角度方向数期望索引说明44.9400 方向扇区边界内45.041边界归到下一个扇区314.943270 方向扇区边缘315.140跨过边界后回到 0 方向22.4800 方向扇区边缘22.5818 方向的 45 度边界337.487最后扇区边缘337.680折回 0 方向我在项目里把这些用例写成一个纯函数不依赖 GUI任何一次代码改动都能回归验证。从那以后每次重构摇杆控件我都会先跑一遍边界角度用例再做界面测试雷达控件改动后也强制走一遍高 DPI 缓存检查。这两个习惯救过我好几次希望帮到你。本文还有配套的精品资源点击获取