ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

MFC图形界面编程实战:彩色图形绘制与控件交互实例详解

MFC图形界面编程实战:彩色图形绘制与控件交互实例详解 简介MFC微软基础类库图形及界面编程实例围绕对话框的完整开发过程展开面向已掌握C基础、希望转向Windows桌面界面开发的程序员重点演示对话框创建、控件初始化与交互事件响应。压缩包共54个文件大小3.34MB包含多个C源文件、头定义文件、资源脚本文件以及图标、位图等界面素材同时提供发布版与调试版两个可执行程序并附有目标文件和预编译头等中间产物便于对照源码与运行效果。已有1656人学习示例代码以“ShowDialog”为题逐步讲解如何在对话框初始化时设置控件初始状态如何通过控件ID获取界面元素如何用模态方式弹出对话框并根据用户操作返回结果此外还给出了消息映射与按钮点击的响应写法。整份代码目录结构清晰基本覆盖了MFC对话框应用从创建、显示到关闭退出的完整生命周期适合作为系统学习MFC界面编程的入门到进阶资料。 说实话MFC这个名字现在在C圈子里多少有点“老古董”的味道但真去翻老工程、做个桌面小工具、或者想彻底搞明白Windows窗口消息机制它依然是一块绕不开的硬骨头。最近我做了个“图形界面交互”的MFC实例功能不复杂一个对话框中间根据列表框的选中项绘制不同的彩色图形底下的自绘按钮用来切换填充颜色静态文本实时刷新当前状态。做完以后我发现这个看似不起眼的例子竟然把MFC最核心的那套东西——设备上下文、消息映射、控件自绘、资源管理——全部串起来了。所以我把这个实例从设计到落地完完整整拆给你看代码都是照着可以直接跑通的标准来写。如果你正准备用MFC做图形界面或者刚把Visual Studio装上、还不清楚从哪儿下手这篇应该能给你省不少试错时间。1. 这个实例到底在做什么一个带交互的彩色图形展示窗我接到这个需求的时候对方提得很简单希望有个窗口中间画一块正方形能换颜色最好还能用鼠标点一点就切换形状。听起来像是个期末大作业对吧但落到MFC里这个需求恰好覆盖了图形和界面编程两个大类所以我把它扩成了下面这个结构左侧是一个列表框ListBox用来选择当前绘制的图形类型正方形、圆形、三角形。右侧是一组自绘按钮三个分别对应红色、绿色、蓝色点击后设置当前图形的填充色。顶部是一个静态文本Static Text实时显示“当前图形正方形颜色红色”这样的状态信息。中间客户区用于图形绘制根据当前的选择即时重绘。为什么要用这几个控件组合而不是一个按钮搞定所有事情因为MFC的本质是消息驱动图形编程绕不开设备上下文界面编程绕不开控件交互。列表框负责“触发消息”静态文本负责“反馈结果”自绘按钮负责“替代默认外观”客户区绘图负责“响应重绘”。这几个点凑齐了你对MFC的掌握就能从“会用”变成“能排错”。另外提一句MFC虽然老但它的图形接口底层是GDI图形设备接口这套概念平行映射到现代Windows开发依然成立哪怕你以后不写MFC去写WinUI或者Qt设备上下文和重绘机制的本质都不会变。2. 项目骨架搭建VS2013下MFC对话框程序的关键设置先明确一下环境我用的是Visual Studio 2013MFC版本是12.0。选这个版本纯粹是因为大量存量程序和老项目都基于它而且对话框资源编辑器用起来熟。创建项目的路径是文件 - 新建 - 项目 - Visual C - MFC应用程序然后在向导里把应用程序类型改成“基于对话框”。这里有几个关键选项值得单独解释。第一个是“使用 MFC 的共享 DLL”和“静态链接 MFC”的选择。共享DLL生成的可执行文件体积小但换到没有装运行库的机器上容易报“缺少mfc120u.dll”这种错误静态链接则把所有MFC代码揉进exe体积大但部署省心。自己练习或者做内部工具我建议直接选静态链接省得后面打包环节被DLL补丁折磨。第二个是向导最后一步里的“生成的类”和“基类”确认基类显示的是CButton的派生关系不用管只需要保证对话框的主类继承CDialogEx即可。MFC的项目文件结构很清楚主要文件就三类xxDlg.h和xxDlg.cpp主对话框、xx.h和xx.cpp应用对象、以及资源文件xx.rc。创建完项目后先在资源编辑器里拖控件。对话框模板上放一个ListBoxIDC_LIST_SHAPE、三个ButtonIDC_BTN_COLOR_R、IDC_BTN_COLOR_G、IDC_BTN_COLOR_B和一个Static TextIDC_STATIC_STATUS。放置之后调整布局图形绘制区就是对话框客户区的剩余部分。接下来要理解MFC最核心的机制消息映射。MFC没有强制你用Windows API那套switch-case处理窗口过程而是通过一组宏把消息和成员函数关联起来。打开xxDlg.cpp你会看到类似下面这样的结构BEGIN_MESSAGE_MAP(CMFCGraphDlg, CDialogEx) ON_WM_PAINT() ON_WM_ERASEBKGND() ON_LBN_SELCHANGE(IDC_LIST_SHAPE, CMFCGraphDlg::OnLbnSelchangeListShape) ON_BN_CLICKED(IDC_BTN_COLOR_R, CMFCGraphDlg::OnBnClickedColorR) ON_BN_CLICKED(IDC_BTN_COLOR_G, CMFCGraphDlg::OnBnClickedColorG) ON_BN_CLICKED(IDC_BTN_COLOR_B, CMFCGraphDlg::OnBnClickedColorB) END_MESSAGE_MAP()每一行宏做的事情就是在窗口收到指定消息时把消息路由到后面的处理函数。你在写图形和界面代码时大部分时间就是在填这个映射表和处理函数。3. 核心绘图用GDI画出一个可切换的彩色正方形MFC的图形绘制不是直接往窗口上写像素而是通过设备上下文CDC。你可以把CDC理解成一块“画布”不管是屏幕、打印机还是内存位图都被抽象成一块画布图形接口只负责在画布上做图。在对话框里绘图最常用的是CPaintDC。为什么是它而不是CClientDC这里有个Windows消息机制的细节WM_PAINT消息到达后系统会要求你调用BeginPaint获取设备上下文再调用EndPaint释放。CPaintDC的构造函数封装了BeginPaint析构函数封装了EndPaint所以它专门用在OnPaint函数里。如果在这里用CClientDC轻则绘图不更新重则整个窗口响应异常。我在头文件里声明了两个成员变量class CMFCGraphDlg : public CDialogEx { int m_nShapeType; // 0正方形1圆形2三角形 COLORREF m_colorFill; // 当前填充颜色 };构造函数里初始化CMFCGraphDlg::CMFCGraphDlg(CWnd* pParent) : CDialogEx(IDD_MFCGRAPH_DIALOG, pParent) , m_nShapeType(0) , m_colorFill(RGB(255, 0, 0)) { }绘图代码写在OnPaint里。OnPaint是WM_PAINT的消息处理函数窗口第一次显示、被遮挡后重新露出、或者调用Invalidate后Windows都会安排发送WM_PAINTMFC再把它路由到这里。这是MFC图形编程里最核心的“重绘即绘制”思路void CMFCGraphDlg::OnPaint() { CPaintDC dc(this); CRect rcClient; GetClientRect(rcClient); // 先铺一层底色避免残留之前绘制的图形 dc.FillSolidRect(rcClient, RGB(240, 240, 240)); // 留出上方控件区域把绘制区域压缩到客户区中下部 CRect rcDraw(rcClient); rcDraw.top 60; rcDraw.left 30; rcDraw.right - 30; rcDraw.bottom - 20; CBrush brush(m_colorFill); CBrush* pOldBrush dc.SelectObject(brush); switch (m_nShapeType) { case 0: { int nSide rcDraw.Width() rcDraw.Height() ? rcDraw.Width() : rcDraw.Height(); CRect rcSquare(rcDraw.left (rcDraw.Width() - nSide) / 2, rcDraw.top (rcDraw.Height() - nSide) / 2, rcDraw.left (rcDraw.Width() - nSide) / 2 nSide, rcDraw.top (rcDraw.Height() - nSide) / 2 nSide); dc.Rectangle(rcSquare); break; } case 1: { int nRadius min(rcDraw.Width(), rcDraw.Height()) / 2; CPoint ptCenter(rcDraw.CenterPoint()); dc.Ellipse(ptCenter.x - nRadius, ptCenter.y - nRadius, ptCenter.x nRadius, ptCenter.y nRadius); break; } case 2: { CPoint ptCenter(rcDraw.CenterPoint()); int nRadius min(rcDraw.Width(), rcDraw.Height()) / 2; CPoint ptVertices[3]; ptVertices[0] CPoint(ptCenter.x, ptCenter.y - nRadius); ptVertices[1] CPoint(ptCenter.x - nRadius, ptCenter.y nRadius); ptVertices[2] CPoint(ptCenter.x nRadius, ptCenter.y nRadius); dc.Polygon(ptVertices, 3); break; } } dc.SelectObject(pOldBrush); }这段代码里有几个细节我单独讲一下。为什么用CBrush而不用FillSolidRect直接填充因为FillSolidRect只能画矩形画圆和三角形做不到。CBrush是GDI画刷对象代表填充图案把它选入设备上下文后所有封闭图形的填充都自动使用这个画刷。关键是绘完以后要把原来的画刷选回来dc.SelectObject(pOldBrush)否则设备上下文析构时会带着一个已被销毁的GDI对象轻则警告重则引发各种诡异的绘制异常。正方形为什么用Rectangle而不是FillSolidRectRectangle是GDI标准函数会使用当前画笔绘制边框、使用当前画刷填充内部这样画出来的图形有边界线视觉上更清晰。我这里用的是黑色画笔默认值如果想要红色边框还需要创建一个CPen对象并SelectObject进去。关于坐标GetClientRect获取的是对话框客户区坐标原点在客户区左上角X轴向右Y轴向下。所以我在绘制的时候先算出客户区范围再留出头部控件的60像素空间这样图形不会跑到控件底下。等到切换形状时三种图形的中心点都在同一个位置视觉体验比较统一。我这里没有处理窗口尺寸变化时的自适应重绘。如果你希望拖动窗口后图形跟着缩放可以在对话框属性里启用WM_SIZE的消息处理或者在OnPaint里只依赖GetClientRect的结果就不会出现固定坐标的问题。简单说永远不要使用写死的像素坐标就从根上避免了缩放变形。4. 界面交互自绘按钮、列表框与状态文本的联动画图只是第一步真正让这个程序“活起来”的是控件交互。MFC里最常用的交互方式是用户操作控件后控件向对话框发送通知消息对话框的消息映射捕获后调用对应处理函数函数内部修改绘图参数再触发重绘。4.1 自绘按钮BS_OWNERDRAW的完整实现默认按钮在Windows下长得又灰又平想做一个颜色块样式的按钮最简单的方式是使用自绘按钮。原理是给按钮设置BS_OWNERDRAW样式后系统就不再负责绘制按钮外观而是向父窗口发送WM_DRAWITEM消息我们在这个消息的处理函数里自己画。MFC里更优雅的写法是写一个CButton的派生类重写DrawItem虚函数。这样每个按钮对象自带绘制逻辑功能内聚对话框代码不会堆成一团。class CColorButton : public CButton { public: COLORREF m_color; public: CColorButton() : m_color(RGB(255, 0, 0)) {} protected: virtual void DrawItem(LPDRAWITEMSTRUCT lpDrawItemStruct) { CDC dc; dc.Attach(lpDrawItemStruct-hDC); CRect rcItem(lpDrawItemStruct-rcItem); // 底色用颜色值填充 dc.FillSolidRect(rcItem, m_color); // 获取控件是否处于按下状态 UINT uState lpDrawItemStruct-itemState; if (uState ODS_SELECTED) { dc.Draw3dRect(rcItem, RGB(0, 0, 0), GetSysColor(COLOR_BTNHILIGHT)); } else { dc.Draw3dRect(rcItem, GetSysColor(COLOR_BTNHILIGHT), RGB(0, 0, 0)); } // 需要显示文字时再画文本 CString strText; GetWindowText(strText); if (!strText.IsEmpty()) { CRect rcText rcItem; dc.DrawText(strText, rcText, DT_CENTER | DT_VCENTER | DT_SINGLELINE); } dc.Detach(); } };在对话框头文件里声明三个CColorButton成员变量CColorButton m_btnRed; CColorButton m_btnGreen; CColorButton m_btnBlue;在OnInitDialog里关联控件ID同时设置自绘样式BOOL CMFCGraphDlg::OnInitDialog() { CDialogEx::OnInitDialog(); // 关联控件 m_btnRed.SubclassDlgItem(IDC_BTN_COLOR_R, this); m_btnGreen.SubclassDlgItem(IDC_BTN_COLOR_G, this); m_btnBlue.SubclassDlgItem(IDC_BTN_COLOR_B, this); // 设置自绘样式 m_btnRed.ModifyStyle(0, BS_OWNERDRAW); m_btnGreen.ModifyStyle(0, BS_OWNERDRAW); m_btnBlue.ModifyStyle(0, BS_OWNERDRAW); // 给按钮赋颜色 m_btnRed.m_color RGB(255, 0, 0); m_btnGreen.m_color RGB(0, 180, 0); m_btnBlue.m_color RGB(0, 100, 255); return TRUE; }这里有一个特别容易踩的坑BS_OWNERDRAW样式必须在按钮创建之后、显示之前生效。如果你在资源编辑器里没有勾选“Owner Draw”而是像我这样在OnInitDialog里ModifyStyle注意ModifyStyle之后通常需要调用SetWindowPos来触发样式刷新。实测中如果不做这一步按钮可能仍然是系统默认外观或者干脆显示成一个空白方块。所以更稳妥的办法是在资源编辑器里直接勾选Owner Draw属性勾选后对话框加载时样式就带上了连ModifyStyle都可以省掉。DrawItem函数里特别要注意dc.Attach和dc.Detach的配对。Attach把一个现有的HDC包装成MFC的CDC对象Detach则解除绑定避免对象析构时把属于系统的HDC释放掉。我在刚开始写的时候经常漏掉Detach结果向导运行几次后整个程序崩溃后来才明白是GDI句柄被重复释放了。4.2 列表框的消息与数据交换列表框在这里承担两个任务一是初始化时提供可选的图形类型二是用户选择后通知对话框刷新。OnInitDialog里添加选项CListBox* pList (CListBox*)GetDlgItem(IDC_LIST_SHAPE); pList-AddString(_T(正方形)); pList-AddString(_T(圆形)); pList-AddString(_T(三角形)); pList-SetCurSel(0);用户点击列表框项时Windows发送LBN_SELCHANGE通知。注意这个通知名很容易和编辑框的EN_CHANGE混淆LBN开头的是列表框通知EN开头的是编辑框通知。处理函数里获取当前选中项索引更新成员变量后调用Invalidate触发重绘void CMFCGraphDlg::OnLbnSelchangeListShape() { CListBox* pList (CListBox*)GetDlgItem(IDC_LIST_SHAPE); int nSel pList-GetCurSel(); if (nSel ! LB_ERR) { m_nShapeType nSel; UpdateStatusText(); Invalidate(); // 请求重绘 } }这里解释一下Invalidate的作用。Windows的窗口重绘不是立刻发生的Invalidate只是把窗口标记为“无效”然后消息循环里会生成WM_PAINT最终才走到OnPaint。这种机制避免了你快速连续操作时每次都全量重绘性能上更友好。4.3 静态文本的状态反馈静态文本本身不接受交互它的作用是向用户反馈当前状态。每次状态变化时调用SetWindowText更新文本void CMFCGraphDlg::UpdateStatusText() { CString strShape; switch (m_nShapeType) { case 0: strShape _T(正方形); break; case 1: strShape _T(圆形); break; case 2: strShape _T(三角形); break; } CString strColor; if (m_colorFill RGB(255, 0, 0)) strColor _T(红色); else if (m_colorFill RGB(0, 180, 0)) strColor _T(绿色); else strColor _T(蓝色); CString strStatus; strStatus.Format(_T(当前图形%s颜色%s), strShape, strColor); SetDlgItemText(IDC_STATIC_STATUS, strStatus); }三个颜色按钮的点击处理函数逻辑相似拿红色按钮举例void CMFCGraphDlg::OnBnClickedColorR() { m_colorFill RGB(255, 0, 0); UpdateStatusText(); Invalidate(); }5. 实测中最容易翻车的几个地方这个例子写完后我顺手把代码扔到VS2013里编译运行结果顺利通过的不是一次都没有中间实实在在遇到过几个问题。这些坑如果你提前知道能省下不少翻文档的时间。5.1 重绘闪烁与残影最开始跑的时候每次切换图形或颜色窗口中央都会出现明显的闪烁。原因是OnPaint里我先用FillSolidRect铺底色再画图形如果窗口背景也在同时被擦除两股绘制操作叠加就会闪。解决办法有两种。简单方案是在OnEraseBkgnd里直接返回TRUE告诉系统“背景我已经处理了不用你再擦”BOOL CMFCGraphDlg::OnEraseBkgnd(CDC* pDC) { return TRUE; }然后再在OnPaint里把背景填充和图形绘制一起完成。实测这个方案对当前程序已经足够。如果绘制的图形非常复杂就要上双缓冲先创建一个内存DC和兼容位图所有绘制都在内存里完成最后一次性BitBlt到屏幕。双缓冲的思路是把中间过程都发生在不可见的位置用户看到的只有最后一帧完全不闪。5.2 GDI对象资源泄漏MFC的CBrush、CPen这类对象虽然会自动管理底层GDI句柄但如果你反复SelectObject而不恢复原对象或者SelectObject一个局部变量后在函数结束前不恢复句柄表会慢慢被占满。Windows的GDI句柄数量是有限制的一旦耗完整个程序的绘制就会开始乱画甚至崩掉。我的习惯是写成一个模式创建对象 - SelectObject - 绘图 - SelectObject(原对象)。上面OnPaint示例里就是这个套路。此外我排查资源问题时会在调试器的“诊断工具”里观察GDI对象计数每次点击按钮后如果计数不断上涨几乎能确定是SelectObject循环没有恢复导致的。5.3 自绘按钮点击后变成白块这个问题我在做彩色按钮时遇到过几次。原因通常是DrawItem里调用GetWindowText获取按钮文字时如果你在资源编辑器里设置了按钮文字而DrawText又用了这个文字没设置透明背景文字区域就会带着系统默认的按钮背景色把颜色块盖住一部分。解决方法是设置文字背景色透明或者干脆不给自绘按钮设置标题文字。如果你需要文字在DrawItem里加上int nBkMode dc.SetBkMode(TRANSPARENT); dc.DrawText(strText, rcText, DT_CENTER | DT_VCENTER | DT_SINGLELINE); dc.SetBkMode(nBkMode);这样文字就直接浮在颜色块上了。5.4 控件字体显得很“丑”MFC默认的对话框字体是Windows系统字体在老版本系统上中文显示得很细。如果你在意界面观感可以在OnInitDialog里创建一个CFont对象设置成“微软雅黑”或“宋体”然后调用SetFont。但千万注意CFont必须是对话框类的成员变量不能是局部变量。局部变量在函数返回后就析构了字体对象被销毁控件再使用这个字体轻则字体变回默认重则直接访问野句柄。6. 扩展方向从图形展示走向完整小工具这个实例跑通之后你可以顺着几条方向继续把它做成真正能用的工具。第一个方向是增加图形类型。比如加一个直线绘制模式或者加一个可拖拽顶点的多边形编辑功能。MFC里鼠标消息处理是现成的ON_WM_LBUTTONDOWN、ON_WM_MOUSEMOVE、ON_WM_LBUTTONUP配合CClientDC就能实现交互式绘图。第二个方向是保存和加载。Windows的GDI绘图本身不直接把图像保存成文件但你可以把设备上下文指向CImage对象再用CImage的Save方法导出PNG或BMP。这样这个“图形展示窗”就变成了一个小型的截图工具或者图形编辑器雏形。第三个方向是把自绘按钮换成自绘静态图片按钮。很多旧项目里那种带图标的工具栏按钮本质上就是BS_OWNERDRAW配合在DrawItem里贴位图实现的。把CColorButton改成在DrawItem里加载CBitmap逻辑是一样的。我在实际做这个例子的过程中最大的体会不是“MFC有多复杂”而是“MFC把Windows窗口编程最基础的东西都暴露在了明面上”。消息映射、设备上下文、控件自绘这些东西在今天的各类框架里依然存在只是被封装得更深了。你把这段例子跑通将来再遇到什么奇怪的重绘问题、控件样式问题都能顺着这套思路去排查。如果你照着写一遍遇到哪里编译不过或者运行效果不对多半是资源ID没有对应或者消息映射宏漏写了某一行。这两个地方是我自己最容易出错的检查顺序可以放在第一位。本文还有配套的精品资源点击获取
返回列表