
1. 项目概述为什么从实心圆开始在VCVisual C的图形编程世界里绘制一个实心圆听起来像是“Hello World”级别的入门练习。很多新手教程会一笔带过直接甩给你几行调用Ellipse函数的代码。但如果你真这么想可能就错过了理解Windows图形设备接口GDI核心机制的最佳切入点。我干了十几年Windows桌面开发带过不少新人发现能把一个实心圆画明白、画高效、画得在不同场景下都稳定可靠的开发者其实并不多。这个看似简单的任务背后串联起了设备上下文DC、画笔Pen、画刷Brush、坐标映射、重绘机制等一系列关键概念。最近我在一些技术社区和热搜里看到很多人在搜“VC基础教程”、“电脑vc库自检”甚至“微软 vc 2015-2022 x64 运行库”这说明依然有大量开发者特别是学生和转行进入Windows桌面开发的朋友正在这个生态里摸索。而“实心圆绘制”恰好是一个绝佳的沙箱它不涉及复杂的网络拓扑图、PCB布线或者三维饼图却能让你把GDI的“内功”练扎实。当你理解了如何在VC里优雅地画出一个实心圆再去应对“Android View绘制流程”或者“WebGL绘制粗线”这些不同平台、不同API的图形挑战时你会发现自己有了一个稳固的参照系。所以这篇内容我们不搞花架子就深挖“VC实现实心圆的绘制”这一件事。我会带你从零开始不仅写出能运行的代码更要弄懂每一个参数的意义、每一种方法的选择背后的考量以及在实际项目中可能踩到的坑。无论你是刚安装好Visual Studio的新手还是想重温GDI基础的老兵相信都能从中获得一些实实在在的收获。2. 核心原理与GDI基础探秘2.1 图形绘制的基石设备上下文DC在Windows里你不能直接拿支“笔”就在屏幕上乱画。所有的绘制操作都必须通过一个叫做“设备上下文”Device Context DC的抽象层来进行。你可以把DC理解为一个画家的“绘画许可证”和“工具箱打包清单”。它不仅仅关联着一块具体的“画布”可能是屏幕、内存、打印机还管理着当前绘画所用的所有工具属性比如用什么颜色的笔画笔、用什么图案的刷子画刷、用什么字体写字等等。当我们说“在窗口上画图”本质上是先获取这个窗口的DC然后使用这个DC进行一系列GDI函数调用。对于实心圆绘制DC里最关键的两个工具属性就是画刷HBRUSH和画笔HPEN。画刷决定了封闭图形如矩形、椭圆、多边形内部的填充颜色和样式画笔决定了图形轮廓线的颜色、粗细和样式。我们要画一个实心圆就意味着需要关注画刷而轮廓线画笔有时可以设置为空NULL_PEN来忽略。注意很多初学者会混淆Rectangle和Ellipse函数对画笔和画刷的使用。记住一个原则画刷管“肚子”画笔管“边儿”。对于实心圆我们主要操作画刷。2.2 关键APIEllipse函数详解GDI提供了Ellipse函数来绘制椭圆和圆。它的原型如下BOOL Ellipse( HDC hdc, // 设备上下文句柄 int left, // 外接矩形左上角x坐标 int top, // 外接矩形左上角y坐标 int right, // 外接矩形右下角x坐标 int bottom // 外接矩形右下角y坐标 );这个函数的原理非常直观它绘制一个由参数指定的矩形left, top, right, bottom内切产生的椭圆。当这个矩形是正方形时画出来的自然就是一个正圆。所以画圆的关键变成了如何计算出一个正方形的外接矩形坐标。函数执行时会使用DC中当前选中的画笔来绘制椭圆的轮廓并使用当前选中的画刷来填充椭圆的内部。这就是为什么在调用Ellipse之前我们通常需要创建并选入SelectObject特定的画刷和画笔。2.3 颜色与坐标RGB与RECT在GDI中颜色通常用RGB宏来定义它接受三个0-255的整数参数分别代表红、绿、蓝的强度。例如RGB(255, 0, 0)是红色RGB(0, 255, 0)是绿色RGB(0, 0, 255)是蓝色RGB(255, 255, 255)是白色RGB(0, 0, 0)是黑色。坐标系统默认以客户区的左上角为原点(0, 0)X轴向右递增Y轴向下递增。这与我们常见的数学坐标系Y轴向上不同需要特别注意。我们绘制的矩形区域用RECT结构体表示包含left,top,right,bottom四个成员。Ellipse函数画出的图形会填充这个矩形定义的空间但并不会包括右下边界即填充区域为[left, top) 到 (right, bottom)。这是一个容易忽略的细节在精确计算像素位置时很重要。3. 从零开始一个完整的实心圆绘制实现3.1 环境准备与项目创建首先确保你有一个可用的VC开发环境。我推荐使用Visual Studio 2019或2022社区版它们免费且对C的支持非常完善。安装时记得勾选“使用C的桌面开发”工作负载这会自动安装必要的VC运行库和头文件。创建一个新的Windows桌面应用程序项目打开Visual Studio选择“创建新项目”。搜索并选择“Windows桌面向导”C点击下一步。输入项目名称例如“SolidCircleDemo”选择位置点击创建。在应用程序类型中选择“桌面应用程序(.exe)”在附加选项中勾选“空项目”然后点击确定。这样我们就得到了一个干净的Win32项目骨架。接下来我们需要添加一个主源文件如main.cpp并实现窗口过程和绘制代码。3.2 基础窗口框架搭建任何GDI绘制都需要一个载体窗口。我们先搭建一个最简化的Win32窗口程序。这段代码是标准模板它创建了一个窗口并处理了基本的消息循环。#include windows.h // 声明窗口过程函数 LRESULT CALLBACK WindowProc(HWND hwnd, UINT uMsg, WPARAM wParam, LPARAM lParam); int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nCmdShow) { // 1. 注册窗口类 const wchar_t CLASS_NAME[] LSolidCircleWindowClass; WNDCLASS wc {}; wc.lpfnWndProc WindowProc; wc.hInstance hInstance; wc.lpszClassName CLASS_NAME; wc.hCursor LoadCursor(NULL, IDC_ARROW); wc.hbrBackground (HBRUSH)(COLOR_WINDOW 1); // 默认背景色 RegisterClass(wc); // 2. 创建窗口 HWND hwnd CreateWindowEx( 0, CLASS_NAME, LVC 实心圆绘制示例, WS_OVERLAPPEDWINDOW, CW_USEDEFAULT, CW_USEDEFAULT, 800, 600, // 窗口初始位置和大小 NULL, NULL, hInstance, NULL ); if (hwnd NULL) { return 0; } // 3. 显示和更新窗口 ShowWindow(hwnd, nCmdShow); UpdateWindow(hwnd); // 4. 消息循环 MSG msg {}; while (GetMessage(msg, NULL, 0, 0)) { TranslateMessage(msg); DispatchMessage(msg); } return 0; }这个框架创建了一个带标题栏、可缩放的标准窗口。核心在于WindowProc函数我们将在这里处理绘制消息。3.3 在WM_PAINT消息中绘制实心圆窗口的绘制发生在响应WM_PAINT消息时。这是GDI编程的核心模式系统告诉你“窗口的某个区域需要重画了”你就在此时进行绘制操作。直接向窗口DC绘图是无效的必须遵循“BeginPaint/EndPaint”的流程。下面是完整的WindowProc函数实现其中包含了实心圆的绘制逻辑LRESULT CALLBACK WindowProc(HWND hwnd, UINT uMsg, WPARAM wParam, LPARAM lParam) { switch (uMsg) { case WM_PAINT: { PAINTSTRUCT ps; HDC hdc BeginPaint(hwnd, ps); // 获取窗口的绘制DC // --- 核心绘制代码开始 --- // 1. 创建一个红色的实心画刷 HBRUSH hRedBrush CreateSolidBrush(RGB(255, 0, 0)); // 2. 创建一个空画笔不绘制轮廓也可以创建特定画笔如 CreatePen(PS_SOLID, 1, RGB(0,0,0)) HPEN hNullPen (HPEN)GetStockObject(NULL_PEN); // 3. 将我们创建的工具选入DC并保存旧的工具句柄 HBRUSH hOldBrush (HBRUSH)SelectObject(hdc, hRedBrush); HPEN hOldPen (HPEN)SelectObject(hdc, hNullPen); // 4. 计算圆形的位置和大小 RECT rcClient; GetClientRect(hwnd, rcClient); // 获取窗口客户区大小 int clientWidth rcClient.right - rcClient.left; int clientHeight rcClient.bottom - rcClient.top; // 定义圆的半径取窗口短边的一半的80%确保圆在窗口内 int circleRadius (min(clientWidth, clientHeight) / 2) * 0.8; // 计算外接正方形矩形的左上角和右下角坐标使圆居中 int left (clientWidth / 2) - circleRadius; int top (clientHeight / 2) - circleRadius; int right (clientWidth / 2) circleRadius; int bottom (clientHeight / 2) circleRadius; // 5. 调用Ellipse函数绘制实心圆 Ellipse(hdc, left, top, right, bottom); // 6. 恢复DC原来的工具重要避免资源泄漏和状态混乱 SelectObject(hdc, hOldBrush); SelectObject(hdc, hOldPen); // 7. 删除我们创建的GDI对象释放资源 DeleteObject(hRedBrush); DeleteObject(hNullPen); // 注意对于GetStockObject获取的对象不要Delete // --- 核心绘制代码结束 --- EndPaint(hwnd, ps); // 结束绘制 return 0; } case WM_DESTROY: { PostQuitMessage(0); return 0; } default: return DefWindowProc(hwnd, uMsg, wParam, lParam); } }将这段WindowProc函数代码添加到main.cpp中WinMain函数之前编译并运行你应该能看到一个居中的红色实心圆。当你调整窗口大小时圆会随着窗口中心动态变化因为每次WM_PAINT都会重新计算位置。3.4 代码逐行解析与关键点CreateSolidBrush: 这是创建实心画刷的函数。参数是一个COLORREF通常由RGB宏生成。画刷决定了填充色。GetStockObject(NULL_PEN): 获取一个库存GDI对象——空画笔。库存对象是系统预定义的无需删除。使用空画笔意味着绘制的图形没有轮廓线。如果你想有轮廓可以用CreatePen(PS_SOLID, 宽度, 颜色)。SelectObject: 这是GDI编程中极其重要的函数。它将一个GDI对象如画刷、画笔、字体选入指定的DC并返回DC中之前同类型的对象句柄。你必须保存这个返回的旧句柄在绘制完成后用它恢复DC原状。这是良好的编程习惯能避免状态污染和资源问题。GetClientRect: 获取窗口客户区即去除了标题栏、边框后的可绘制区域的大小。这是我们计算绘制坐标的基础。坐标计算: 我们通过使圆的中心点(centerX, centerY)等于客户区中心(clientWidth/2, clientHeight/2)来实现居中。外接正方形的计算就是中心点坐标加减半径。Ellipse: 执行绘制。此时DC中当前选中的是红色画刷和空画笔所以结果是一个无边框的红色实心圆。资源管理:SelectObject恢复旧对象后调用DeleteObject删除我们创建的画刷和画笔。切记不要删除通过GetStockObject获取的对象只删除自己通过CreateXXX创建的对象。资源泄漏是GDI编程常见的错误会导致程序运行一段时间后界面异常或崩溃。4. 进阶技巧与性能优化实战4.1 避免闪烁双缓冲绘图技术如果你运行上面的代码在拖动窗口改变大小时可能会观察到圆在重绘时有轻微的闪烁。这是因为WM_PAINT直接向屏幕DC绘制在复杂或频繁重绘的场景下屏幕的更新过程会被用户看到产生闪烁。解决方案是双缓冲。双缓冲的原理是先在内存中创建一个“离屏”的位图DC把所有绘制操作都先在这个内存DC上完成然后一次性将这个内存位图“贴”到屏幕DC上。因为贴图操作很快用户就看不到中间的绘制过程了。以下是修改后的WM_PAINT处理代码加入了双缓冲case WM_PAINT: { PAINTSTRUCT ps; HDC hdc BeginPaint(hwnd, ps); RECT rcClient; GetClientRect(hwnd, rcClient); // 1. 创建兼容的内存DC HDC hMemDC CreateCompatibleDC(hdc); // 2. 创建兼容的位图大小与客户区相同 HBITMAP hMemBitmap CreateCompatibleBitmap(hdc, rcClient.right, rcClient.bottom); // 3. 将位图选入内存DC并保存旧位图 HBITMAP hOldBitmap (HBITMAP)SelectObject(hMemDC, hMemBitmap); // 4. 先用窗口背景色填充内存位图模拟背景 HBRUSH hBgBrush (HBRUSH)GetStockObject(WHITE_BRUSH); // 假设背景为白色 FillRect(hMemDC, rcClient, hBgBrush); // --- 在内存DC上绘制实心圆代码与之前类似--- HBRUSH hRedBrush CreateSolidBrush(RGB(255, 0, 0)); HPEN hNullPen (HPEN)GetStockObject(NULL_PEN); HBRUSH hOldBrush (HBRUSH)SelectObject(hMemDC, hRedBrush); HPEN hOldPen (HPEN)SelectObject(hMemDC, hNullPen); int circleRadius (min(rcClient.right, rcClient.bottom) / 2) * 0.8; int left (rcClient.right / 2) - circleRadius; int top (rcClient.bottom / 2) - circleRadius; int right (rcClient.right / 2) circleRadius; int bottom (rcClient.bottom / 2) circleRadius; Ellipse(hMemDC, left, top, right, bottom); SelectObject(hMemDC, hOldBrush); SelectObject(hMemDC, hOldPen); DeleteObject(hRedBrush); // --- 绘制结束 --- // 5. 将内存DC的内容一次性拷贝到屏幕DC BitBlt(hdc, 0, 0, rcClient.right, rcClient.bottom, hMemDC, 0, 0, SRCCOPY); // 6. 清理资源恢复内存DC的旧位图并删除位图和内存DC SelectObject(hMemDC, hOldBitmap); DeleteObject(hMemBitmap); DeleteDC(hMemDC); EndPaint(hwnd, ps); return 0; }实操心得双缓冲是改善GDI绘图体验的利器尤其对于动态图形、游戏或频繁更新的UI。它的代价是额外的内存开销和一次位图拷贝操作。对于静态或简单的界面直接绘制可能更高效。需要根据实际情况权衡。4.2 绘制多个与动态变化的圆单一静态圆只是开始。实际项目中我们可能需要绘制多个圆或者让圆能够响应鼠标、键盘事件动态变化。这涉及到状态管理和无效区域的概念。状态管理我们需要在窗口过程之外例如全局变量、窗口类额外数据、或者更好的C类成员变量中保存圆的信息如圆心坐标、半径、颜色等。无效区域当你改变了需要绘制的内容比如移动了圆的位置你需要告诉Windows“窗口的某某区域现在内容过时了需要重画”。这是通过InvalidateRect函数实现的。它会在消息队列中产生一个WM_PAINT消息但系统会智能地合并多个无效区域请求并在合适的时机通常是消息队列空闲时进行重绘而不是立即绘制。下面是一个简单示例演示如何通过鼠标点击改变圆的颜色首先定义存储圆信息的结构体和全局或静态变量。struct CircleInfo { int centerX, centerY; int radius; COLORREF color; }; static CircleInfo g_circle {400, 300, 100, RGB(255, 0, 0)}; // 初始状态修改WM_PAINT中的绘制代码使用g_circle的数据。// 在WM_PAINT中... HBRUSH hCircleBrush CreateSolidBrush(g_circle.color); // ... 计算left, top, right, bottom 基于 g_circle.centerX, centerY, radius Ellipse(hMemDC, left, top, right, bottom);在WindowProc中处理WM_LBUTTONDOWN鼠标左键按下消息改变颜色并触发重绘。case WM_LBUTTONDOWN: { // 随机生成一个新颜色 g_circle.color RGB(rand() % 256, rand() % 256, rand() % 256); // 标记整个客户区为无效需要重绘 InvalidateRect(hwnd, NULL, TRUE); // TRUE表示擦除背景 return 0; }InvalidateRect的第二个参数可以指定一个具体的矩形区域只重绘该区域以提升性能。第三个参数为TRUE会先发送WM_ERASEBKGND消息擦除背景为FALSE则保留原有背景直接在上面绘制。在我们的双缓冲例子中因为我们在内存DC中自己填充了背景所以这个参数影响不大。4.3 抗锯齿与更高质量的绘制标准的GDIEllipse函数绘制的是锯齿边缘的图形。如果你需要平滑的边缘抗锯齿GDI本身支持有限。一个方法是使用GDI它是随Windows XP引入的更先进的图形API。使用GDI绘制抗锯齿实心圆的步骤在项目中包含GDI头文件和链接库。初始化GDI环境通常在程序启动和退出时。在绘制代码中使用Graphics、SolidBrush、Pen等类。以下是使用GDI修改绘制部分的示例片段#include gdiplus.h #pragma comment(lib, gdiplus.lib) using namespace Gdiplus; // ... 在WinMain中初始化GDI ... ULONG_PTR gdiplusToken; GdiplusStartupInput gdiplusStartupInput; GdiplusStartup(gdiplusToken, gdiplusStartupInput, NULL); // ... 在WindowProc的WM_PAINT中 ... // 假设我们已经有了内存DC hMemDC 和其对应的Graphics对象 graphics需从hMemDC创建 Graphics graphics(hMemDC); graphics.SetSmoothingMode(SmoothingModeAntiAlias); // 开启抗锯齿 SolidBrush brush(Color(255, 255, 0, 0)); // ARGB颜色255表示不透明 // GDI的坐标和大小可以使用浮点数更精确 graphics.FillEllipse(brush, (REAL)left, (REAL)top, (REAL)(right-left), (REAL)(bottom-top)); // ... 程序退出前 ... GdiplusShutdown(gdiplusToken);GDI提供了更丰富的功能如渐变画刷、路径、图像处理等但也会引入额外的复杂性和轻微的性能开销。对于大多数传统的桌面UI标准GDI已经足够。是否需要GDI取决于你对图形质量的具体要求。5. 常见问题排查与深度避坑指南即使是一个简单的实心圆在实际编码和调试中也会遇到各种问题。下面我整理了一些典型问题及其解决方案很多都是我在早期项目中踩过的坑。5.1 问题一圆没有显示出来症状窗口一片空白或者只有背景色看不到圆。排查步骤检查WM_PAINT处理确保你的绘制代码确实在WM_PAINT消息分支里并且正确调用了BeginPaint和EndPaint。检查坐标计算这是最常见的原因。打印或调试输出你计算出的left, top, right, bottom值。确保它们都在客户区范围内并且left right且top bottom。一个常见的错误是坐标计算出现负数或零导致矩形无效。检查画刷和画笔确认你创建并选入了有效的画刷。尝试使用一个库存画刷GetStockObject(LTGRAY_BRUSH)来测试排除画刷创建失败的问题。同时检查你是否不小心选入了一个NULL_BRUSH库存的空画刷那会导致无法填充。检查DC有效性确保BeginPaint返回的hdc不是NULL。背景覆盖如果你在WM_ERASEBKGND消息中返回了TRUE或者窗口类背景画刷擦除了客户区它可能会在WM_PAINT之后执行覆盖你的绘制。可以在WM_PAINT中使用双缓冲并自己填充背景或者在窗口类注册时设置wc.hbrBackground NULL并处理WM_ERASEBKGND返回TRUE表示已处理背景擦除。5.2 问题二圆的位置或大小不对不居中症状圆偏离中心或者不是正圆。排查步骤验证客户区坐标使用GetClientRect获取的right和bottom是宽度和高度值而不是右下角坐标的绝对像素值不对这里是个关键点RECT的right和bottom是排他性的边界。即客户区有效像素的X坐标范围是[0, right)Y坐标范围是[0, bottom)。所以中心点应该是(right/2, bottom/2)。计算半径和矩形边界时要用整数运算注意避免因整数除法导致的中心点偏移。确保是正方形圆的半径radius在计算left, top, right, bottom时必须使用相同的值。right-left应该等于bottom-top。如果不等画出的就是椭圆。考虑窗口边框和菜单GetClientRect获取的是客户区不包括非客户区。如果你的窗口有菜单、工具栏客户区的原点(0,0)就在这些区域的下方。这通常是我们期望的。但如果你需要基于整个窗口计算要用GetWindowRect然后转换坐标这通常更复杂。5.3 问题三程序运行一段时间后界面异常或崩溃症状程序运行几分钟或操作后绘图出现乱码或者直接崩溃。排查步骤GDI对象泄漏这是Win32 GDI编程的“头号杀手”。务必检查每一个CreateSolidBrush,CreatePen,CreateCompatibleDC,CreateCompatibleBitmap等创建的句柄是否都有对应的DeleteObject或DeleteDC。特别注意SelectObject返回的旧句柄在删除自己创建的新对象之前应该先被选回DC或者确保DC即将被释放。一个良好的模式是HGDIOBJ hOld SelectObject(hdc, hNew); // ... 绘制操作 ... SelectObject(hdc, hOld); // 恢复旧对象 DeleteObject(hNew); // 删除新对象库存对象通过GetStockObject获取的对象如NULL_PEN,WHITE_BRUSH绝对不要调用DeleteObject。内存DC和位图泄漏双缓冲中创建的hMemDC和hMemBitmap必须成对删除。顺序一般是先SelectObject恢复旧的位图到内存DC然后DeleteObject删除内存位图最后DeleteDC删除内存DC。多线程访问GDI对象和DC不是线程安全的。如果你在多个线程中操作同一个DC或GDI对象会导致未定义行为。所有GUI绘制操作都应在主线程窗口过程所在线程中进行。5.4 问题四绘制性能差拖动窗口卡顿症状拖动窗口改变大小时圆的重绘跟不上感觉卡顿。解决方案使用双缓冲如前所述双缓冲能有效减少闪烁但本身不能提升绘制速度。它只是把多次绘制合并成一次位图拷贝。减小无效区域不要总是InvalidateRect(hwnd, NULL, TRUE)使整个客户区无效。如果你只改变了一个小圆的位置可以只使圆所在的旧区域和新区域无效。计算这两个区域的并集矩形传递给InvalidateRect。RECT rcOld, rcNew; // 计算旧圆和新圆的外接矩形 rcOld CalculateCircleRect(oldCenter, oldRadius); rcNew CalculateCircleRect(newCenter, newRadius); // 使两个矩形区域无效 InvalidateRect(hwnd, rcOld, FALSE); InvalidateRect(hwnd, rcNew, FALSE);避免在WM_PAINT中做复杂计算WM_PAINT可能被频繁调用。将可以缓存的计算结果如圆的坐标、画刷句柄移到外部变量中只在数据改变时重新计算。考虑更现代的图形API如果对性能要求极高且项目允许可以考虑使用Direct2D或OpenGL。但对于传统的业务UI和简单的图形优化后的GDI通常足够。5.5 一个实用的调试技巧验证GDI对象创建在调试时可以在创建GDI对象后立即检查句柄是否有效。虽然CreateSolidBrush在失败时返回NULL但一些更复杂对象的创建失败可能不那么明显。一个简单的验证方法是使用GetObjectType。HBRUSH hBrush CreateSolidBrush(someColor); if (hBrush NULL || GetObjectType(hBrush) 0) { // 对象创建失败或无效 DWORD err GetLastError(); // 可以输出错误信息或使用默认画刷 hBrush (HBRUSH)GetStockObject(BLACK_BRUSH); }通过系统地理解原理、谨慎地管理资源、并善用调试技巧你就能在VC的图形编程路上走得更稳更远。这个实心圆就是你图形世界的第一块坚实基石。