news 2026/11/4 18:10:33

VC++6.0 MFC入门:消息机制、消息映射与GDI绘图避坑

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
VC++6.0 MFC入门:消息机制、消息映射与GDI绘图避坑

简介:《VC++6.0 MFC入门学习》面向具备一定C/C++基础、准备进入Windows桌面开发的初学者,重点解决从语言基础到MFC框架、消息驱动编程之间的入门衔接问题。压缩包仅含1个doc文档,约521KB,篇幅集中,便于按主题连续阅读和检索。内容围绕C/C++基础巩固、Windows消息机制、窗口句柄与GUI句柄、MFC消息映射展开,并给出使用Help Online、记忆常用消息及参数、阅读他人代码和选择参考书的学习建议。文档还通过消息组成、窗口过程、消息队列与消息循环等知识点,帮助读者理解MFC封装背后的运行逻辑。已有731人学习,适合作为VC++6.0与MFC入门阶段的导读材料,也可供查漏补缺时快速回顾核心概念。

1. 从一份 VC++6.0 的老讲义说起:MFC 入门到底该先学什么

翻这份 MFC 入门讲义会发现一个反直觉的排序:前三节没有一行绘图代码,先讲 C/C++ 基础,再讲 Windows 消息机制,第三节才轮到 AppWizard 和 ClassWizard。很多人装完 VC++6.0 第一件事就是拖控件、点编译,结果第一个窗口还没跑起来就被一堆LNK2001和断言框劝退。这份材料的价值不在代码量,它把「MFC 到底在封装什么」拆成了可执行的顺序:字符界面练语法和对象模型,消息三元组练心智模型,消息映射练框架对接,最后才轮到 GDI 绘图和常用类。它适合手上只有 VC++6.0 这套老工具链、又不想停留在拖控件层面的人。下面按它的脉络走一遍,把我自己踩过的坑补进去。

2. 从字符界面到消息驱动:MFC 入门前的两门必修课

2.1 先用控制台把 C++ 的地基砸实

MFC 的类层次是从CObject一路派生下来的,CView、CFrameWnd、CDocument全靠虚函数挂钩子,OnDraw本身就是一个虚函数。这些机制在字符界面程序里练成本最低——控制台没有消息循环、没有句柄、没有 GDI 资源泄漏,写错了最多打印一行错误或直接崩在main里,排查半径几乎为零。反过来,如果在 MFC 工程里第一次接触虚函数和多态,一个没加virtual的析构函数就能让你在调试器里耗掉半天。

我一般会让新手先写这么一段代码,跑通再改几个地方:

// VC++6.0:新建 Win32 Console Application,选 An empty project,再手工添加 .cpp #include <iostream.h> // VC++6.0 的旧式头文件,不带 std 命名空间 class CShape // 扮演 MFC 里 CObject 的角色 { public: virtual void Draw() const { cout << "CShape::Draw" << endl; } virtual ~CShape() {} // 基类析构必须为虚,否则派生类资源不释放 }; class CCircle : public CShape { public: virtual void Draw() const { cout << "CCircle::Draw" << endl; } }; void PaintAll(CShape* pShape) // 参数用基类指针,模拟 CDC* 传进 OnDraw { pShape->Draw(); // 由虚函数表决定实际调用哪个版本 } void main() { CCircle circle; PaintAll(&circle); // 输出 CCircle::Draw }

逻辑说明:PaintAll接收基类指针,这一点和OnDraw(CDC* pDC)收到一个CDC指针是同构的——你永远面对的是基类接口,实际对象由框架在运行时构造。参数说明:这里的「参数」其实是编译期行为,virtual决定有没有虚表,const决定成员函数能不能被常量对象调用,~CShape()前的virtual决定派生类析构是否被调用。可以在 VC++6.0 里把virtual ~CShape()的virtual删掉再跑一遍,观察CCircle的析构有没有执行,这个实验比看十页书都直接。

再往下练的东西依次是:指针与数组边界、const正确性、拷贝构造与赋值运算符、简单的异常抛出捕获。VC++6.0 对标准的支持停得很早,for循环里声明的变量作用域会泄漏到循环外,部分模板偏特化不支持,异常规范基本是摆设。知道这些边界,后面读 MFC 源码时才不会被各种宏和_AFX前缀吓住。

2.2 消息是三元组:UINT + WPARAM + LPARAM

Windows 是消息驱动的系统,这句话被说烂了,但落到代码上就一件事:每一次用户输入、每一次窗口状态变化,系统都会往某个窗口投递一条消息,消息由三部分组成——消息名称(UINT)、WPARAM、LPARAM。前一个是「发生了什么」,后两个是「发生在谁身上、带什么数据」。WPARAM在 32 位下是 32 位无符号整数,LPARAM是有符号 32 位,两者装不下的数据就传指针。

下面这几个是入门阶段必须形成条件反射的:

消息名wParam 含义lParam 含义典型触发
WM_PAINT未使用未使用窗口需要重绘
WM_LBUTTONDOWNMK_CONTROL/MK_SHIFT等按键状态LOWORD为 x,HIWORD为 y(客户区坐标)鼠标左键按下
WM_COMMANDHIWORD为通知码,LOWORD为控件或菜单 ID控件句柄(菜单消息时为 0)点菜单、点按钮
WM_CREATE未使用指向CREATESTRUCT的指针窗口创建过程中
WM_SIZESIZE_RESTORED/SIZE_MINIMIZED等LOWORD为宽,HIWORD为高窗口尺寸变化
WM_TIMER定时器 ID回调函数指针SetTimer到期

菜单消息是最容易被忽略的一个细节:点菜单产生WM_COMMAND,命令 ID 不在lParam里,而在wParam的低字,HIWORD(wParam)才是通知码。原文里写的HIWORD(wParam)是命令 ID,这个说法在菜单场景下要打个折扣——准确的写法是LOWORD(wParam)取 ID、HIWORD(wParam)取通知码。用ON_COMMAND(ID_XXX, OnXxx)宏时框架已经替你拆好了,但一旦手写WM_COMMAND的switch,这个拆字的顺序错了就会死活进不去分支。

WM_LBUTTONDOWN的坐标也是同理,很多人直接拿lParam当坐标用,在小坐标下看不出问题,超过 65535 就翻车,因为lParam是高低字拼的,必须LOWORD/HIWORD拆。这种拆字写法在 32 位 MFC 里到处可见,GET_X_LPARAM这类宏在 VC++6.0 的windowsx.h里也有,但老工程常常不包含,手写更稳。

2.3 窗口句柄与消息队列:GetMessage 为什么不烧 CPU

消息必须由一个窗口接收,系统靠窗口句柄HWND在整个系统范围内唯一标识一个窗口。两个窗口共用同一份窗口过程代码是完全合法的,消息靠句柄分发,所以你在一号窗口按鼠标,消息不会跑到二号窗口去。这就是句柄存在的意义:窗口过程是「怎么处理」,句柄是「处理谁」。

消息从产生到被处理要走队列。32 位系统下每个 GUI 线程有自己的消息队列,系统在多个队列之间轮转,不等待当前程序处理完就可以把控制权给别人,这叫抢先式多任务。16 位系统只有一个全局队列,一个程序死循环就能拖死整台机器。理解这个差别,才能明白为什么「在消息处理函数里做耗时操作会卡界面」——耗的是你自己线程的队列,界面线程不回去取消息,窗口就不刷新。

消息循环的骨架长这样:

// 手写 Win32 窗口的消息循环,VC++6.0 的 Win32 Application 可直接用 MSG msg; while (GetMessage(&msg, NULL, 0, 0)) // 队列为空时挂起,不占 CPU { if (msg.message == WM_QUIT) // 防御性判断,虽然返回 0 时已快退出 break; TranslateMessage(&msg); // 把 WM_KEYDOWN 翻译成 WM_CHAR DispatchMessage(&msg); // 按 msg.hwnd 投递到对应窗口过程 } return msg.wParam;

参数说明:GetMessage的第二个参数hWnd传NULL表示取本线程所有窗口的消息,传具体句柄就只取那个窗口的;第三、第四个参数是消息 ID 的过滤区间,0, 0表示不过滤。返回值有三个状态:-1表示出错、0表示收到WM_QUIT、其他非零表示取到了一条消息。所以千万别写成while (GetMessage(...) == TRUE),-1在布尔判断里是真,会进死循环,这是个经典翻车点。TranslateMessage不能省,省了之后WM_KEYDOWN有、WM_CHAR没有,输入框打不出字。DispatchMessage内部就是按句柄找到窗口过程并调用它,MFC 的CWinThread::Run和PumpMessage干的是同一件事,只是外面包了一层。

3. 消息映射解剖:从 BEGIN_MESSAGE_MAP 到 switch-case

3.1 手写窗口过程与消息映射的等价关系

先看没有 MFC 的世界。每个窗口注册时都要提供一个窗口过程,签名固定,内部一个大switch:

LRESULT CALLBACK MyWndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam) { switch (msg) { case WM_PAINT: DoPaint(hWnd); return 0; // 已处理,返回 0 case WM_LBUTTONDOWN: DoLeftDown(hWnd, LOWORD(lParam), HIWORD(lParam)); return 0; case WM_DESTROY: PostQuitMessage(0); return 0; default: break; } return DefWindowProc(hWnd, msg, wParam, lParam); // 不处理的交回系统 }

问题很明显:一个窗口几十条消息全塞在一个函数里,加一个消息要改一次switch,多个窗口共用逻辑只能靠函数指针表或者全局变量,编译期没有任何检查,写错一个case值编译器不会提醒。

MFC 的解法是把「消息」和「处理函数」的对应关系从运行期的switch挪到编译期的宏表里。写的时候是这样:

// MainFrm.h class CMainFrame : public CFrameWnd { DECLARE_MESSAGE_MAP() protected: afx_msg int OnCreate(LPCREATESTRUCT lpCreateStruct); // afx_msg 只是标记,无实际语义 afx_msg void OnPaint(); }; // MainFrm.cpp BEGIN_MESSAGE_MAP(CMainFrame, CFrameWnd) ON_WM_CREATE() // 注意结尾没有分号,这是宏不是语句 ON_WM_PAINT() END_MESSAGE_MAP() int CMainFrame::OnCreate(LPCREATESTRUCT lpCreateStruct) { if (CFrameWnd::OnCreate(lpCreateStruct) == -1) return -1; return 0; } void CMainFrame::OnPaint() { CPaintDC dc(this); // CPaintDC 的构造和析构负责 BeginPaint/EndPaint }

展开之后,编译器看到的东西接近这样(实际实现更绕,这里只保留语义):

// 宏展开后的等价形态,用于理解,不要照抄 LRESULT CMainFrame::WindowProc(UINT msg, WPARAM wParam, LPARAM lParam) { switch (msg) { case WM_CREATE: return OnCreate((LPCREATESTRUCT)lParam); case WM_PAINT: OnPaint(); return 0; default: return CFrameWnd::WindowProc(msg, wParam, lParam); // 沿基类链往上找 } }

逻辑说明:BEGIN_MESSAGE_MAP展开出的是一个静态数组,数组元素记录「消息 ID + 处理函数指针 + 参数签名」,WindowProc拿着消息 ID 在这个数组里查表,查到就调函数,查不到就顺着pBaseClass指针去基类的表里继续找。这条基类链就是为什么你在CView派生类里不处理WM_SIZE也没事——CWnd那层有一份默认实现兜着。参数说明:ON_WM_CREATE()括号里空着,因为它约定的处理函数签名是固定的int OnCreate(LPCREATESTRUCT),宏不需要额外参数就能推出函数名和类型;而ON_COMMAND(ID_XXX, OnXxx)就必须带两个参数,因为 ID 和处理函数名都无法从消息名推出。这种「宏的参数个数决定了它能生成什么」的设计,在 MFC 里到处都是。

3.2 加一个消息处理的三个落点

用 ClassWizard 加最省事:Ctrl+W打开,切到 Message Maps 页,Class name 选目标类,Messages 列表里找到消息,双击或点 Add Function,然后 Edit Code 跳到函数体。但 ClassWizard 在 VC++6.0 里有个老毛病——它只认自己写过的映射条目,如果你手工加过ON_MESSAGE或者改过类名,它可能拒绝工作或者把映射表写乱。所以我习惯手工加,加完再让 ClassWizard 校验一遍。

手工加要保证三个落点齐全,少一个都白干:

// 落点一:头文件里的声明,必须带 afx_msg,虽然它展开后是空的 protected: afx_msg void OnLButtonDown(UINT nFlags, CPoint point); DECLARE_MESSAGE_MAP() // 落点二:cpp 里的映射表,夹在 BEGIN/END 之间 BEGIN_MESSAGE_MAP(CMyView, CView) ON_WM_LBUTTONDOWN() END_MESSAGE_MAP() // 落点三:函数实现,签名必须和宏约定的一模一样 void CMyView::OnLButtonDown(UINT nFlags, CPoint point) { CString str; str.Format("x=%d, y=%d, flags=0x%08X", point.x, point.y, nFlags); AfxMessageBox(str); CView::OnLButtonDown(nFlags, point); // 不关心也调基类,方便以后扩展 }

参数说明:nFlags是修饰键状态,MK_LBUTTON、MK_CONTROL、MK_SHIFT按位组合,nFlags & MK_CONTROL判断有没有按 Ctrl;point已经是拆好字的客户区坐标,不用再LOWORD。逻辑说明:ON_WM_LBUTTONDOWN()这个宏内部会把消息 ID 映射到WM_LBUTTONDOWN,并生成一个调用OnLButtonDown(nFlags, CPoint(lParam))的跳板函数,所以函数签名差一个const或者参数类型写成CPoint&都会编译不过——这是好事,编译期就把错误拦下来了。

失败时看什么:函数加了但断点进不去,先看映射表那行是不是被写在了BEGIN_MESSAGE_MAP外面;再看宏后面有没有手贱加分号,ON_WM_LBUTTONDOWN();在某些上下文里会展开成空语句,编译过但映射表里少一项;最后看视图有没有真正获得焦点,CView收不到鼠标消息很多时候是子控件盖住了或者CFrameWnd先截了。

3.3 自定义消息:WM_USER 之上是自己的地盘

WM_USER以下的编号归系统,WM_USER及以上归应用自己用,这是约定俗成的分界线。自定义消息适合做跨线程通知或者模块解耦,比如后台线程算完了通知界面刷新。定义和处理:

// 定义:放在公共头文件里,值从 WM_USER+100 起,避开其他库已占用的段 #define WM_MY_TASK_DONE (WM_USER + 100) // 头文件声明:返回 LRESULT,参数固定 WPARAM / LPARAM afx_msg LRESULT OnMyTaskDone(WPARAM wParam, LPARAM lParam); // 映射表:用 ON_MESSAGE 而不是 ON_COMMAND BEGIN_MESSAGE_MAP(CMyView, CView) ON_MESSAGE(WM_MY_TASK_DONE, OnMyTaskDone) END_MESSAGE_MAP() // 实现:返回值语义自己定,0 表示成功是常见约定 LRESULT CMyView::OnMyTaskDone(WPARAM wParam, LPARAM lParam) { int nPercent = (int)wParam; CString* pResult = (CString*)lParam; // 指针跨线程传递要保证生命周期 if (pResult) { /* 使用完记得由接收方 delete */ } return 0; }

关键差异在SendMessage和PostMessage:前者同步,调用线程阻塞到目标窗口处理完才返回,跨进程时还可能被对方卡住;后者只是把消息塞进目标线程队列就返回,不等结果。后台线程往界面线程发消息,能用PostMessage就别用SendMessage,否则界面一卡,后台线程跟着一起卡,反过来又加剧卡顿,形成死锁。参数wParam/lParam只能装整数或指针,传结构体就要new一块内存把指针递过去,接收方负责释放——这一条如果忘了,就是稳定的内存泄漏。

4. MFC 常用类与 GDI 对象:CRect、CString 到 CPen、CBrush

4.1 CRect、CPoint、CString:三个天天用、三个天天错

CRect四个成员left、top、right、bottom,注意right和bottom是开区间,Width()返回right - left而不是right - left + 1。这个半开区间约定贯穿整个 Win32 API,鼠标命中判断、区域裁剪都按它来,混用闭区间会导致边界上差一个像素,界面上看不出来,做打印排版时就是错位。

CRect rc(10, 10, 110, 60); // left, top, right, bottom int w = rc.Width(); // 100,不是 101 int h = rc.Height(); // 50 CPoint ptCenter = rc.CenterPoint(); // (60, 35) CRect rcOther(0, 0, 50, 100); CRect rcUnion; rcUnion.UnionRect(rc, rcOther); // 求并集 rc.OffsetRect(5, 5); // 整体平移 BOOL bHit = rc.PtInRect(CPoint(20, 20));// 命中判断,边界按半开区间算

CPoint只有x、y两个成员,但它重载了+、-、+=,可以和CSize互转,坐标运算直接写point += CSize(10, 10)就行。CString是可变长字符串,内部自己做引用计数和写时复制,所以按值传参的代价比想象中小,但跨线程共享同一个CString对象仍然不安全,引用计数不是原子的。

成员函数作用等价 C 函数
GetLength()取字符数,不含结尾 0strlen
GetAt(i)/SetAt(i, ch)按下标读写单个字符str[i]
Format(fmt, ...)格式化写入sprintf
Find/ReverseFind正向/反向查找strchr/strrchr
Compare/CompareNoCase区分/不区分大小写比较strcmp/stricmp
MakeUpper/MakeLower原地转大小写strupr/strlwr
Left/Mid/Right取子串无直接对应

CStringArray是可变的CString数组,Add、RemoveAt、InsertAt、GetAt、SetAt这几个够用,但要注意RemoveAt之后索引会整体前移,循环里删除元素必须倒着遍历,这个坑在没有迭代器的 MFC 容器里格外致命。

4.2 GDI 对象的四步法:创建、选入、恢复、销毁

GDI 对象(CPen、CBrush、CFont、CBitmap)和 C++ 对象是两套东西:C++ 对象是你在栈上或堆上的那个壳,GDI 对象是内核里的句柄资源。一个CPen对象可以先后创建、销毁、再创建多次,对应多个 GDI 句柄。规则是:创建合法的对象、选入 DC 并保存旧对象、用完恢复旧对象、对象析构前必须已经从 DC 里出来。

void CMyView::OnDraw(CDC* pDC) { CPen pen; CBrush brush; pen.CreatePen(PS_SOLID, 2, RGB(200, 60, 60)); brush.CreateSolidBrush(RGB(240, 240, 200)); CPen* pOldPen = pDC->SelectObject(&pen); // 保存 DC 原来的笔 CBrush* pOldBrush = pDC->SelectObject(&brush); // 保存 DC 原来的刷子 pDC->Rectangle(20, 20, 220, 140); pDC->SelectObject(pOldPen); // 先恢复,再让对象析构 pDC->SelectObject(pOldBrush); } // pen / brush 在这里析构,此时已不在 DC 中,安全

逻辑说明:SelectObject返回的是被替换下来的旧对象指针,这个返回值必须存住,否则恢复不了。参数说明:CreatePen三个参数是风格、宽度(逻辑单位)、颜色,颜色用RGB(r, g, b)宏拼成COLORREF;CreateSolidBrush只有一个颜色参数。如果同一个对象要换属性,必须先DeleteObject()再CreateXxx(),不能直接对已创建的CPen再调一次CreatePen,那样第一次创建的句柄就漏了:

pen.DeleteObject(); // 重新创建前先销毁 pen.CreatePen(PS_SOLID, 2, RGB(0, 0, 0)); // 再创建 pDC->SelectObject(&pen);

还有一个隐形雷:如果SelectObject选进去的是一个临时对象,比如pDC->SelectObject(&CPen(PS_SOLID, 1, RGB(0,0,0))),这个临时对象在语句结束就析构了,DC 里留下一个悬空指针,下一次绘图就是未定义行为,运气好是断言,运气差是随机崩溃。VC++6.0 的调试版会在CGdiObject::~CGdiObject里断言「对象仍被选入 DC」,这个断言框很多人见过但不知道什么意思,原因就在这里。

4.3 画笔风格与库存对象:宽度 1 是道硬门槛

CreatePen的第一个参数是风格,其中虚线、点线、点划线这几种有硬性限制:

风格效果宽度约束
PS_SOLID实线任意宽度
PS_DASH虚线必须 ≤ 1
PS_DOT点线必须 ≤ 1
PS_DASHDOT点划线必须 ≤ 1
PS_DASHDOTDOT双点划线必须 ≤ 1
PS_NULL不画线无意义
PS_ENDCAP_ROUND线端圆头与风格按位或

宽度写成 2 配PS_DASH,Windows 会静默降级成实线,不报错、不提示,你在代码里怎么看都对,画出来就是实线。这是典型的「玄学」问题,血泪经验是:只要发现虚线画不出来,先回去看宽度参数。

库存对象是系统预置的一批 GDI 对象,不用创建也不用销毁,用CDC::SelectStockObject直接选入:

pDC->SelectStockObject(BLACK_BRUSH); // 黑色实心刷 pDC->SelectStockObject(NULL_BRUSH); // 空心刷,画矩形只有边框 pDC->SelectStockObject(WHITE_PEN); // 白色画笔 pDC->SelectStockObject(SYSTEM_FONT); // 系统默认字体

NULL_BRUSH和NULL_PEN是画空心图形的关键,很多人画矩形只想要边框,结果内部被默认白色刷子填满,把底下的内容盖住了,原因就是没把刷子换成NULL_BRUSH。库存对象常驻系统,选入之后即使不恢复也不会造成泄漏,所以它们可以当「后悔药」用——实在记不清原来选的是什么,SelectStockObject一个回去也不会出大问题。但正规写法还是保存旧对象再恢复,尤其在打印和预览时会切换 DC,恢复不干净会导致后续绘制全部用错对象。

5. 避坑与排查:五个能把人卡一整天的坑

5.1 坑一:OnDraw 里创建的 GDI 对象没恢复,调试版弹断言

现象:程序在 Debug 版下跑着跑着弹出「对象仍被选入 DC」的断言框,点重试进到CGdiObject的析构函数;Release 版不弹框,但画一会儿界面开始发花,任务管理器里 GDI 对象数一路涨。

原因:SelectObject选入的对象没有在析构前恢复。DC 里保存的是指向 C++ 对象的指针,对象在栈上出作用域被销毁后,DC 还指着那块已经回收的内存。

解决:所有SelectObject的返回值都存成pOldXxx,函数结束前一一SelectObject(pOldXxx)恢复。如果对象是在堆上new的,delete之前必须先确认它已经从 DC 里出来。养成习惯:写SelectObject的下一行就把恢复语句补上,中间再填绘图代码。

5.2 坑二:Debug 正常,Release 一跑就崩

现象:同一份代码,Debug 版点按钮弹出消息框,Release 版点下去毫无反应,甚至直接退出。

原因:把有副作用的函数调用写进了ASSERT或VERIFY。ASSERT在 Release 下整个被预处理器剔除,里面的表达式根本不求值;VERIFY稍好一点,Release 下会保留表达式求值但丢弃结果,但如果表达式本身是函数调用,行为就和 Debug 不一致了。

解决:凡是「既要执行又要检查」的调用,单独写成一行语句,再用ASSERT检查它的返回值。比如ASSERT(pWnd->Create(...))要改成先BOOL bOk = pWnd->Create(...);再ASSERT(bOk);。这个坑在接手老工程时尤其常见,因为老代码里VERIFY用得很多。

5.3 坑三:消息处理函数写了,断点死活不进

现象:OnLButtonDown写完了,编译链接都过,鼠标点上去没反应,断点显示为空心圆。

原因:三落点缺一个。最常见的是映射表里漏了ON_WM_LBUTTONDOWN();其次是宏后面多加了分号,展开成空语句;再次是函数签名和宏约定不一致,比如把CPoint point写成CPoint& point,编译器可能给个警告就过了,实际跳板函数调不到。

解决:先看映射表,再看签名,最后确认消息是不是被上层截了。CView收不到鼠标消息,很多时候是CFrameWnd或者子控件先处理了。判断方法是在PreTranslateMessage里下断点,看消息有没有到这一层。

5.4 坑四:LNK2001 unresolved external symbol

现象:加了新类,编译单元都过了,链接阶段报一大堆unresolved external symbol,指向某个类的成员函数。

原因:新写的.cpp没被加进工程。VC++6.0 的 FileView 里看不到这个文件,编译器压根没编它,头文件里的声明就找不到实现。其次是AFXDLL设置不一致——有的文件按静态库编、有的按共享库编,符号修饰对不上。

解决:Project → Add To Project → Files把.cpp加进去,然后在Project → Settings → General里确认整个工程用的是同一种 MFC 链接方式。改完记得Build → Rebuild All,增量编译在 VC++6.0 里经常留旧.obj,不清干净看不出效果。

5.5 坑五:中文字符串显示成乱码或问号

现象:CString里放中文,TextOut出来是乱码;或者AfxMessageBox弹出来一半正常一半问号。

原因:VC++6.0 默认按 MBCS(多字节字符集)编译,源文件编码、字符串字面量编码、运行时区域设置三者不一致就会乱。从别的机器拷来的文件经常是 UTF-8 无 BOM,VC++6.0 的编辑器按本地代码页解释,一个汉字被拆成三个字节存进去,运行时就散架了。

解决:源文件统一存成 ANSI(本地代码页),不要用 UTF-8;字符串字面量前不加L前缀(那是宽字符,得配wchar_t和TextOutW);CString和char*互转时注意GetBuffer/ReleaseBuffer成对出现。要是工程必须处理多语言,那就得整体切到_UNICODE,这属于另一个量级的改造,不在入门范围内。

6. 把 OnDraw 用透:一份代码同时喂窗口、预览和打印机

OnDraw和OnPaint的区别值得单独拎出来说。CView收到WM_PAINT时走的是OnPaint,而CView::OnPaint内部构造一个CPaintDC然后调用OnDraw(pDC)。所以你在OnDraw里写的绘图代码,实际是被OnPaint间接调用的,这也意味着如果你重写了OnPaint却忘了调OnDraw,界面上什么都不会出现——这是新手最常见的「白屏」原因之一。

真正的价值在打印和预览。CView::OnPrint也会调用OnDraw,只是传进去的CDC*不再是屏幕 DC,而是打印机 DC 或CPreviewDC。同一份绘图代码,三种输出设备,靠的是 DC 的设备无关性:你只管调Rectangle、TextOut,具体落到像素还是墨点由 DC 自己决定。验证方法很土但有效:直接点File → Print Preview,如果预览里的排版和窗口里一致,说明绘图代码没有写死屏幕相关的假设;如果预览里全挤在左上角,多半是用了GetClientRect却没做比例换算。

排版文字时最容易出错的是行距。TextOut不换行,也不管行距,你得自己算:

void CMyView::OnDraw(CDC* pDC) { CString strText = "MFC 入门练习:这段文字会同时出现在窗口和打印预览里"; CRect rcClient; GetClientRect(&rcClient); rcClient.DeflateRect(20, 20); // 留出边距,避免贴边 CFont font; font.CreateFont(20, 0, 0, 0, FW_NORMAL, 0, 0, 0, DEFAULT_CHARSET, OUT_DEFAULT_PRECIS, CLIP_DEFAULT_PRECIS, DEFAULT_QUALITY, DEFAULT_PITCH | FF_SWISS, "宋体"); CFont* pOldFont = pDC->SelectObject(&font); CSize sz = pDC->GetTextExtent(strText); // 先量出实际宽高,行距才准 pDC->SetBkMode(TRANSPARENT); // 背景透明,字不会带白底 pDC->TextOut(rcClient.left, rcClient.top + sz.cy, strText); pDC->SelectObject(pOldFont); }

GetTextExtent返回的cy是当前字体下行高,拿它当行距比硬编码数字靠谱,换字体、换 DPI 都不用改代码。参数说明:CreateFont的nHeight是逻辑单位高度,传 20 在 96 DPI 下大约是 15 磅;nWidth传 0 表示按高度自动匹配横纵比;FW_NORMAL是字重,等价于 400;倒数第二个参数DEFAULT_PITCH | FF_SWISS表示可变间距无衬线族,最后一个是字体名。这一串参数确实反人类,实际项目里我一般用CFontDialog让用户自己选,拿回来的LOGFONT直接喂给CreateFontIndirect,省得手填出错。

还有个进阶技巧是DT_CALCRECT:调DrawText时带上这个标志,它不画字,只把文字排版后需要的矩形尺寸算出来写回lpRect。做多行文本、自动换行、居中排版时先算一次再画,比自己拆字符串靠谱得多。VC++6.0 时代没有现成的布局引擎,这算是当时最实用的一招。

从那以后我每次写OnDraw,都会强制走一遍「窗口 → 打印预览 → 实际打印」三段验证,只要有一步对不上,就说明代码里混进了屏幕相关的假设。这个习惯帮我提前发现过好几次坐标硬编码的问题。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/11/4 18:10:23

Jev推理模型接入指南:从概念到Codex配置与参数调优

最近总有朋友问我&#xff0c;Jev到底是个什么东西。每次在群里看到那张“Jev模型官网”的截图&#xff0c;大家的第一反应都是&#xff1a;这又是什么新玩具&#xff1f;怎么所有人都在刷&#xff1f;我花了两天时间&#xff0c;把这东西从概念到接入、从API调用到Codex里的配…

作者头像 李华
网站建设 2026/11/4 18:09:28

全光网络架构与PON技术实战解析

1. 全光网络架构概述 在数字化浪潮席卷全球的今天&#xff0c;网络基础设施正经历着从传统铜缆到全光纤的革命性转变。作为一名参与过多个大型园区网络改造的工程师&#xff0c;我深刻体会到PON&#xff08;无源光网络&#xff09;架构带来的变革。这种采用光纤作为唯一传输介质…

作者头像 李华
网站建设 2026/11/4 18:05:28

RAG与AI Agent知识管道:从检索原理到LangChain实践

先说个可能颠覆认知的结论&#xff1a;AI Agent 项目里最容易翻车的环节&#xff0c;往往不是规划、不是工具调用&#xff0c;而是最不起眼的“知识获取”。我做过好几个 Agent 练手项目&#xff0c;也帮朋友调过基于 LangChain 的知识库问答&#xff0c;最后发现大家踩的坑高度…

作者头像 李华
网站建设 2026/11/4 18:05:22

EdgeCrafter:边缘计算场景下的轻量级ViT模型优化方案

1. 项目背景与核心价值在边缘计算场景下部署视觉Transformer模型一直存在两大痛点&#xff1a;一方面标准ViT模型参数量大、计算复杂度高&#xff0c;难以在资源受限的边缘设备上高效运行&#xff1b;另一方面&#xff0c;直接压缩后的轻量模型往往在密集预测任务&#xff08;如…

作者头像 李华
网站建设 2026/11/4 17:59:37

会员积分营销系统源码解析与多商户运营实践

1. 项目概述作为一名在会员营销系统领域摸爬滚打多年的开发者&#xff0c;我深知商家在数字化转型过程中面临的痛点。今天要分享的这套会员积分营销系统源码&#xff0c;正是为解决这些实际问题而生。不同于市面上那些华而不实的SaaS产品&#xff0c;这套系统提供了完整的源代码…

作者头像 李华
网站建设 2026/11/4 17:58:38

MySQL多表查询实战:电影演员票房中的SELECT、JOIN与聚合

电影、演员、票房这三张表凑在一起&#xff0c;是我见过最适合练 MySQL 数据查询的素材组合。它不像电商订单表那样字段繁多到劝退&#xff0c;也不像单表练习那样查完WHERE就没下文了——电影表给你维度、演员表给你多对多关系、票房表给你可计算的数值&#xff0c;一条SELECT…

作者头像 李华