1. 项目概述:为什么选择VC++与Visual Studio 2019进行二维绘图?
如果你是一位从C语言或C++基础学起,对Windows桌面应用开发感兴趣,尤其是想亲手绘制点、线、面,实现一个属于自己的画图工具或数据可视化界面,那么“VC++”和“Visual Studio 2019”这个组合,绝对是你绕不开的经典路径。VC++,通常指的是微软Visual C++,它不是一门新语言,而是C++语言在微软生态下的一个“开发环境套餐”,核心是那套经典的MFC(Microsoft 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和设备上下文。你可以把电脑屏幕(或者一块内存)想象成一张画布。GDI(Graphics 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::vector<CLine> m_lines; // 存储所有线段的数组 void AddLine(CPoint start, CPoint end, COLORREF color = RGB(0,0,0), int width = 1); // ... 其他代码 };在CMy2DDrawerDoc.cpp中实现AddLine:
void 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,都会发现很多概念是相通的。编程实战的魅力就在于,从一个明确的小目标开始,在解决问题的过程中,那些抽象的概念自然而然地变得具体而清晰。