1. 项目概述为什么选择VC与Visual Studio 2019进行二维绘图如果你是一位从C语言或C基础学起对Windows桌面应用开发感兴趣尤其是想亲手绘制点、线、面实现一个属于自己的画图工具或数据可视化界面那么“VC”和“Visual Studio 2019”这个组合绝对是你绕不开的经典路径。VC通常指的是微软Visual C它不是一门新语言而是C语言在微软生态下的一个“开发环境套餐”核心是那套经典的MFCMicrosoft Foundation Classes框架和Win32 API。很多人觉得它“老”但正是这份“老”意味着稳定、深入系统底层、以及对Windows图形界面GDI/GDI无与伦比的直接控制力。Visual Studio 2019作为其承载的IDE在用户体验、调试工具和现代C标准支持上达到了一个非常成熟的平衡点。这个项目教程的核心就是带你穿越“老技术”的表面直击其仍然鲜活的内核如何使用VC在Visual Studio 2019中从零开始构建一个具备实际交互功能的二维绘图程序。这不仅仅是画个矩形那么简单它涉及Windows消息循环、设备上下文DC、绘图资源管理、坐标变换、鼠标交互等一系列桌面应用开发的核心概念。掌握了这些你不仅能做出绘图程序更能透彻理解绝大多数Windows桌面软件包括很多专业工具软件的界面与图形渲染基础。相比于直接使用更上层的图形库如OpenGL、DirectX或跨平台框架如Qt从这里入手你对“窗口”、“像素”、“消息”的理解会扎实得多。2. 环境准备与项目创建避开第一个坑万事开头难在VC世界里项目类型选错可能意味着后面要填无数的坑。我们目标是创建一个带图形界面的Windows桌面应用而不是控制台程序。2.1 Visual Studio 2019的安装与组件选择首先确保你安装的是Visual Studio 2019 Community或以上版本。Community版对个人和学习者完全免费功能足够。在安装时工作负载的选择至关重要。你至少需要勾选使用C的桌面开发这是核心工作负载包含了VC编译器、链接器、标准库以及最重要的MFC和ATL支持。可选如果你后续可能涉及更现代的C图形可以勾选“使用C的Linux开发”或“使用C的游戏开发”它们会包含一些额外的库和工具但非本项目必需。注意很多新手会直接使用默认安装或只选“C核心功能”那样创建项目时是找不到MFC应用向导的。如果已经安装但缺少组件可以打开Visual Studio Installer进行修改。2.2 创建MFC应用程序项目启动Visual Studio 2019点击“创建新项目”。在搜索框中输入“MFC”选择“MFC应用”点击下一步。配置新项目给你的项目起个名字比如“My2DDrawer”。注意“位置”路径不要有中文或特殊字符这是避免编译问题的好习惯。“解决方案名称”默认与项目名相同即可。应用程序类型这是关键一步。选择“单个文档”。虽然“基于对话框”的应用程序更简单但“单个文档”架构更接近真实的绘图软件如Windows画图它天然支持视图View和文档Document的分离便于我们后续管理图形数据。在“项目样式”里选择“Visual Studio”风格即可。文档模板属性文件扩展名可以填“myd”描述填“My Draw File”。这主要是为了未来如果要做文件保存/打开功能时用的暂时不影响绘图。数据库支持选择“无”。用户界面功能这里保持默认即可确保“使用菜单栏和工具栏”、“使用状态栏”、“使用浏览器样式”是选中的。这会给我们的程序一个标准Windows应用的外观。高级功能通常保持默认。注意“公共控件清单”建议启用以确保使用新版本的通用控件。生成的类最后一步你会看到VS为你生成了几个主要的类CMy2DDrawerApp应用类、CMainFrame主框架类、CMy2DDrawerDoc文档类、CMy2DDrawerView视图类。我们的绘图操作主要将在CMy2DDrawerView中完成。点击“完成”VS会自动生成一个具备基础窗口、菜单、工具栏的MFC应用程序框架。直接按F5编译运行你应该能看到一个标准的Windows窗口程序。虽然现在它什么也画不了但坚实的脚手架已经搭好了。3. 核心绘图原理理解GDI与设备上下文在开始写代码画图之前必须理解两个核心概念GDI和设备上下文。你可以把电脑屏幕或者一块内存想象成一张画布。GDIGraphics Device Interface图形设备接口就是Windows系统提供的一整套画图工具比如画笔、画刷、调色板。而设备上下文Device Context, DC就是连接你的程序、GDI工具和最终“画布”可以是屏幕、打印机、内存位图之间的那个“绘图环境”或“绘图许可证”。3.1 设备上下文DC的获取与释放在MFC的视图类CMy2DDrawerView中绘图最常用的DC是客户区DC即窗口内除了标题栏、菜单、边框以外的区域。所有的绘图操作都必须在一次有效的DC获取和释放之间完成。MFC提供了几种获取DC的方式OnDraw函数中的pDC这是最标准、最常用的方式。当窗口需要重绘如被其他窗口遮挡后重新显示、窗口大小改变时框架会自动调用视图类的OnDraw(CDC* pDC)函数并传入一个已经准备好的DC。所有为了保持画面持久性的绘图代码都应该放在这里。void CMy2DDrawerView::OnDraw(CDC* pDC) { CMy2DDrawerDoc* pDoc GetDocument(); ASSERT_VALID(pDoc); if (!pDoc) return; // 在这里添加你的绘图代码 // 例如pDC-Rectangle(10, 10, 200, 100); }CPaintDC类专门用于响应WM_PAINT消息即重绘消息。在视图类的OnPaint()函数中你会看到它的使用。OnDraw函数本质上也是被OnPaint调用的。对于简单的自定义绘制直接重写OnDraw就够了。CClientDC类当你需要在非重绘消息响应中立即绘图时使用例如响应鼠标移动实时画线。它在构造函数中获取DC析构时自动释放非常方便。void CMy2DDrawerView::OnMouseMove(UINT nFlags, CPoint point) { if (m_bDrawing) { // 假设一个正在绘制的标志 CClientDC dc(this); // 获取客户区DC dc.MoveTo(m_ptOrigin); // 上次的点 dc.LineTo(point); // 当前鼠标位置 m_ptOrigin point; // 更新原点 } CView::OnMouseMove(nFlags, point); }CWindowDC类获取整个窗口包括非客户区的DC用得较少。重要心得务必注意DC资源的获取和释放必须成对出现。使用CClientDC、CPaintDC这类MFC封装类是最安全的方式它们利用C的RAII资源获取即初始化特性在对象离开作用域时自动释放DC避免了资源泄漏。如果手动调用GetDC()就必须记得配对调用ReleaseDC()。3.2 GDI绘图对象画笔、画刷与颜色有了DC我们还需要工具。GDI的主要绘图对象是CPen画笔和CBrush画刷。CPen控制线条的样式、宽度和颜色。比如实线、虚线、点线线的粗细以及红色、蓝色等。CBrush控制封闭图形内部的填充样式和颜色。比如实心填充、网格线填充、位图填充以及填充的颜色。使用它们的关键步骤是“选入”DC创建GDI对象定义一个CPen或CBrush对象并用参数初始化它。选入DC使用CDC::SelectObject将新创建的GDI对象选入DC。这个函数会返回一个指向之前GDI对象的指针务必保存好。执行绘图调用DC的绘图函数如Rectangle,Ellipse,LineTo等。恢复原对象绘图完成后使用保存的指针再次调用SelectObject将原来的GDI对象选回DC。这是良好的编程习惯可以避免状态混乱。GDI对象销毁CPen和CBrush对象在析构时会自动删除其底层的GDI资源。void CMy2DDrawerView::OnDraw(CDC* pDC) { // 1. 创建一支红色、3像素宽的实线画笔 CPen redPen(PS_SOLID, 3, RGB(255, 0, 0)); // 2. 创建一把蓝色的实心画刷 CBrush blueBrush(RGB(0, 0, 255)); // 3. 将新画笔选入DC并保存旧画笔 CPen* pOldPen pDC-SelectObject(redPen); // 4. 将新画刷选入DC并保存旧画刷 CBrush* pOldBrush pDC-SelectObject(blueBrush); // 5. 绘制一个矩形边框用红笔内部用蓝刷填充 pDC-Rectangle(50, 50, 250, 150); // 6. 恢复旧的GDI对象 pDC-SelectObject(pOldBrush); pDC-SelectObject(pOldPen); // 7. redPen和blueBrush对象离开作用域自动析构释放资源 }颜色表示使用RGB(r, g, b)宏每个分量取值0-255。例如RGB(255, 255, 0)是黄色。4. 实现交互式绘图处理鼠标消息一个只会静态显示图形的程序是死的。要让用户能画必须处理鼠标消息。MFC通过消息映射机制让我们可以轻松地响应鼠标事件。4.1 添加消息处理函数在“类视图”中右键点击CMy2DDrawerView类选择“属性”。在属性窗口中点击顶部的“消息”图标一个小闪电。这里列出了所有可以处理的消息。我们需要关注以下几个WM_LBUTTONDOWN鼠标左键按下。WM_MOUSEMOVE鼠标移动。WM_LBUTTONUP鼠标左键释放。为它们添加处理函数在消息右侧的下拉框中选择“添加 OnLButtonDown”等。VS会自动在.cpp文件中生成消息映射条目和空的函数体。4.2 实现基本画线功能我们以实现一个最简单的“橡皮筋”画线功能为例演示鼠标交互绘图的完整流程。首先在视图类的头文件CMy2DDrawerView.h中添加几个成员变量用于记录绘图状态class CMy2DDrawerView : public CView { // ... 其他代码 protected: CPoint m_ptOrigin; // 记录线条的起点 CPoint m_ptOld; // 记录上一个鼠标位置用于擦除旧线 BOOL m_bDrawing; // 标志是否正在绘制中 // ... 其他代码 };在视图类的构造函数中初始化它们CMy2DDrawerView::CMy2DDrawerView() noexcept : m_bDrawing(FALSE) { // TODO: 在此处添加构造代码 }然后实现消息处理函数void CMy2DDrawerView::OnLButtonDown(UINT nFlags, CPoint point) { // 按下左键开始绘图 m_ptOrigin point; // 记录起点 m_ptOld point; // 初始化旧点 m_bDrawing TRUE; // 设置绘制标志 // 捕获鼠标确保即使鼠标移出窗口也能收到WM_MOUSEMOVE和WM_LBUTTONUP消息 SetCapture(); CView::OnLButtonDown(nFlags, point); } void CMy2DDrawerView::OnMouseMove(UINT nFlags, CPoint point) { if (m_bDrawing) { // 获取客户区DC CClientDC dc(this); // 设置绘图模式为R2_NOTXORPEN这是一种异或模式第二次画同一条线会擦除它 int nOldMode dc.SetROP2(R2_NOTXORPEN); // 擦除从上一点到旧点画的线如果存在 dc.MoveTo(m_ptOrigin); dc.LineTo(m_ptOld); // 画从起点到当前点的新线 dc.MoveTo(m_ptOrigin); dc.LineTo(point); // 恢复原来的绘图模式 dc.SetROP2(nOldMode); // 更新旧点 m_ptOld point; } CView::OnMouseMove(nFlags, point); } void CMy2DDrawerView::OnLButtonUp(UINT nFlags, CPoint point) { if (m_bDrawing) { m_bDrawing FALSE; // 结束绘制 ReleaseCapture(); // 释放鼠标捕获 // 最终我们需要将这条线永久地画到视图上。 // 但此时我们只是擦除了橡皮筋线。为了永久保存我们需要 // 1. 将这条线的数据起点m_ptOrigin终点point保存到文档类中。 // 2. 然后强制视图重绘在OnDraw中根据文档数据重新绘制所有图形。 CClientDC dc(this); // 用最终颜色和样式画一条实线例如黑色 CPen blackPen(PS_SOLID, 1, RGB(0, 0, 0)); CPen* pOldPen dc.SelectObject(blackPen); dc.MoveTo(m_ptOrigin); dc.LineTo(point); dc.SelectObject(pOldPen); // TODO: 将线段数据(m_ptOrigin, point)添加到文档的数据结构中 // GetDocument()-AddLine(m_ptOrigin, point); } CView::OnLButtonUp(nFlags, point); }4.3 绘图模式R2_NOTXORPEN的妙用上面代码的关键是dc.SetROP2(R2_NOTXORPEN)。SetROP2设置的是光栅操作模式即新像素颜色与屏幕上原有像素颜色的混合方式。R2_NOTXORPEN模式意味着用画笔颜色与屏幕颜色进行“异或非”操作。一个有趣的特性是用同一种颜色在同一条路径上画两次屏幕会恢复到最初的状态。这正是我们实现“橡皮筋”效果的基础在OnMouseMove中我们先画一次线显示鼠标移动后在旧位置再画一次擦除然后在新位置画一次显示新的。这样就能看到一条跟随鼠标移动的线而不会在屏幕上留下拖影。实操心得R2_NOTXORPEN虽然方便但它的显示效果颜色取决于屏幕原有颜色可能不是你想要的确切颜色。它只适合做临时性的、需要擦除的绘图反馈。最终确定图形时OnLButtonUp中应该使用R2_COPYPEN默认模式或直接指定画笔颜色来绘制永久性的图形。5. 图形数据持久化文档/视图架构的应用到目前为止我们画的线在窗口重绘比如最小化再恢复后会消失。因为OnDraw函数里没有画它们的代码。MFC的文档/视图架构就是为了解决这个问题文档类CDocument派生类负责存储数据视图类CView派生类负责显示和编辑数据。5.1 在文档类中定义数据结构我们需要在文档类CMy2DDrawerDoc中定义一个容器来存储用户绘制的所有图形元素。为了简单我们先以线段为例。在CMy2DDrawerDoc.h中#include vector #include afxwin.h // 确保CPoint等类型可用 class CLine { // 一个简单的线段类 public: CPoint m_start; CPoint m_end; COLORREF m_color; int m_width; CLine(CPoint s, CPoint e, COLORREF c RGB(0,0,0), int w 1) : m_start(s), m_end(e), m_color(c), m_width(w) {} }; class CMy2DDrawerDoc : public CDocument { // ... 其他代码 public: std::vectorCLine m_lines; // 存储所有线段的数组 void AddLine(CPoint start, CPoint end, COLORREF color RGB(0,0,0), int width 1); // ... 其他代码 };在CMy2DDrawerDoc.cpp中实现AddLinevoid CMy2DDrawerDoc::AddLine(CPoint start, CPoint end, COLORREF color, int width) { m_lines.push_back(CLine(start, end, color, width)); SetModifiedFlag(TRUE); // 标记文档已被修改关闭时会提示保存 UpdateAllViews(NULL); // 通知所有关联的视图进行重绘 }5.2 在视图类中保存与读取数据修改视图类CMy2DDrawerView的OnLButtonUp函数将最终的线段保存到文档void CMy2DDrawerView::OnLButtonUp(UINT nFlags, CPoint point) { if (m_bDrawing) { m_bDrawing FALSE; ReleaseCapture(); // 获取文档指针 CMy2DDrawerDoc* pDoc GetDocument(); ASSERT_VALID(pDoc); // 将线段数据添加到文档 pDoc-AddLine(m_ptOrigin, point, RGB(0, 0, 0), 2); // 黑色2像素宽 // 注意此时AddLine内部已经调用了UpdateAllViews会触发重绘 // 所以我们不需要再手动画一遍永久线了 } CView::OnLButtonUp(nFlags, point); }然后在视图类的OnDraw函数中根据文档数据重绘所有图形void CMy2DDrawerView::OnDraw(CDC* pDC) { CMy2DDrawerDoc* pDoc GetDocument(); ASSERT_VALID(pDoc); if (!pDoc) return; // 遍历文档中存储的所有线段并绘制出来 for (const auto line : pDoc-m_lines) { CPen pen(PS_SOLID, line.m_width, line.m_color); CPen* pOldPen pDC-SelectObject(pen); pDC-MoveTo(line.m_start); pDC-LineTo(line.m_end); pDC-SelectObject(pOldPen); } }现在运行程序画几条线然后最小化窗口再恢复或者用另一个窗口盖住它再移开你会发现画的线都还在这就是文档/视图架构的魔力数据与显示分离。5.3 实现撤销(Undo)功能思路有了文档存储所有图形实现撤销功能就有了基础。一个简单的思路是在文档类中不仅保存当前图形列表还保存一个“命令历史”列表每个命令代表一次添加或删除操作。当用户添加一条线时实际上是将一个“添加命令”推入历史栈。执行撤销时从历史栈顶弹出命令执行其逆操作如果是添加则删除对应的线然后重绘视图。 这涉及到命令模式的设计是进阶内容但核心思想离不开文档对数据的集中管理。6. 高级功能拓展图形选择、属性编辑与双缓冲一个基本的绘图程序成型后我们可以考虑添加更多实用功能。6.1 图形选择与拾取如何让用户点击选中已经画好的某条线然后改变它的颜色或删除它这需要实现“拾取”算法。矩形包围盒判断对于线段可以计算鼠标点与线段的最短距离如果小于某个阈值如5像素则认为选中。更通用的方法是为每个图形计算其最小包围矩形CRect然后判断鼠标点是否在该矩形内。命中测试在视图类中添加一个HitTest(CPoint point)函数遍历所有图形返回被点击图形的索引或指针。反馈绘制选中一个图形后可以在OnDraw中用它被选中的样式如虚线边框、控制点重绘它。6.2 图形属性编辑选中图形后可以通过对话框或属性栏来修改其属性颜色、线宽等。在文档类中为图形数据结构添加修改属性的方法。在视图类中响应菜单或工具栏按钮弹出标准颜色对话框CColorDialog或自定义对话框。获取新属性后调用文档类的方法更新数据并调用UpdateAllViews重绘。6.3 解决闪烁问题双缓冲技术当图形很多或绘制复杂时直接OnDraw中绘制到屏幕DC可能会引起明显的闪烁。这是因为屏幕是一点点被更新的。双缓冲技术是解决这个问题的标准方案。原理先在内存中创建一个与屏幕显示区域兼容的位图“后台缓冲区”然后在这个内存位图的DC上完成所有绘制操作最后一次性将这个位图复制到屏幕DC上。这样用户看到的是完整的、瞬间更新的画面。在视图类的OnDraw中实现双缓冲void CMy2DDrawerView::OnDraw(CDC* pDC) { CMy2DDrawerDoc* pDoc GetDocument(); ASSERT_VALID(pDoc); if (!pDoc) return; CRect rect; GetClientRect(rect); // 获取客户区矩形 // 1. 创建兼容的内存DC CDC memDC; memDC.CreateCompatibleDC(pDC); // 2. 创建兼容的位图大小与客户区相同 CBitmap memBitmap; memBitmap.CreateCompatibleBitmap(pDC, rect.Width(), rect.Height()); // 3. 将位图选入内存DC并保存旧位图 CBitmap* pOldBitmap memDC.SelectObject(memBitmap); // 4. 先用背景色填充内存位图模拟擦除背景 memDC.FillSolidRect(rect, RGB(255, 255, 255)); // 白色背景 // 5. 在内存DC上绘制所有图形调用一个统一的绘制函数传入memDC DrawAllGraphics(memDC, pDoc); // 6. 将内存DC的内容一次性“贴”到屏幕DC上 pDC-BitBlt(0, 0, rect.Width(), rect.Height(), memDC, 0, 0, SRCCOPY); // 7. 恢复内存DC的旧位图memBitmap会自动析构释放资源 memDC.SelectObject(pOldBitmap); // memDC和memBitmap离开作用域自动释放 } // 将原来的绘制代码移到一个单独的函数中 void CMy2DDrawerView::DrawAllGraphics(CDC* pDC, CMy2DDrawerDoc* pDoc) { for (const auto line : pDoc-m_lines) { CPen pen(PS_SOLID, line.m_width, line.m_color); CPen* pOldPen pDC-SelectObject(pen); pDC-MoveTo(line.m_start); pDC-LineTo(line.m_end); pDC-SelectObject(pOldPen); } }避坑技巧双缓冲会消耗额外的内存对于非常大的绘图区域要谨慎使用。另外确保memBitmap的创建大小与当前客户区一致在窗口大小改变时需要重新创建。一个常见的优化是将内存位图作为视图类的成员变量只在窗口大小改变或首次需要时创建/调整大小。7. 项目调试与发布生成可独立运行的程序开发完成后你需要将程序分享给别人或部署到其他电脑上。7.1 调试技巧与常见问题崩溃与调试文件如果程序发布后在其他电脑崩溃可以启用“生成调试信息”。在项目属性 - “链接器” - “调试” - “生成调试信息”选择“是(/DEBUG)”。这样生成的.exe文件会包含.pdb文件在崩溃时能提供更多线索。运行时库依赖这是VC程序部署中最常见的问题。你的程序可能依赖于特定版本的Microsoft Visual C Redistributable。在项目属性 - “C/C” - “代码生成” - “运行时库”中你可以选择/MT静态链接运行时库。生成的.exe会变大但可以不依赖外部运行时库独立运行。/MD动态链接运行时库。生成的.exe较小但目标机器必须安装对应版本的VC运行库可以从微软官网下载安装包。对于新手建议选择/MT虽然文件大几MB但省去了部署运行库的麻烦。Unicode字符集在项目属性 - “高级” - “字符集”中通常建议使用“使用Unicode字符集”。这能更好地支持多语言也是现代Windows程序的推荐设置。但要注意所有字符串处理函数和API都要使用宽字符版本如wchar_t,TCHAR,_tcslen。7.2 发布配置与生成在VS顶部的工具栏将“解决方案配置”从“Debug”切换到“Release”。Release版本经过了编译器优化去除了调试信息运行速度更快文件更小。按F7重新生成解决方案。在项目文件夹下的x64\Release或Win32\Release取决于你的平台目录中找到生成的.exe文件。如果你使用的是/MT选项这个.exe通常可以直接复制到其他没有安装Visual Studio的Windows 10/11电脑上运行。如果使用/MD则需要确保目标电脑安装了对应版本的VC运行库。7.3 处理第三方库依赖如果你的项目将来需要用到一些开源的C图形库比如用于更高级绘图的Cairo、Anti-Grain Geometry等或者像热词中提到的“flutter打包怎么带vc库”这种场景你需要处理库的链接。静态库(.lib)将库的.lib文件路径添加到项目属性 - “链接器” - “输入” - “附加依赖项”中并将.lib文件所在目录添加到“链接器” - “常规” - “附加库目录”中。头文件(.h)路径则需要添加到“C/C” - “常规” - “附加包含目录”。动态库(.dll)除了上述的.lib导入库和头文件设置还需要将.dll文件复制到你的.exe文件所在的目录或者系统路径下。走完以上所有步骤你已经完成了一个具备基本绘图、数据持久化、交互功能的VC二维绘图程序。从项目创建到原理理解再到功能实现和问题排查这套流程覆盖了Windows桌面图形编程的核心脉络。虽然MFC和原始GDI在今天看来不是最时髦的技术但它们是理解Windows图形编程基石的最佳入口。掌握了这些你再学习WPF、Qt甚至DirectX都会发现很多概念是相通的。编程实战的魅力就在于从一个明确的小目标开始在解决问题的过程中那些抽象的概念自然而然地变得具体而清晰。