ARTICLE DETAIL

资讯详情

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

MFC计算器项目解析:消息映射与自绘控件实战

MFC计算器项目解析:消息映射与自绘控件实战 简介这是一份使用C与MFC编写的Windows计算器程序资源适合正在学习Windows GUI开发、MFC框架或C面向对象编程的开发者。压缩包共44个文件包含.h头文件与.cpp源文件、编译生成的.obj中间文件、可直接运行的Calculator.exe以及.dsw/.dsp/.sln等工程配置文件和.rc/.bmp/.ico界面资源整体约484KB。项目演示了MFC对话框程序从界面设计、按钮消息映射到计算逻辑的完整流程既有编译好的程序用于功能测试也让读者能对照源码逐行理解窗口类、消息循环和控件交互的实现方式。当前已有276人学习下载。对入门者可借此熟悉Visual Studio下MFC工程的构建、资源编辑器和调试技巧有基础后还能进一步扩展科学计算、键盘输入、历史记录或自定义皮肤是一个适合二次开发的实用范例。1. MFC 计算器不是玩具是理解 Windows 消息机制的捷径拿到这个计算器压缩包时我有点意外CalculatorDlg、CNUM、COP、CFUNC、Calculation被拆成了五个编译单元数字、操作符、函数和调度引擎各管各的没把所有逻辑都塞进按钮消息里。网上能搜到的 MFC 计算器大半是把运算直接写在OnBnClickedButtonAdd这类函数里而这个项目把计算层和界面层拆开了。这个特点对两类人价值最大一是要做 MFC 课程设计、需要展示代码组织能力的学生二是想搞明白 Win32 消息循环与 MFC 封装关系的 C 开发者。整个工程不大但资源脚本、消息映射表、DDX 数据交换、可独立测试的计算内核都能在一屏之内看完。2. 对话框框架与控件资源布局从 .rc 和 Resource.h 拆起2.1 Resource.h 里的 ID 是消息路由的起点MFC 对话框里每个按钮本质上是一个子窗口父窗口靠数字 ID 识别它们。打开Resource.h会看到一批宏定义#define IDD_CALCULATOR_DIALOG 102 #define IDC_BUTTON_0 1001 #define IDC_BUTTON_1 1002 #define IDC_BUTTON_9 1010 #define IDC_BUTTON_ADD 1011 #define IDC_BUTTON_SUB 1012 #define IDC_BUTTON_MUL 1013 #define IDC_BUTTON_DIV 1014 #define IDC_EDIT_DISPLAY 1020这些#define本身没有逻辑但它们是整套消息机制的锚点。用户在按钮上按下鼠标Windows 向父窗口投递WM_COMMANDMFC 在BEGIN_MESSAGE_MAP对照表里根据LOWORD(wParam)找到对应的处理函数。这个链条可以在CalculatorDlg.cpp里完整看到。如果某个按钮点了没反应排查顺序应当是资源里按钮的 ID 数值、处理函数签名、消息映射表条目这三处一致消息才会被接住。2.2 用文本方式改 Calculator.rc 调整布局不一定非要用可视化资源编辑器拖控件.rc本质就是文本。计算器里每个按钮对应一行CONTROL定义CONTROL 7, IDC_BUTTON_7, Button, BS_PUSHBUTTON | WS_TABSTOP, 14, 92, 40, 28四个数字依次是 x、y、宽度、高度单位是对话框单位 DLU不是像素。当前这个四行四列的经典布局行距约 36 DLU、列距 46 DLU 排起来是够用的。想加一行按键复制一行CONTROL换掉 ID 和坐标即可编译后资源编辑器里就会看到新控件。改坐标时注意对话框若设置了DS_SETFONT控件会随字体缩放字体不一致会导致布局在不同机器上错位这也是经常被忽略的 MFC 控件自适应屏幕分辨率问题。提示不要在纯文本编辑器里手写.rc的中文按钮文字字符集与工程设置不一致时编译后按钮会显示乱码稳妥做法是在资源编辑器里改文本让 IDE 处理编码。2.3 从 CWinApp 到 CDialog 的启动顺序Calculator.cpp里的CCalculatorApp继承CWinApp是程序的 Application 对象。InitInstance里的代码非常简洁BOOL CCalculatorApp::InitInstance() { CCalculatorDlg dlg; // 栈上构造对话框对象 m_pMainWnd dlg; // 保存主窗口指针 dlg.DoModal(); // 进入模态对话框消息循环 return FALSE; // 对话框退出后结束应用 }DoModal()内部运行一个嵌套的消息循环这是一个事件驱动循环而不是线性执行。程序启动后并不是从InitInstance一路走到退出而是停在DoModal()里反复取消息、分发消息直到对话框被关闭。压缩包里出现vc60.pdb说明工程大概率走的是 Visual C 6.0 编译链目标机器上需要对应版本的 MFC 运行库支持否则双击Calculator.exe会直接报缺失 DLL。运行库这一层先确认好再谈界面和算法。3. 计算核心分层CNUM、COP、CFUNC 与表达式处理3.1 为什么把计算拆成三个类CNUM负责数字输入状态和拼数规则COP负责操作符的识别、覆盖与优先级CFUNC负责sqrt、log、sin这类一元数学函数Calculation作为调度者维护累加值。这个拆分的直接收益是核心算法不依赖具体控件可以脱离界面写单元测试。课程设计里常见的反面写法是把数字拼接、操作符判断全部写进按钮事件逻辑分散在十几个函数里改一个规则要动多处代码。Calculation.h的典型声明如下class CCalculation { public: CCalculation(); void Clear(); void InputDigit(int nDigit); // 接收单个数字并拼数 void InputValue(double dVal); // 直接输入操作数 int SetOperator(int nOp); // - * / int ApplyFunction(int nFunc); // sqrt / log / sin double GetResult() const; private: double m_dAccum; // 累计结果 double m_dOperand; // 当前输入的操作数 int m_nPendingOp; // 未决操作符 bool m_bPending; // 是否存在未决操作符 };注意所有变更操作都返回状态码而不是void调用层据此决定要不要弹错误提示引擎本身也能独立返回失败原因。接口签名是设计的一部分算是一个容易被忽略的细节。3.2 操作符覆盖与连续计算语义边输入边算的计算器有一个高频错误点用户输入第一个操作数后按了还没按数字又按了-错误的实现会去解析1 (-)导致状态混乱。正确的做法是覆盖未决操作符int CCalculation::SetOperator(int nOp) { if (!m_bPending) { m_nPendingOp nOp; m_bPending true; return 0; // 首次记录操作符 } m_nPendingOp nOp; // 替换上一次操作符 return 1; // 返回已覆盖标记 }1 -想要表达负数应当由独立的取负按键处理而不是把减号叠加到加号上。按等号时的核心计算函数同样有讲究除零判断不能写成b 0.0double CCalculation::Apply(int nOp, double a, double b, int nErr) { switch (nOp) { case OP_ADD: return a b; case OP_SUB: return a - b; case OP_MUL: return a * b; case OP_DIV: if (fabs(b) 1e-12) { // 浮点比较必须用阈值 nErr ERR_DIV_ZERO; return 0.0; } return a / b; } return 0.0; }fabs(b) 1e-12而不是b 0.0是因为 IEEE 754 浮点数中一个足够小的非零数做除数时会溢出成inf。阈值取多少看场景计算器场景下1e-12足够我一般会把它定义成常量方便统一修改。3.3 完整表达式求值的双栈法如果想把计算器升级成支持1 2 * 3这种整体表达式仅靠状态机不够需要真正的表达式求值。我一般用两个栈一个存操作数一个存操作符遍历时按优先级压缩double Evaluate(const std::vectorToken tokens) { std::stackdouble nums; std::stackint ops; for (const Token tk : tokens) { if (tk.IsNumber()) { nums.push(tk.value); continue; } // 当前操作符优先级不高于栈顶时先计算栈顶 while (!ops.empty() GetPriority(tk.op) GetPriority(ops.top())) { double b nums.top(); nums.pop(); double a nums.top(); nums.pop(); int err 0; nums.push(Apply(ops.top(), a, b, err)); ops.pop(); } ops.push(tk.op); } while (!ops.empty()) { double b nums.top(); nums.pop(); double a nums.top(); nums.pop(); int err 0; nums.push(Apply(ops.top(), a, b, err)); ops.pop(); } return nums.top(); }配套的优先级表决定弹栈时机操作符优先级 -1* /2sinsqrt等一元函数3括号只控制压栈弹栈不参与运算CFUNC在这个位置介入读到一个一元函数要立刻消费后面括号里的数字并压入操作数栈。一元函数优先级高于乘除否则2 * sqrt(4)没问题但sin(30) * 2这种复合表达式会以错误顺序弹栈结果与手算不一致。4. 界面响应与状态机CalculatorDlg 的按钮控制逻辑4.1 用 ON_COMMAND_RANGE 合并数字按钮0 到 9 十个按钮如果各写一个OnBnClickedButtonX代码会长得很难看。MFC 提供范围映射宏可以把连续 ID 的控件合并到一个处理函数BEGIN_MESSAGE_MAP(CCalculatorDlg, CDialogEx) ON_COMMAND_RANGE(IDC_BUTTON_0, IDC_BUTTON_9, CCalculatorDlg::OnNumButton) ON_COMMAND_RANGE(IDC_BUTTON_ADD, IDC_BUTTON_DIV, CCalculatorDlg::OnOpButton) ON_BN_CLICKED(IDC_BUTTON_EQUAL, CCalculatorDlg::OnEqual) END_MESSAGE_MAP()ON_COMMAND_RANGE的两个 ID 必须在数值上连续处理函数签名固定为void Handler(UINT nID)。数字按下时用nID - IDC_BUTTON_0拿到的就是 0 到 9 的真实数字void CCalculatorDlg::OnNumButton(UINT nID) { int nDigit nID - IDC_BUTTON_0; // 由控件 ID 推导数字值 m_calculation.InputDigit(nDigit); // 交给计算引擎 UpdateDisplay(); // 刷新显示编辑框 }按钮只负责把数字递给引擎不关心引擎怎么拼数、是否超精度控件层变薄之后出问题更容易定位。4.2 四态状态机与按钮行为映射计算器逻辑可以抽象成四个状态输入第一个操作数、等待操作符、输入第二个操作数、显示结果。状态转移规则一张表说清当前状态收到数字收到操作符收到等号输入操作数1拼接数字记录操作符转到等待操作符不响应等待操作符清屏开始新数字替换当前操作符不响应或重复上次运算输入操作数2拼接数字先结算当前表达式再记录新操作符执行计算转到显示结果显示结果清屏开始新数字用结果作操作数1转入等待操作符不响应用 C 实现时状态通常是一个枚举成员变量每次按键都先查当前状态再决定是清屏、拼接还是触发运算。关键点是所有状态迁移必须收敛不能出现按完等号后残留旧表达式的情况。工程里m_bNewInput这类标志位的职责就是标记下一次输入要清屏防止5 5按完等号再按3时拼出15 3这种错误显示。4.3 显示刷新用 SetWindowText 而非 UpdateDataMFC 对话框里最常见的显示刷新写法是m_strDisplay strText; UpdateData(FALSE);UpdateData(FALSE)会遍历对话框所有 DDX 绑定的控件变量逐个调用SetWindowText、SetCheck。这个计算器控件少感觉不出差异但在控件多的对话框里这一句会拖慢响应。更精准的做法是只刷新目标控件GetDlgItem(IDC_EDIT_DISPLAY)-SetWindowText(strText);这条语句只触发一次WM_SETTEXT不经过 DDX 遍历。显示编辑框还有一个必要设置要设为只读防止用户从键盘输入字母同时把输入法禁掉避免弹出中文输入法。只读样式可以写在资源里也可以运行时下发GetDlgItem(IDC_EDIT_DISPLAY)- SendMessage(EM_SETREADONLY, TRUE, 0);EM_SETREADONLY是编辑框专用消息与按钮的BM_SETSTYLE一样属于控件自带系统消息不经过消息映射表这条链路值得单独记一下。5. 界面美化与自绘按钮替换 MFC 默认控件的绘制路径5.1 用 BS_OWNERDRAW 接管按钮绘制这个工程资源里带了bitmap_c.bmp但默认按钮控件不会自动贴图。想让计算器界面真正改观我一般用自绘按钮从CButton派生一个CSkinButton重写DrawItem再把按钮属性设为BS_OWNERDRAW在OnInitDialog里用SubclassDlgItem接管既有控件void CSkinButton::DrawItem(LPDRAWITEMSTRUCT lpDIS) { CDC dc; dc.Attach(lpDIS-hDC); CRect rc(lpDIS-rcItem); dc.FillSolidRect(rc, (lpDIS-itemState ODS_SELECTED) ? RGB(180, 205, 240) // 按下变深 : RGB(230, 240, 255)); // 默认浅蓝底 CString str; GetWindowText(str); dc.DrawText(str, rc, DT_CENTER | DT_VCENTER | DT_SINGLELINE); dc.Detach(); }lpDIS-hDC是系统传入的绘制句柄Attach之后必须用Detach归还不能DeleteDC否则句柄被提前释放。按下状态用ODS_SELECTED判断这是DRAWITEMSTRUCT里最基本的位。换成位图外观时把FillSolidRect替换成TransparentBlt贴bitmap_c.bmp再叠加文本即可。5.2 双缓冲消除刷新闪烁自绘控件多了之后窗口重绘会频繁擦背景快速连续点击时容易出现闪烁。双缓冲是标准解法先在内存兼容 DC 上画完整帧再一次BitBlt写回屏幕CPaintDC dc(this); CRect rc; GetClientRect(rc); CDC memDC; memDC.CreateCompatibleDC(dc); CBitmap bmp; bmp.CreateCompatibleBitmap(dc, rc.Width(), rc.Height()); CBitmap* pOld (CBitmap*)memDC.SelectObject(bmp); // 背景、按钮、文字全部画到内存 DC dc.BitBlt(0, 0, rc.Width(), rc.Height(), memDC, 0, 0, SRCCOPY); memDC.SelectObject(pOld); bmp.DeleteObject(); memDC.DeleteDC();CreateCompatibleDC创建的内存 DC 默认只有 1x1 像素位图必须用CreateCompatibleBitmap生成与窗口等大的位图选入否则绘制内容超出有效区域。如果双缓冲后仍然闪检查OnEraseBkgnd是否返回TRUEMFC 默认会在擦背景时把屏幕刷一遍禁掉这次擦除屏幕更新才真正只剩一次 BitBlt。调试时在这段代码和Calculation::Apply里各放一个断点从按下按键到结果上屏的全链路就都能单步走完。本文还有配套的精品资源点击获取
返回列表