首页 / 资讯中心 / 文章详情

从零手写Windows窗口程序:深入理解Win32 GUI底层原理

从零手写Windows窗口程序:深入理解Win32 GUI底层原理 ★ FEATURED ARTICLE
Windows 桌面开发这块很多人第一次接触都是从拖控件开始的。打开 Visual Studio新建一个 Win32 项目工具箱里拽几个按钮双击生成消息处理函数编译运行一个窗口就出来了。方便是真方便但时间长了容易心虚——把向导生成的代码全删掉面对一个空白的.cpp文件还能不能从零把窗口跑起来我自己就经历过这个阶段后来逼着自己从main函数开始一行一行把窗口程序敲出来才算真正把 Windows GUI 的底层逻辑理顺了。这篇内容就是那次折腾的完整记录从入口函数的选择、窗口类的注册、消息循环的运转到窗口过程函数的编写全部拆开讲清楚。适合已经会一点 C/C、想搞明白 Windows 程序到底怎么跑起来的人也适合那些用惯了框架、想回头补一补底层原理的开发者。看完之后你应该能在一个空文件里不依赖任何向导手写一个能正常显示、响应关闭的窗口程序。1. 从入口函数说起为什么 Windows GUI 程序不叫 main1.1 main 和 WinMain 的真实关系刚学 C 语言的时候老师都会说程序从main函数开始执行。这个说法在控制台程序里没问题但到了 Windows GUI 程序你会发现向导生成的代码里根本没有main取而代之的是一个叫WinMain或者wWinMain的函数。很多人第一次看到这个就懵了以为 Windows 程序不走main。实际情况是main一直都在只是被藏起来了。链接器在链接一个 Windows 子系统程序时会去链接 CRTC 运行时库里一个叫WinMainCRTStartup的入口点。这个函数才是操作系统真正调用的第一个函数它负责初始化 CRT、解析命令行参数、设置全局变量做完这些准备工作之后再去调用你写的WinMain。所以严格来说WinMain不是程序的第一行代码而是 CRT 初始化完成后的第一个用户代码入口。那为什么 GUI 程序要用WinMain而不是main核心原因在于参数不一样。main接收的是argc和argv也就是命令行参数的个数和字符串数组。而WinMain接收的是四个参数hInstance当前程序实例句柄、hPrevInstance前一个实例句柄现在基本没用恒为 NULL、lpCmdLine命令行参数字符串不含程序名、nCmdShow窗口显示方式比如最大化、最小化、正常显示。这四个参数里hInstance尤其重要后面注册窗口类、创建窗口都要用到它。你可以这样理解main是给控制台程序用的标准入口WinMain是给图形界面程序用的入口两者最终都会被 CRT 的启动代码调用只是参数形式不同。如果你非要在 GUI 程序里用main也不是不行但需要手动改链接器设置把子系统从 Windows 改成 Console这样会附带弹出一个控制台黑窗口体验很差不推荐。1.2 字符集选择ANSI 还是 Unicode在动手写代码之前有一个必须先定下来的事情用 ANSI 字符集还是 Unicode 字符集。这个选择会直接影响你调用哪些 API 函数以及字符串用什么类型。Windows API 里大量函数都有两个版本比如CreateWindowEx实际上有CreateWindowExA和CreateWindowExW两个版本A 结尾的是 ANSI 版本W 结尾的是宽字符Unicode版本。你在代码里写CreateWindowEx编译器会根据是否定义了UNICODE宏来决定链接到哪个版本。同理TCHAR、LPTSTR、LPCTSTR这些类型也会跟着变定义了UNICODE就是wchar_t系列没定义就是char系列。我的建议很明确新项目一律用 Unicode。原因有三点。第一Unicode 是 Windows 内部的原生编码ANSI 版本最终还是要转换成 Unicode 再调用内核多一层转换就多一层开销和出错的可能。第二ANSI 版本依赖当前系统的代码页同样的代码在中文系统和英文系统上行为可能不一样跨语言环境容易出乱码。第三现在主流的开发环境和库都默认 Unicode跟着大方向走不会错。具体操作上在 Visual Studio 里项目属性中把字符集设为“使用 Unicode 字符集”或者在代码最前面手动定义#ifndef UNICODE #define UNICODE #endif #ifndef _UNICODE #define _UNICODE #endif注意这两个宏都要定义UNICODE影响 Windows API 的声明_UNICODE影响 CRT 函数的声明少定义一个就可能出现类型不匹配的编译错误。定义完之后字符串字面量要用L...的形式或者用TEXT(...)宏包裹后者会根据是否定义UNICODE自动加不加L前缀。提示如果你在代码里混用了 ANSI 和 Unicode 的字符串类型编译器会报“无法从 const char 转换为 LPCWSTR”这类错误。遇到这种报错先检查字符串有没有加L前缀再检查字符集宏有没有定义对。2. 窗口程序的核心骨架注册、创建、消息循环2.1 窗口类注册给窗口办一张身份证Windows 里创建一个窗口不是直接调用一个函数就完事了得先“注册窗口类”。这个说法听起来抽象其实可以类比成给窗口办一张身份证。这张身份证上记录了窗口的各种属性用什么函数处理消息、窗口的图标是什么、鼠标指针长什么样、背景色怎么画、窗口类的名字叫什么。系统拿到这张身份证才知道该怎么创建和管理这类窗口。注册窗口类用的是RegisterClassEx函数参数是一个WNDCLASSEX结构体。这个结构体字段不少但常用的就那么几个我逐个说一下。cbSize是结构体大小直接写sizeof(WNDCLASSEX)就行系统用它来做版本兼容检查。style是窗口类风格常用的有CS_HREDRAW和CS_VREDRAW表示窗口水平或垂直尺寸变化时重绘整个窗口不设的话窗口拉伸时可能留下残影。lpfnWndProc是最关键的字段指向窗口过程函数所有发给这个窗口的消息都会送到这个函数处理。cbClsExtra和cbWndExtra是额外内存一般填 0除非你要在窗口类或窗口实例上附加自定义数据。hInstance是程序实例句柄从WinMain参数里拿。hIcon和hIconSm是大图标和小图标可以用LoadIcon加载系统图标也可以加载自己的资源。hCursor是鼠标指针用LoadCursor(NULL, IDC_ARROW)加载标准箭头。hbrBackground是背景画刷用(HBRUSH)(COLOR_WINDOW1)表示使用系统窗口背景色。lpszMenuName是菜单资源名没有菜单就填 NULL。lpszClassName是窗口类的名字自己起一个字符串后面创建窗口时要用这个名字来引用。这里有个容易踩的坑lpszClassName不能随便起个和系统内置类重名的名字比如Button、Edit、Static这些是系统预定义的窗口类你注册同名类会失败。我一般习惯加个前缀比如LMyWindowClass避免冲突。注册失败的话RegisterClassEx返回 0可以用GetLastError拿到错误码。常见失败原因有两个一是窗口类名重复注册二是lpfnWndProc没设置或者设置错了。排查的时候先确认类名没和系统类冲突再确认窗口过程函数签名正确。2.2 创建窗口CreateWindowEx 的参数逐个拆解窗口类注册好之后就可以调用CreateWindowEx创建窗口了。这个函数参数多达十二个第一次看确实头大但拆开看每个都有明确含义。第一个参数dwExStyle是扩展风格比如WS_EX_CLIENTEDGE给窗口加个凹陷边框WS_EX_TOPMOST让窗口置顶。不需要扩展风格就填 0。第二个参数lpClassName就是刚才注册的窗口类名。第三个参数lpWindowName是窗口标题显示在标题栏上。第四个参数dwStyle是窗口风格WS_OVERLAPPEDWINDOW是最常用的组合包含标题栏、边框、最小化最大化按钮、可调整大小等。接下来四个参数x、y、nWidth、nHeight是窗口位置和大小。这里有个细节如果x填CW_USEDEFAULT系统会自动选一个位置y参数会被忽略。宽度和高度也可以填CW_USEDEFAULT系统会给个默认值。我一般位置用默认大小自己指定比如 800x600。hWndParent是父窗口句柄顶层窗口填 NULL。hMenu是菜单句柄没有菜单填 NULL。hInstance还是程序实例句柄。最后一个参数lpParam是传给窗口过程的附加参数创建的时候会通过WM_CREATE消息的lParam传进去一般填 NULL。CreateWindowEx返回一个HWND也就是窗口句柄。如果返回 NULL说明创建失败。失败原因可能是窗口类没注册、内存不足、或者参数有问题。实际排查时我习惯在创建失败后立刻调用GetLastError打印错误码比盲目猜要快得多。创建完窗口窗口还是不可见的需要调用ShowWindow(hwnd, nCmdShow)显示窗口再调用UpdateWindow(hwnd)强制立即重绘。这两步顺序不能反先显示再更新否则可能看到窗口内容闪烁或者空白。2.3 消息循环程序的心脏窗口显示出来之后程序就进入消息循环了。这是 Windows GUI 程序最核心的机制也是和控制台程序最大的区别。控制台程序是顺序执行从头跑到尾GUI 程序是事件驱动坐在那里等消息来一个处理一个。消息循环的标准写法是这样的MSG msg; while (GetMessage(msg, NULL, 0, 0)) { TranslateMessage(msg); DispatchMessage(msg); } return (int)msg.wParam;GetMessage从消息队列里取消息取到非 WM_QUIT 消息返回非零取到 WM_QUIT 返回零循环结束。TranslateMessage把虚拟键消息转换成字符消息比如把按键消息转成WM_CHAR这样你才能在编辑框里输入文字。DispatchMessage把消息分发到对应的窗口过程函数去处理。这里有个关键点GetMessage在消息队列为空时会阻塞程序停在那里等不消耗 CPU。这就是为什么 GUI 程序闲着的时候 CPU 占用是 0。和它相对的是PeekMessage后者不阻塞取不到消息立刻返回适合在游戏循环或者需要持续做事的场景里用。消息循环结束的条件是收到WM_QUIT消息。这个消息一般在窗口过程里处理WM_DESTROY时通过PostQuitMessage(0)投递。PostQuitMessage的参数会成为WM_QUIT消息的wParam最终作为WinMain的返回值。所以整个流程是用户点关闭按钮 → 窗口收到WM_CLOSE→ 默认处理调用DestroyWindow→ 窗口收到WM_DESTROY→ 调用PostQuitMessage→ 消息循环收到WM_QUIT→ 循环结束 → 程序退出。注意PostQuitMessage只是投递一个退出消息不会立刻结束程序。消息循环要等到把队列里排在WM_QUIT前面的消息都处理完才会取到WM_QUIT并退出。这个设计保证了退出前所有待处理消息都能得到处理。3. 窗口过程函数消息处理的真正战场3.1 函数签名与消息分发结构窗口过程函数是真正干活的地方所有发给窗口的消息都在这里处理。它的签名是固定的LRESULT CALLBACK WindowProc(HWND hwnd, UINT uMsg, WPARAM wParam, LPARAM lParam)CALLBACK是调用约定展开就是__stdcall表示由被调用方清理栈这是 Windows API 的标准约定。HWND是接收消息的窗口句柄UINT是消息 IDWPARAM和LPARAM是消息附带的参数具体含义取决于消息类型。返回值LRESULT是消息处理结果不同消息返回值含义不同。函数内部通常是一个switch语句根据uMsg分派到不同的case。这里有个非常重要的原则你只处理你关心的消息不关心的消息必须交给DefWindowProc处理。DefWindowProc是系统的默认窗口过程负责处理那些你没处理的杂项消息比如窗口移动、大小调整、系统菜单等。如果你把所有消息都自己处理了窗口会失去很多默认行为变得很难用。switch (uMsg) { case WM_DESTROY: PostQuitMessage(0); return 0; case WM_PAINT: // 处理绘制 return 0; default: return DefWindowProc(hwnd, uMsg, wParam, lParam); }这个default分支千万不能漏。我见过有人图省事所有 case 都不写 default结果窗口拖不动、按钮点不了排查半天才发现是没交给默认处理。3.2 必须处理的几个核心消息虽然消息有几百种但一个基础窗口程序必须处理的消息就那么几个。WM_DESTROY是窗口被销毁时收到的消息。在这里调用PostQuitMessage(0)投递退出消息是程序正常退出的关键。如果不处理这个消息关闭窗口后消息循环还在跑程序会变成后台僵尸进程任务管理器里能看到但界面上找不到。WM_CLOSE是用户点击关闭按钮或者按 AltF4 时收到的消息。默认处理会调用DestroyWindow销毁窗口。如果你想在关闭前弹个确认框比如“确定要退出吗”就在这里拦截弹框询问用户确认了再调DestroyWindow不确认就直接返回 0 什么都不做。WM_PAINT是窗口需要重绘时收到的消息。比如窗口刚显示、被其他窗口遮挡后重新露出、大小改变等。处理这个消息时必须先调用BeginPaint获取设备上下文绘制完成后调用EndPaint释放。这两个函数必须成对出现否则会导致重绘消息不断堆积CPU 飙升。WM_CREATE是窗口创建过程中收到的消息在窗口完全创建好之前发出。适合在这里初始化一些资源比如创建子控件、加载数据。如果初始化失败返回 -1 可以让窗口创建失败。WM_SIZE是窗口大小改变时收到的消息lParam的低 16 位是新宽度高 16 位是新高度。如果窗口里有子控件需要跟着调整布局就在这里处理。3.3 消息参数的拆包技巧WPARAM和LPARAM这两个参数在不同消息里含义完全不同这是初学者最容易迷糊的地方。它们本质上就是两个整数具体怎么解释要看消息类型。以WM_SIZE为例宽度和高度被压缩在lParam里低 16 位是宽度高 16 位是高度。取出来要这样写int width LOWORD(lParam); int height HIWORD(lParam);LOWORD和HIWORD是 Windows 提供的宏分别取一个 32 位整数的低 16 位和高 16 位。对应的还有LOBYTE和HIBYTE取字节。这些宏在windows.h里定义用起来很方便。再比如WM_COMMAND消息wParam的低 16 位是控件 ID高 16 位是通知码lParam是控件句柄。菜单命令、按钮点击、快捷键都通过这个消息传递。拆包的时候用LOWORD(wParam)拿控件 ID用HIWORD(wParam)拿通知码。鼠标消息的参数又不一样。WM_LBUTTONDOWN的lParam里低 16 位是鼠标 x 坐标高 16 位是 y 坐标坐标是相对于客户区左上角的。wParam里则包含各种键的状态比如MK_CONTROL表示 Ctrl 键被按下MK_SHIFT表示 Shift 键被按下。我的经验是遇到不熟悉的消息直接查微软的官方文档每个消息的wParam和lParam含义都写得清清楚楚。不要凭记忆猜猜错了调试起来很痛苦。4. 完整代码实现与逐行解析4.1 从零到可运行的完整代码前面把各个部分都拆开讲了现在把它们组装起来给出一份完整的、可以直接编译运行的代码。这份代码不依赖任何向导纯手写用 Unicode 字符集。#ifndef UNICODE #define UNICODE #endif #ifndef _UNICODE #define _UNICODE #endif #include windows.h LRESULT CALLBACK WindowProc(HWND hwnd, UINT uMsg, WPARAM wParam, LPARAM lParam); int WINAPI wWinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, PWSTR pCmdLine, int nCmdShow) { const wchar_t CLASS_NAME[] LMyWindowClass; WNDCLASSEX wc { }; wc.cbSize sizeof(WNDCLASSEX); wc.style CS_HREDRAW | CS_VREDRAW; wc.lpfnWndProc WindowProc; wc.hInstance hInstance; wc.hIcon LoadIcon(NULL, IDI_APPLICATION); wc.hIconSm LoadIcon(NULL, IDI_APPLICATION); wc.hCursor LoadCursor(NULL, IDC_ARROW); wc.hbrBackground (HBRUSH)(COLOR_WINDOW 1); wc.lpszClassName CLASS_NAME; if (!RegisterClassEx(wc)) { MessageBox(NULL, L注册窗口类失败, L错误, MB_ICONERROR); return 0; } HWND hwnd CreateWindowEx( 0, CLASS_NAME, L我的第一个窗口程序, WS_OVERLAPPEDWINDOW, CW_USEDEFAULT, CW_USEDEFAULT, 800, 600, NULL, NULL, hInstance, NULL ); if (hwnd NULL) { MessageBox(NULL, L创建窗口失败, L错误, MB_ICONERROR); return 0; } ShowWindow(hwnd, nCmdShow); UpdateWindow(hwnd); MSG msg; while (GetMessage(msg, NULL, 0, 0)) { TranslateMessage(msg); DispatchMessage(msg); } return (int)msg.wParam; } LRESULT CALLBACK WindowProc(HWND hwnd, UINT uMsg, WPARAM wParam, LPARAM lParam) { switch (uMsg) { case WM_DESTROY: PostQuitMessage(0); return 0; case WM_PAINT: { PAINTSTRUCT ps; HDC hdc BeginPaint(hwnd, ps); RECT rect; GetClientRect(hwnd, rect); DrawText(hdc, LHello, Windows GUI!, -1, rect, DT_SINGLELINE | DT_CENTER | DT_VCENTER); EndPaint(hwnd, ps); return 0; } case WM_CLOSE: if (MessageBox(hwnd, L确定要退出吗, L提示, MB_YESNO | MB_ICONQUESTION) IDYES) { DestroyWindow(hwnd); } return 0; default: return DefWindowProc(hwnd, uMsg, wParam, lParam); } }4.2 编译配置与常见编译错误这份代码在 Visual Studio 里编译需要确认几个配置项。项目属性里配置属性 → 链接器 → 系统 → 子系统要设为“窗口 (/SUBSYSTEM:WINDOWS)”。如果设成了控制台会报“无法解析的外部符号 WinMain”之类的错误因为链接器在找main而不是wWinMain。入口点设置也要注意。如果用wWinMain链接器 → 高级 → 入口点保持默认即可CRT 会自动找到它。如果手动指定了入口点要确保写的是wWinMainCRTStartup而不是wWinMain后者是用户函数前者才是 CRT 入口。字符集方面项目属性 → 配置属性 → 常规 → 字符集设为“使用 Unicode 字符集”。如果设成“使用多字节字符集”代码里的L...字符串和wchar_t类型会报错因为 API 期望的是char类型。常见的编译错误还有几个。error C2440: “初始化”: 无法从“const char [N]”转换为“LPCWSTR”这是字符串没加L前缀或者字符集宏没定义对。error LNK2019: 无法解析的外部符号 _WinMain16这是子系统设成了控制台改成 Windows 就行。error LNK2001: 无法解析的外部符号 _wWinMain这是入口点设置有问题检查链接器高级设置里的入口点。4.3 运行效果与调试技巧编译通过后运行应该能看到一个 800x600 的窗口标题栏写着“我的第一个窗口程序”客户区正中间显示“Hello, Windows GUI!”。点击关闭按钮会弹出确认框点“是”退出点“否”窗口继续留着。调试 GUI 程序和控制台程序不太一样。控制台程序可以用printf打印调试信息GUI 程序没有控制台printf的输出看不到。我常用的替代方案有三种。第一种是用OutputDebugString输出到调试器窗口配合 DebugView 工具查看。第二种是用MessageBox弹框简单粗暴但有效适合快速确认某个分支有没有走到。第三种是写日志文件用fopen、fprintf把关键信息写到文件里适合需要记录大量信息的场景。OutputDebugString的用法很简单OutputDebugString(L进入 WM_PAINT 处理\n);在 Visual Studio 里调试运行时输出会显示在“输出”窗口的“调试”页。如果不在调试状态可以用第三方工具捕获。这个函数在 Release 版本里也有效但要注意字符串拼接的开销别在性能敏感的循环里频繁调用。提示OutputDebugString在字符串末尾不会自动加换行需要手动加\n。另外它只接受宽字符串ANSI 字符串要先转换或者直接用OutputDebugStringA。5. 常见问题排查与避坑经验5.1 窗口创建失败排查清单窗口创建失败是新手最常遇到的问题CreateWindowEx返回 NULL但不知道哪里错了。我整理了一个排查顺序按这个顺序查基本都能定位到问题。排查项检查方法常见错误窗口类是否注册成功检查RegisterClassEx返回值类名和系统类冲突、lpfnWndProc为空窗口类名是否一致对比注册和创建时的字符串拼写错误、大小写不一致实例句柄是否正确检查hInstance来源用了 NULL 或错误的句柄子系统设置项目属性 → 链接器 → 系统设成了控制台字符集设置项目属性 → 常规 → 字符集ANSI/Unicode 不匹配排查的时候我习惯在CreateWindowEx失败后立刻调用GetLastError并弹框显示错误码。错误码 1407 表示“找不到窗口类”说明类名不对或者没注册成功。错误码 1400 表示“无效的窗口句柄”一般是父窗口句柄传错了。错误码 0 表示“操作成功完成”这种情况通常是别的地方出了问题比如窗口创建成功了但显示失败。还有一个隐蔽的坑WNDCLASSEX结构体没有清零。如果用了WNDCLASSEX wc;而不是WNDCLASSEX wc { };结构体里的字段是随机值cbSize可能是垃圾数据导致RegisterClassEx失败。这个错误很隐蔽因为编译器不会报错运行时才出问题。养成习惯声明结构体时直接初始化清零。5.2 消息循环卡死与 CPU 占用异常消息循环卡死或者 CPU 占用 100%通常和WM_PAINT处理不当有关。前面说过BeginPaint和EndPaint必须成对出现。如果只调了BeginPaint没调EndPaint系统会认为绘制没完成不断发送WM_PAINT消息形成死循环CPU 直接跑满。另一个常见原因是GetMessage用错了。有人把GetMessage的返回值判断写成了while (GetMessage(...) ! 0)这本身没错但如果消息队列里出现了WM_QUIT之外的其他导致返回 0 的情况循环会意外退出。更稳妥的写法是判断返回值是否大于 0因为GetMessage在出错时返回 -1。BOOL ret; while ((ret GetMessage(msg, NULL, 0, 0)) ! 0) { if (ret -1) { // 处理错误 break; } TranslateMessage(msg); DispatchMessage(msg); }还有一种情况是窗口过程里处理某个消息时进入了死循环比如在WM_SIZE里调用了会触发WM_SIZE的函数形成递归。这种问题用调试器打断点看调用栈就能发现。5.3 窗口关闭后进程残留点了关闭按钮窗口消失了但任务管理器里进程还在这是新手常遇到的另一个问题。根本原因是WM_DESTROY里没有调用PostQuitMessage消息循环没收到WM_QUIT一直在跑。还有一种情况是调用了DestroyWindow但窗口过程里没处理WM_DESTROY。DestroyWindow会发送WM_DESTROY消息但如果你在窗口过程里把这个消息拦截了没有调用DefWindowProc也没有自己调PostQuitMessage消息循环就永远等不到退出信号。排查这个问题可以在WM_DESTROY里加一句OutputDebugString看有没有走到。如果没走到说明WM_DESTROY没被触发检查是不是WM_CLOSE处理有问题。如果走到了但进程还在检查PostQuitMessage有没有被调用参数对不对。注意PostQuitMessage的参数会成为WM_QUIT的wParam最终作为wWinMain的返回值。一般传 0 表示正常退出传其他值表示异常退出。这个返回值可以被批处理脚本或者启动器捕获用来判断程序是否正常结束。5.4 中文显示乱码问题窗口标题或者绘制文本里包含中文显示出来是乱码这个问题几乎每个中文开发者都遇到过。根本原因还是字符集不匹配。如果你用的是 Unicode 字符集字符串必须用L中文或者TEXT(中文)。如果写成了中文编译器会把它当成 ANSI 字符串而 API 期望的是宽字符串类型不匹配会编译报错。如果强行转换比如用(LPCWSTR)中文运行时就会乱码因为 ANSI 字节被当成了宽字符解释。如果你用的是 ANSI 字符集字符串写中文没问题但源文件必须保存为当前系统代码页对应的编码。比如中文系统下保存为 GBK否则编译器按 UTF-8 解析运行时也会乱码。这就是为什么我强烈建议新项目用 Unicode省去这些编码烦恼。还有一个容易忽略的地方DrawText绘制中文时如果字体没设置好可能显示成方块。默认的系统字体对中文支持一般没问题但如果自己创建了字体要确保字体包含中文字形。用CreateFont创建字体时字符集参数设为GB2312_CHARSET或者DEFAULT_CHARSET让系统自动选择合适的中文字体。6. 从能跑到好用几个实用的进阶方向6.1 封装窗口类减少重复代码手写一遍窗口程序之后你会发现每次新建项目都要重复注册窗口类、创建窗口、写消息循环这些代码。这时候可以考虑封装一个简单的窗口基类把公共逻辑抽出来。封装的核心思路是定义一个Window类构造函数里注册窗口类提供一个Create方法创建窗口消息循环放在Run方法里。窗口过程函数是静态的通过SetWindowLongPtr把this指针存到窗口的用户数据里在窗口过程里取出来转发给成员函数处理。class Window { public: Window(); ~Window(); BOOL Create(LPCWSTR title, int width, int height); int Run(); protected: virtual LRESULT HandleMessage(UINT uMsg, WPARAM wParam, LPARAM lParam); private: static LRESULT CALLBACK WndProc(HWND hwnd, UINT uMsg, WPARAM wParam, LPARAM lParam); HWND m_hwnd; };这样封装之后子类只需要重写HandleMessage处理自己关心的消息其他都交给基类。代码复用率大大提高也更容易维护。不过封装要注意一点SetWindowLongPtr存this指针的时机是在WM_NCCREATE消息里这时候窗口句柄已经有效但窗口还没完全创建好是存用户数据的最佳时机。6.2 用资源文件管理图标和菜单手写代码创建菜单和加载图标比较麻烦用资源文件.rc会方便很多。在 Visual Studio 里添加资源可以可视化编辑菜单、对话框、图标、字符串表等。资源会被编译成二进制嵌入到 exe 里运行时用LoadMenu、LoadIcon、LoadString等函数加载。资源文件的好处是分离了界面和逻辑改界面不用动代码改代码不影响界面。而且资源支持多语言同一套代码可以加载不同语言的资源适合做国际化。不过资源文件也有缺点就是版本控制不太友好二进制资源没法 diff多人协作时容易冲突。我的做法是文本资源菜单、字符串用 .rc 管理图标、图片这类二进制资源单独放目录用相对路径加载。6.3 子控件与布局管理基础窗口跑通之后下一步自然是往里加按钮、编辑框、列表这些子控件。子控件本质上也是窗口用CreateWindowEx创建父窗口句柄填主窗口的hwnd窗口类名用系统预定义的Button、Edit、ListBox等。子控件创建后位置和大小是固定的。窗口大小改变时子控件不会自动调整需要你在WM_SIZE里手动计算新位置并调用MoveWindow或SetWindowPos。控件多了之后手动计算很繁琐可以考虑用布局管理器或者自己写一套简单的布局规则比如按比例分配空间、按行排列等。子控件发消息给父窗口是通过WM_COMMAND消息。比如按钮被点击父窗口会收到WM_COMMANDLOWORD(wParam)是按钮 IDHIWORD(wParam)是通知码BN_CLICKEDlParam是按钮句柄。在父窗口的窗口过程里处理这个消息就能响应按钮点击。6.4 双缓冲绘图消除闪烁如果窗口里绘制的内容比较多或者有动画可能会看到闪烁。原因是WM_PAINT里直接往屏幕设备上下文绘制绘制过程中屏幕会刷新看到中间状态。解决办法是双缓冲先在内存设备上下文里画好再一次性拷贝到屏幕。双缓冲的基本步骤是创建兼容设备上下文CreateCompatibleDC创建兼容位图CreateCompatibleBitmap把位图选入内存设备上下文在内存设备上下文里绘制最后用BitBlt把内存位图拷贝到屏幕设备上下文。绘制完成后记得释放资源位图和设备上下文都要删除否则会内存泄漏。HDC hdc BeginPaint(hwnd, ps); HDC memDC CreateCompatibleDC(hdc); HBITMAP memBmp CreateCompatibleBitmap(hdc, width, height); HBITMAP oldBmp (HBITMAP)SelectObject(memDC, memBmp); // 在 memDC 里绘制 // ... BitBlt(hdc, 0, 0, width, height, memDC, 0, 0, SRCCOPY); SelectObject(memDC, oldBmp); DeleteObject(memBmp); DeleteDC(memDC); EndPaint(hwnd, ps);双缓冲的代价是额外内存和一次拷贝开销对于普通窗口程序来说完全可以接受。如果绘制内容很简单比如就画几行文字不用双缓冲也没事。判断标准是看有没有肉眼可见的闪烁有就上双缓冲没有就别过度优化。6.5 消息钩子与全局热键有时候需要监听全局的键盘鼠标事件比如做截图工具、快捷键工具这时候就要用到消息钩子。SetWindowsHookEx可以安装钩子拦截系统消息。钩子分线程钩子和全局钩子全局钩子需要 DLL 注入比较复杂而且容易被安全软件拦截。如果只是想要全局热键用RegisterHotKey更简单。注册一个热键组合比如 CtrlAltA系统会在热键按下时给指定窗口发送WM_HOTKEY消息。在窗口过程里处理这个消息执行对应操作。用完记得UnregisterHotKey注销否则热键会一直被占用。RegisterHotKey(hwnd, 1, MOD_CONTROL | MOD_ALT, A); // 在 WM_HOTKEY 里处理 case WM_HOTKEY: if (wParam 1) { // 执行操作 } return 0;热键注册失败通常是组合键被其他程序占用了换一个组合试试。另外热键是全局的注册成功后不管焦点在哪个程序按下都会触发所以别注册太常用的组合免得干扰其他软件。7. 我踩过的几个坑和一点个人体会第一次从main函数开始写窗口程序我犯的最大错误是忘了在WM_DESTROY里调PostQuitMessage。程序编译运行都正常窗口也能显示但点了关闭按钮之后窗口消失了进程还在后台跑。当时不知道怎么回事反复检查代码也没发现问题后来用任务管理器才看到进程残留。加上PostQuitMessage之后问题解决这个教训让我记住了消息循环退出的完整链路。第二个坑是WNDCLASSEX结构体没清零。有次代码写得太快声明结构体时忘了初始化RegisterClassEx一直返回失败但错误码是 0查了半天没头绪。后来把结构体改成 { }初始化问题立刻消失。从那以后我养成了习惯所有 Windows 结构体声明时一律清零不管用不用得到所有字段。第三个坑是字符集混用。早期项目用了 ANSI 字符集后来想改成 Unicode结果字符串类型全乱了编译报了几百个错误。那次之后我新项目一律 Unicode老项目如果改动量大就保持原样不折腾。说点个人体会。从main函数开始写窗口程序最大的价值不是学会了几个 API而是理解了 Windows 程序的消息驱动模型。这个模型和控制台的顺序执行模型完全不同理解了它再看 MFC、Qt、WPF 这些框架就能看透它们封装的本质。框架帮你把注册窗口类、消息循环这些样板代码藏起来了但底层跑的还是同一套东西。知道底层怎么跑用框架的时候心里就有底出了问题也能往下查。另外手写一遍之后你会对“句柄”这个概念有更具体的认识。HWND、HINSTANCE、HDC、HBRUSH这些句柄本质上都是不透明的整数代表系统内部的一个对象。你拿着句柄去调 API系统根据句柄找到对应对象操作。理解了这个再看那些CloseHandle、DeleteObject、ReleaseDC之类的释放函数就知道为什么要成对调用了——不释放系统内部的对象就一直占着资源。最后分享一个小技巧如果你在调试窗口程序时想快速查看某个消息有没有被触发可以在窗口过程函数的第一行加一句OutputDebugString把消息 ID 打印出来。配合消息 ID 的宏定义对照表能快速定位消息流向。不过别在最终版本里留着大量字符串输出会影响性能。
阅读完成 · 觉得有帮助?
咨询建站