☰
CStatic 子类化与 GDI 双缓冲的 MFC 实时曲线控件实现
2026/10/28 18:00:14 网站建设 项目流程

简介:面向 VC++/MFC 初学者与界面开发者的 Static 控件绘图实践包,聚焦在对话框或窗口中用静态控件承载实时曲线与波形显示这一常见需求。核心示例以「虚拟示波器模块」为主线,演示重载 OnPaint、处理 WM_PAINT、借助兼容 DC 与内存位图进行双缓冲绘图,再通过 BitBlt 将内存位图回贴到控件,并结合 MoveTo/LineTo、GDI+ 平滑曲线与 SetPixel 逐点更新等方式处理动态数据;此外还涉及绘图资源释放等易错环节。压缩包共 41 个文件、约 233KB,以 h 头文件与 cpp 源文件为主体,配合 ico、bmp 图标位图资源及 rc、dsp、dsw 等工程配置,构成一套完整的 MFC 工程。已有 276 人学习,适合参照源码理解 Windows 消息机制与 GDI 绘图流程,并在此基础上改造成自己的曲线控件或数据监视面板。

1. 一个 CStatic 撑起来的实时曲线窗

上位机里那种一直往右滚动的实时曲线,很多并不依赖什么图表库,实际做法是把一个静态文本控件当画布用——在 VC 里放一个 Static 控件,子类化之后在 OnPaint 里用 GDI 自行绘制,几十行代码就能让它跑起来。这份工程做的就是这件事:从 CStatic 派生出曲线控件,用双缓冲避免闪烁,通过坐标映射把浮点数据换算成像素坐标,再补上网格、坐标轴、鼠标十字线和滚轮缩放。它适合手上有 MFC 老项目、又不想为了一个曲线图引入整套绘图框架的人;也适合想弄明白「控件自绘」这条链路究竟怎么走的新手。下面按我拆这套代码的顺序讲:整体骨架、绘制主干、刷新与交互,最后是改造成自己的绘制框架。

2. 选型与骨架:为什么把 CStatic 当画布

2.1 三种落点的真实差别

实现一个曲线显示区域,常见有三种做法,差别不在好不好看,而在「谁来管生命周期」和「消息怎么流」。我把它们摊在表里,你可以对照自己项目的情况选择。

方案接入成本可控程度主要风险
CStatic 子类化拖控件、改 ID、写一个派生类绘图、交互、刷新全在自己手上控件默认行为(背景擦除、不接收焦点)要逐个覆盖
从 CWnd 自绘需要自己 Create 窗口或做动态创建最彻底,没有历史包袱布局、Tab 顺序、对话框编辑器里不可见,改动成本高
现成图表库加依赖、看文档、学 API受限于库提供的配置项老工程引入大依赖,部署包变大,定制需求受制于库

MFC 老项目里第三种往往第一个被排除:对话框资源是现成的,界面是现成的,加一个第三方库意味着要把编译环境、发布包、授权都重新过一遍。而 CWnd 自绘虽然干净,但控件在资源编辑器里放不了位置,调界面时全靠改代码猜坐标,改一次编译一次。CStatic 子类化刚好卡在中间:资源编辑器里能摆位置,运行时是一块真正属于你的画布,父对话框的布局逻辑完全不用改。

我一般会选资源里的 Static,把 Type 设成 Rectangle 或 Frame,去掉文字,ID 改成IDC_STATIC_CURVE这类有意义的名字。这一步看着琐碎,但它是后面所有事情的前提——默认的IDC_STATIC值是 -1,对话框上多个 Static 共用这个值,DDX 绑定时会绑到第一个匹配的控件上,图上画不出来还真不一定是绘图代码的问题。

2.2 工程里都有什么

源码包的结构很薄,没有多余的封装层,基本是一个能直接拖进现有对话框工程的模块:

CurveDemo/ ├── CurveDemo.vcxproj 工程文件,MFC 静态/共享链接都能编 ├── CurveStatic.h/.cpp 曲线控件本体:绘制、坐标映射、交互 ├── AxisMap.h 坐标映射结构体,纯头文件,无依赖 ├── RingBuffer.h 定长环形缓冲,用来存最近 N 个点 ├── CurveDemoDlg.h/.cpp 宿主对话框,演示怎么把控件摆进去 └── res/ 图标与对话框资源

职责边界很清楚:AxisMap.h只管「数据值怎么变成像素」,RingBuffer.h只管「点怎么存」,CurveStatic负责把两者串起来并输出到 DC。这种拆法的好处是,你后面换数据源、换平滑算法、换配色,改动面都很小,不用在一个两千行的 OnPaint 里翻找。

2.3 从数据到像素的四段链路

整个绘制过程可以拆成四段,每段都有独立的失败模式,排查时按段切分比盯着整块代码看有效得多:

  1. 数据进来:外部喂点(串口、采集卡、模拟量),进环形缓冲,超出容量的旧点被覆盖。
  2. 确定绘图区:客户区矩形减去四周留白(左边留给 Y 轴刻度,下边留给 X 轴刻度),得到plot矩形。
  3. 坐标映射:把(x, y)的数据值按当前量程映射到plot内的像素坐标,Y 轴要翻转,因为屏幕 Y 向下增长。
  4. 输出到 DC:先画到内存位图(背景、网格、曲线、图例),最后一次 BitBlt 贴到屏幕。

这里最容易出错的是第 3 段和第 4 段的衔接:映射出来的点如果越界,直接跳过会导致曲线断线;正确做法是让裁剪区去限制 GDI 的输出,而不是在数据层丢掉点。后面第 5 章会专门讲这个翻车现场。

3. 绘制主干:双缓冲、坐标映射与曲线平滑

3.1 子类化与消息映射

控件要能自绘,第一步是让这个对话框上的 Static 实例变成CCurveStatic的对象。两种绑定方式,选一种就行,别两个都用——两个都用会出现控件被重复子类化、消息跑两遍的怪现象。

// 方式一:DDX,控件 ID 必须是唯一且有意义的 void CCurveDemoDlg::DoDataExchange(CDataExchange* pDX) { CDialogEx::DoDataExchange(pDX); DDX_Control(pDX, IDC_STATIC_CURVE, m_wndCurve); // m_wndCurve 是 CCurveStatic 成员 } // 方式二:OnInitDialog 里手动子类化,适合控件是运行时动态创建的场合 BOOL CCurveDemoDlg::OnInitDialog() { CDialogEx::OnInitDialog(); m_wndCurve.SubclassDlgItem(IDC_STATIC_CURVE, this); m_wndCurve.SetRange(0.0, 60.0, -1.0, 1.0); // x 量程 0~60 秒,y 量程 -1~1 return TRUE; }

SubclassDlgItem的第二个参数是父窗口,也就是对话框本身。子类化之后,原本发给那个 Static 的WM_PAINT、WM_SIZE、WM_MOUSEMOVE就都进到派生类的消息映射里了。消息映射必须显式写ON_WM_PAINT(),否则基类CStatic的默认绘制会接管,你写在OnPaint里的代码一行都不会执行——新手最常见的「代码没错但啥也不显示」,八成卡在这里。

BEGIN_MESSAGE_MAP(CCurveStatic, CStatic) ON_WM_PAINT() ON_WM_ERASEBKGND() ON_WM_SIZE() ON_WM_MOUSEMOVE() ON_WM_MOUSEWHEEL() ON_WM_LBUTTONDOWN() ON_WM_LBUTTONUP() END_MESSAGE_MAP()

3.2 OnPaint 的双缓冲骨架

单缓冲直接在窗口 DC 上逐笔画,笔一落下去屏幕就开始刷,人眼看到的就是一条一条的撕裂和闪烁。双缓冲的思路是先在一张和客户区等大的内存位图上把所有东西画完,再一次性贴过去。

void CCurveStatic::OnPaint() { CPaintDC dc(this); CRect rcClient; GetClientRect(&rcClient); if (rcClient.IsRectEmpty()) return; // 控件还没布局完,直接返回 // 1. 建内存 DC 和同尺寸位图 CDC memDC; memDC.CreateCompatibleDC(&dc); CBitmap bmp; bmp.CreateCompatibleBitmap(&dc, rcClient.Width(), rcClient.Height()); CBitmap* pOldBmp = memDC.SelectObject(&bmp); // 2. 依次绘制各图层 CalcPlotRect(rcClient); DrawBackground(&memDC, rcClient); DrawGrid(&memDC, rcClient); DrawAxisLabel(&memDC, rcClient); DrawCurve(&memDC, rcClient); DrawCursor(&memDC, rcClient); // 鼠标十字线,没有鼠标时内部直接返回 // 3. 一次性贴到屏幕 dc.BitBlt(0, 0, rcClient.Width(), rcClient.Height(), &memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOldBmp); }

这里有两个参数必须留意。CreateCompatibleBitmap(&dc, ...)的第一个参数是窗口的 CPaintDC,不能写成&memDC;如果写成&memDC,创建出来的是 1bpp 的单色位图,画出来的曲线全是黑白噪点,这个坑我踩过不止一次。另一个是SelectObject之后一定要把旧位图选回去,否则bmp析构时会因为还挂在 DC 上而失败,短时间看不出问题,跑久了句柄泄漏,界面直接卡死。

每帧新建位图是有开销的,尺寸一大会明显。真正上线时我会把CBitmap和CDC提成成员变量,在OnSize里按新尺寸重建,OnPaint里只做SelectObject和绘制。这个优化不难,但省掉的分配次数很可观。

3.3 坐标映射:别把浮点误差带进像素

映射是整套代码里最容易写对也最容易写歪的部分。写成结构体,把量程和绘图区捆在一起,比在类里散着放一堆m_xMin、m_yMin要清晰得多。

struct CAxisMap { CRect plot; // 实际画曲线的矩形(已扣掉边距) double xMin = 0.0, xMax = 1.0; double yMin = 0.0, yMax = 1.0; int XToPixel(double x) const { if (xMax <= xMin) return plot.left; double t = (x - xMin) / (xMax - xMin); // 归一化到 0~1 return plot.left + (int)(t * (plot.Width() - 1) + 0.5); } int YToPixel(double y) const { if (yMax <= yMin) return plot.bottom; double t = (y - yMin) / (yMax - yMin); return plot.bottom - (int)(t * (plot.Height() - 1) + 0.5); // 屏幕 Y 轴向下,要翻转 } };

三个细节值得说。Width() - 1而不是Width(),是为了保证最大值刚好落在最右一列像素上,不越界;+ 0.5再取整是四舍五入,不加的话所有点都往下偏半个像素,密集区看起来就是整体偏移;yMax <= yMin的判断是防御除零,量程没设好时映射会返回一个固定的边界值,画面看着不对但不会崩,比直接抛异常好排查。

绘图区边距建议做成可配置的成员:

void CCurveStatic::CalcPlotRect(const CRect& rcClient) { m_map.plot = rcClient; // 左、上、右、下四个方向留白,左侧要给 Y 轴刻度留够宽度 m_map.plot.DeflateRect(m_nPadLeft, m_nPadTop, m_nPadRight, m_nPadBottom); if (m_map.plot.right <= m_map.plot.left) m_map.plot.right = m_map.plot.left + 1; if (m_map.plot.bottom <= m_map.plot.top) m_map.plot.bottom = m_map.plot.top + 1; }

控件被拖得很小时,DeflateRect可能把矩形压成负数宽度,后续Width()返回 0 或负值,Polyline的画法就会变得难以理解。加两行钳制,成本几乎为零。

3.4 折线、贝塞尔平滑与抗锯齿

点少的时候直接Polyline就够,点多的时候它天然是折线,视觉上会有明显棱角。想要平滑,最省事的做法是把 Catmull-Rom 样条转换成三次贝塞尔控制点,再交给PolyBezier。

void CCurveStatic::DrawSmoothCurve(CDC* pDC, const std::vector<CPoint>& pts) { if (pts.size() < 3) { if (pts.size() == 2) pDC->Polyline(pts.data(), 2); return; } // PolyBezier 要求 1 + 3n 个点:起点 + 每段三个控制点 std::vector<POINT> bez; bez.reserve(pts.size() * 3); bez.push_back(pts[0]); for (size_t i = 0; i + 1 < pts.size(); ++i) { const CPoint& p0 = (i == 0) ? pts[0] : pts[i - 1]; const CPoint& p1 = pts[i]; const CPoint& p2 = pts[i + 1]; const CPoint& p3 = (i + 2 < pts.size()) ? pts[i + 2] : pts[i + 1]; // Catmull-Rom 张力 1/6,是标准换算系数 POINT c1 = { p1.x + (p2.x - p0.x) / 6, p1.y + (p2.y - p0.y) / 6 }; POINT c2 = { p2.x - (p3.x - p1.x) / 6, p2.y - (p3.y - p1.y) / 6 }; bez.push_back(c1); bez.push_back(c2); bez.push_back(p2); } pDC->PolyBezier(bez.data(), (int)bez.size()); }

1/6这个系数不是随手写的,它是 Catmull-Rom 切线公式转成贝塞尔控制点的标准结果,改大改小会让曲线在拐点处变「甩」或变「僵」。另外这段代码在点数过万时内存占用不小(每个点贡献三个控制点),所以只适合曲线本身比较稀疏的场景,比如几十到几百个采样点;实时刷新的密集数据走折线更稳。

GDI 本身没有抗锯齿,线宽 1 的斜线一定是台阶状的。要 smoother,只有两条路:一是用 GDI+ 的Graphics画,二是自己拿像素做混合(没必要)。GDI+ 的接法是在OnPaint里用窗口 DC 构造一个 Graphics 对象,注意它和 GDI 的绘制顺序要统一,不然会出现「曲线被网格盖住」这种事。

#include <gdiplus.h> #pragma comment(lib, "gdiplus.lib") void CCurveStatic::DrawCurveAA(CDC* pDC, const std::vector<CPoint>& pts) { if (pts.size() < 2) return; Gdiplus::Graphics g(pDC->GetSafeHdc()); g.SetSmoothingMode(Gdiplus::SmoothingModeAntiAlias); g.SetPixelOffsetMode(Gdiplus::PixelOffsetModeHalf); // 抗锯齿时对齐像素中心 Gdiplus::Pen pen(Gdiplus::Color(255, 0, 120, 215), 2.0f); pen.SetLineJoin(Gdiplus::LineJoinRound); // 拐角圆滑,避免尖刺 g.DrawLines(&pen, reinterpret_cast<const Gdiplus::Point*>(pts.data()), (INT)pts.size()); }

GDI+ 需要在应用启动时GdiplusStartup、退出时GdiplusShutdown,这两个调用放在CWinApp派生类的InitInstance和ExitInstance里就行。Gdiplus::Point和POINT内存布局一致,可以直接 reinterpret_cast,省一次拷贝——但这个前提是结构体布局没变,跨编译器时我一般会老老实实拷一遍。

4. 交互与刷新:定时器、十字光标与滚轮缩放

4.1 刷新策略:定时器驱动还是数据驱动

刷新方式的选择直接决定 CPU 占用和画面流畅度。定时器驱动是固定节奏,比如 33ms 一帧,对应大约 30fps;数据驱动是来一批点刷一次。

// 定时器驱动:适合数据源频率高且稳定的场合 BOOL CCurveDemoDlg::OnInitDialog() { CDialogEx::OnInitDialog(); SetTimer(TIMER_REFRESH, 33, nullptr); // 33ms ≈ 30fps return TRUE; } void CCurveDemoDlg::OnTimer(UINT_PTR nIDEvent) { if (nIDEvent == TIMER_REFRESH) { m_wndCurve.Invalidate(FALSE); // FALSE 表示不擦背景 return; } CDialogEx::OnTimer(nIDEvent); }

Invalidate(FALSE)里的FALSE是关键,它告诉框架「别擦背景,我自己画」。写成TRUE或者调用Invalidate(),每帧都会先来一次WM_ERASEBKGND,白底一闪,双缓冲也白做了。

数据驱动的写法是在AppendPoint里判断距上次刷新是否超过阈值:

void CCurveStatic::AppendPoint(double x, double y) { m_ring.Push({ x, y }); ULONGLONG now = ::GetTickCount64(); if (now - m_uLastPaint >= 33) // 同样的节流,避免频繁重绘 { m_uLastPaint = now; Invalidate(FALSE); } }

两种都行,我倾向定时器驱动:数据源抖动时画面节奏仍然稳定,不会出现忽快忽慢的视觉跳动。数据驱动的好处是空闲时 CPU 真的为 0,做电池供电的设备时更划算。

4.2 鼠标十字线与取值提示

十字线是这类控件里用户感知最强的一个交互,实现不复杂,但顺序不能错:必须画在曲线之后,否则线会被曲线盖住。

void CCurveStatic::OnMouseMove(UINT nFlags, CPoint point) { if (m_map.plot.PtInRect(point)) { m_bCursorValid = true; m_ptCursor = point; // 反推最近的采样点,供提示框显示数值 m_nNearest = FindNearestIndex(point.x); Invalidate(FALSE); } else if (m_bCursorValid) { m_bCursorValid = false; // 移出绘图区要清掉,不然十字线留在原地 Invalidate(FALSE); } CStatic::OnMouseMove(nFlags, point); }

FindNearestIndex用 X 像素反算数据 X,再在缓冲里二分或线性找最近的索引。点数不大时线性扫就够了,一万点以上再换二分,不要提前优化。

提示框(那个跟着鼠标走的小数值气泡)有两条路:画在同一个 DC 上,或者弹一个CToolTipCtrl。前者可控、同步、不用管生命周期,我一般选前者,画一个圆角矩形加两行TextOut就行。

4.3 滚轮缩放与拖动平移

缩放的本质是改量程,不是改数据。以鼠标位置为锚点缩放,用户体验最好:

BOOL CCurveStatic::OnMouseWheel(UINT nFlags, short zDelta, CPoint pt) { CRect rc; GetClientRect(&rc); ScreenToClient(&pt); if (!m_map.plot.PtInRect(pt)) return FALSE; // 鼠标位置对应的数据坐标,缩放前后要保持不变 double xAnchor = m_map.xMin + (pt.x - m_map.plot.left) * (m_map.xMax - m_map.xMin) / m_map.plot.Width(); double yAnchor = m_map.yMin + (m_map.plot.bottom - pt.y) * (m_map.yMax - m_map.yMin) / m_map.plot.Height(); double scale = (zDelta > 0) ? (1.0 / 1.2) : 1.2; // 向上滚放大 m_map.xMin = xAnchor - (xAnchor - m_map.xMin) * scale; m_map.xMax = xAnchor + (m_map.xMax - xAnchor) * scale; m_map.yMin = yAnchor - (yAnchor - m_map.yMin) * scale; m_map.yMax = yAnchor + (m_map.yMax - yAnchor) * scale; Invalidate(FALSE); return TRUE; }

锚点不变这个约束很值得单独强调:如果直接按比例缩xMin/xMax而不考虑鼠标位置,缩放手感会很别扭,鼠标指着某个峰值,一滚轮那个峰就跑了。另外量程要加下限保护,反复放大到xMax - xMin小于1e-9时,映射里的除法会炸出 inf,(int)一转就是未定义行为。

拖动平移就是记录左键按下时的点和当时的量程,移动时按像素差换算成数据差整体平移,逻辑简单,注意在OnLButtonUp里把状态清干净就行。

4.4 环形缓冲与大数据量抽稀

存点用环形缓冲,容量固定,旧数据自动覆盖,不需要手动erase头部——vector::erase(begin())在十万点时是 O(n) 的,每秒调几十次,性能就没了。

template <typename T, size_t N> class CRingBuffer { public: void Push(const T& v) { m_data[m_head] = v; m_head = (m_head + 1) % N; if (m_size < N) ++m_size; } size_t Size() const { return m_size; } // 逻辑下标 0 是最老的点,Size()-1 是最新的 const T& operator[](size_t i) const { return m_data[(m_head + N - m_size + i) % N]; } private: T m_data[N]; size_t m_head = 0; size_t m_size = 0; };

真正的性能瓶颈不在存,而在画。绘图区宽度可能只有 800 像素,却要塞进去十万个点,等于每个像素上有上百个点——Polyline会老老实实全画一遍,帧率直接掉到个位数。抽稀的常见做法是「每列像素保留最小值和最大值」:

// 把一列像素内的多个点压缩成两个点(min 和 max),保留尖峰不丢 std::vector<CPoint> thinned; thinned.reserve(rc.Width() * 2); for (int px = m_map.plot.left; px <= m_map.plot.right; ++px) { int i0 = FindFirstIndexAtPixel(px); int i1 = FindLastIndexAtPixel(px); if (i0 > i1) continue; double yMin = m_ring[i0].y, yMax = yMin; for (int i = i0 + 1; i <= i1; ++i) { yMin = min(yMin, m_ring[i].y); yMax = max(yMax, m_ring[i].y); } thinned.push_back(CPoint(px, m_map.YToPixel(yMax))); thinned.push_back(CPoint(px, m_map.YToPixel(yMin))); }

抽稀之后点数上限大约是绘图区宽度的两倍,画多少都不会卡。代价是曲线在高倍缩小时细节被压缩,所以缩放时要用「当前量程」重新算像素列,而不是复用抽稀结果。

5. 排查与避坑:五条踩出来的经验

5.1 拖动窗口就闪,双缓冲像没生效

现象:静态截图看着挺好,一拖窗口边框,整块区域白闪,曲线像在抽搐。

原因:OnEraseBkgnd没处理,框架在每次重绘前先用背景色填充客户区;同时Invalidate()用了默认的TRUE,等于主动要求擦背景。双缓冲只解决了「绘制过程可见」,没解决「擦除可见」。

解决:两处一起改。ON_WM_ERASEBKGND()里直接返回TRUE表示背景已处理;所有Invalidate调用统一传FALSE。改完再拖窗口,闪烁基本消失。

BOOL CCurveStatic::OnEraseBkgnd(CDC* /*pDC*/) { return TRUE; // 背景在 OnPaint 的内存位图里画,这里不要再擦一次 }

5.2 曲线画出来了,但是黑白的

现象:网格、坐标轴位置都对,曲线也在正确位置,但整张图是黑白色,RGB(0,120,215)完全不生效。

原因:CreateCompatibleBitmap(&memDC, ...)传的是内存 DC。内存 DC 刚创建时默认挂着一张 1x1 的单色位图,以它为基准创建出来的还是单色位图,颜色信息直接丢了。

解决:第一个参数改成窗口的CPaintDC(或者GetDC()拿到的窗口 DC)。这个错误编译器不管,运行时不报错,只能靠眼睛发现,属于典型的黑匣子问题。

5.3 滚轮怎么滚都没反应

现象:鼠标放在曲线区上滚动滚轮,量程纹丝不动,但同一个对话框里的其他控件能正常响应。

原因:WM_MOUSEWHEEL是发给当前有焦点的窗口的,而 CStatic 默认没有WS_TABSTOP,点它也不会拿到焦点,所以滚轮消息压根到不了控件。

解决:在父对话框的PreTranslateMessage里拦截并转发。注意WM_MOUSEWHEEL的lParam是屏幕坐标,要先转客户区再判断命中。

BOOL CCurveDemoDlg::PreTranslateMessage(MSG* pMsg) { if (pMsg->message == WM_MOUSEWHEEL) { CPoint pt(GET_X_LPARAM(pMsg->lParam), GET_Y_LPARAM(pMsg->lParam)); // 屏幕坐标 if (::WindowFromPoint(pt) == m_wndCurve.GetSafeHwnd()) { m_wndCurve.SendMessage(WM_MOUSEWHEEL, pMsg->wParam, pMsg->lParam); return TRUE; // 吃掉消息,防止父窗口再处理一次 } } return CDialogEx::PreTranslateMessage(pMsg); }

5.4 跑十几分钟界面就卡死

现象:刚启动时很流畅,挂机半小时后拖动窗口变成幻灯片,任务管理器里 GDI 对象数一直涨。

原因:OnPaint里每帧都new CPen、CreateCompatibleBitmap,SelectObject之后没恢复旧对象。每个进程的 GDI 句柄有上限(默认一万个),到顶之后所有绘图调用静默失败,界面看着就是「不刷新了」。

解决:所有SelectObject都配对恢复,位图和 DC 提成成员变量按尺寸复用。排查工具用 GDIView 看对象数曲线,如果随刷新持续上升,就是没恢复。

CPen pen(PS_SOLID, 2, RGB(0, 120, 215)); CPen* pOldPen = pDC->SelectObject(&pen); // ... 绘制 ... pDC->SelectObject(pOldPen); // 必须恢复,否则 pen 析构时仍挂在 DC 上

5.5 曲线在某段突然断掉

现象:数据本身连续,但曲线上偶尔出现一条竖直的断口,位置不固定。

原因:绘制循环里对越界的点做了continue跳过。跳过之后前后两个点不再相邻,Polyline把它们连起来就是一条横跨整个绘图区的直线;如果跳的是中间一段,看起来就是断线。

解决:不要在数据层丢弃点,交给 GDI 裁剪。用SaveDC/IntersectClipRect限定绘制范围,越界的部分由 GDI 自己裁掉,曲线在边界处自然切断而不是乱连。

int nSaved = pDC->SaveDC(); pDC->IntersectClipRect(&m_map.plot); // 只画绘图区内的部分 pDC->Polyline(pts.data(), (int)pts.size()); pDC->RestoreDC(nSaved);

6. 二次开发:把控件改成能接任意数据源的绘制框架

这套代码我后来改过三版,前两版都是直接在OnPaint里加需求,改到第三版发现已经没法维护了,才回头抽接口。教训是:如果这个曲线图要在项目里活过三个月,就别指望「先凑合」——提前留三个口子。

第一个口子是数据源。控件不要直接持有具体的数据结构,抽一个取值接口出来,RingBuffer只是其中一种实现:

class ICurveDataSource { public: virtual ~ICurveDataSource() {} virtual size_t GetCount() const = 0; virtual void GetPoint(size_t i, double& x, double& y) const = 0; };

接串口就实现一个读缓冲的版本,接历史库就实现一个按时间窗查询的版本。控件只认接口,DrawCurve里把m_ring[i]换成m_pSource->GetPoint(i, x, y),其他地方一行不动。

第二个口子是绘制风格。把线色、线宽、网格密度、是否平滑、是否抗锯齿收进一个配置结构体,宿主对话框构造控件时传进去。这样同一份代码可以同时给「主监控曲线」和「小缩略图」用,不用复制两个类。

第三个口子是坐标轴的格式化。Y 轴刻度的文本(保留几位小数、要不要加单位、大数用不用 k/M 缩写)一定是跟着业务变的,做成虚函数让派生类覆盖,比在控件里塞一堆 if 干净。

验证改动有没有把东西改坏,我有一套固定的三步。第一步用标准正弦波:y = sin(2πx),量程设成 -1.2~1.2,看峰值是不是刚好贴在绘图区上下边界内侧一点点,位置对不上就是映射出了问题。第二步把控件拖到最小和最大尺寸各看一遍,看边距塌陷时会不会画出越界的东西。第三步塞十万点进去,用 GDIView 盯着对象数,观察五分钟不涨才算过。

从那以后我每次动OnPaint里的绘制顺序,都会先跑一遍正弦波和十万点压测,再去看实际业务数据——顺序一乱,图例被曲线盖住、十字线跑到网格下面这类问题,靠肉眼盯业务数据是很难发现的。这套源码包里的示例对话框就是按这个思路搭的,头文件和头文件之间的依赖很浅,直接拷进现有工程改 ID 就能用。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询