Visual C++ 2017 Windows窗口编程入门:从WinMain到消息循环

📅 2026/7/14 5:59:36 👁️ 阅读次数 📝 编程学习
Visual C++ 2017 Windows窗口编程入门:从WinMain到消息循环

1. 项目概述:从零开始理解Windows窗口编程

很多刚接触Visual C++ 2017的朋友,一上来就想搞点“看得见”的东西,比如一个带按钮、能交互的窗口程序。这想法太对了,毕竟对着黑漆漆的控制台敲代码,成就感来得没那么直接。但当你兴冲冲地新建一个“Windows桌面应用程序”项目,看到向导生成的那一大坨代码时,是不是瞬间就懵了?WinMainWNDCLASSEXCreateWindowEx、消息循环……这些到底是什么?别慌,这太正常了。我刚开始学的时候,感觉这些概念就像一堵墙,把我和“做出一个程序”的梦想隔开了。

其实,理解Windows窗口程序的核心,就是理解一套运行了几十年的、经典的“事件驱动”模型。它不像控制台程序那样,从main函数开始,一行行执行到你return 0结束。窗口程序更像是一个“服务员”,它创建好一个窗口(就像开了一家店),然后进入一个无限循环(while (GetMessage(...))),静静地等待顾客(也就是系统或用户)的“消息”(Message),比如“鼠标点了一下这里”(WM_LBUTTONDOWN)、“窗口需要重画了”(WM_PAINT)、“用户按了关闭按钮”(WM_CLOSE)。我们的代码,大部分时间都是在写各种“消息处理函数”,来响应这些事件。

所以,这个“01 窗口”的入门主题,目标不是让你立刻做出一个功能复杂的软件,而是帮你亲手搭建起这个“事件驱动”模型的最基础骨架。我们会彻底拆解Visual Studio 2017生成的那个基础窗口项目,把每一行代码、每一个API调用都讲明白。当你真正理解了窗口是如何注册、创建、显示,消息是如何被捕获和分发的,后续添加按钮、菜单、绘图这些功能,就都是在这个坚实骨架上的“添砖加瓦”,会变得顺理成章。无论你未来是想做工具软件、小游戏还是工业界面,这套基础都是绕不开的起点。

2. 环境准备与项目创建:避开第一个坑

在动手写代码之前,确保你的“战场”准备妥当至关重要。很多新手卡在第一步,不是代码问题,而是环境没配好。

2.1 安装Visual Studio 2017并确保C++组件

首先,你得有Visual Studio 2017。安装时,切记勾选“使用C++的桌面开发”这个工作负载。这看起来是废话,但我真见过有人只装了Python或.NET组件,然后到处问为什么不能创建C++项目。安装程序会默认帮你勾选上必要的组件,包括我们后面会频繁用到的“Windows SDK”和“Visual C++ 2017 生成工具”。

安装完成后,一个必须验证的点是:Visual C++ 2017 Redistributable是否安装成功。这就是网络热词里频繁出现的microsoft visual c++ redistributable。它是一组运行时库(DLL文件),你的程序编译后,在别人的电脑上运行,如果那台电脑没有安装对应版本的Redistributable,就会弹窗报错“找不到VCRUNTIME140.dll”之类的。虽然开发时你的环境里已经有这些库了,但理解它的存在很重要。

你可以在“控制面板 -> 程序和功能”里查看是否安装了“Microsoft Visual C++ 2017 Redistributable (x86/x64)”。通常VS安装器会帮你装好。如果遇到像热词中提到的error msb3428: 未能加载 visual c++ 组件“vcbuild.exe”这种错误,这通常发生在用npm install编译某些原生Node模块(如node-sass)时,它提示你需要完整的Visual Studio Build Tools,而不是仅仅Redistributable。这时你需要通过Visual Studio Installer,为你的VS2017安装“Visual C++ 生成工具”这一单独组件。

2.2 创建第一个Windows桌面项目

打开VS2017,点击“文件 -> 新建 -> 项目”。在左侧模板中选择“Visual C++” -> “Windows桌面”,然后在右侧选择“Windows桌面应用程序”。给项目起个名字,比如MyFirstWindow,选择好位置。

注意:这里有个关键选择——“解决方案”和“项目”的目录。建议为你的学习系列单独创建一个解决方案文件夹(例如VC2017_Learning),然后每个章节(如01_Window)作为一个项目放在里面。这样结构清晰,便于管理。

点击“确定”后,VS会弹出一个“Windows桌面项目”对话框。这里强烈建议你取消勾选“预编译头”和“安全开发生命周期(SDL)检查”。对于初学者来说,预编译头(stdafx.h)会增加复杂度,而SDL检查可能会在一些常规操作上报警告,干扰学习。我们初期目标是理解核心原理,越干净越好。确保“空项目”是未勾选状态,因为我们需要它为我们生成初始的窗口代码框架。

点击“确定”,VS就会为你生成一个完整的、可以编译运行的窗口程序。先别急着看代码,直接按Ctrl+F5(开始执行不调试)运行一下。你应该能看到一个标准的、可缩放、可最小化、有关闭按钮的空白窗口弹出来。恭喜你,你的第一个窗口程序已经跑起来了!虽然它什么都没做,但所有现代Windows GUI程序的血脉都在它里面流淌着。

3. 核心代码逐行解析:WinMain与窗口生命周期

现在,我们打开项目里的<项目名>.cpp文件(比如MyFirstWindow.cpp),来直面那些看起来令人畏惧的代码。我会把它们拆解成几个逻辑块,一块一块消化。

3.1 程序的入口:WinMain函数

所有Windows桌面程序的入口点都不是main,而是WinMain。这是Windows API的规定。

int APIENTRY wWinMain(_In_ HINSTANCE hInstance, _In_opt_ HINSTANCE hPrevInstance, _In_ LPWSTR lpCmdLine, _In_ int nCmdShow) { // ... 代码内容 }
  • APIENTRY:这是一个宏,定义为__stdcall,是一种函数调用约定,规定了参数如何压栈、栈由谁清理。Windows API函数普遍使用这个约定,我们的窗口过程函数也必须用它。
  • wWinMain:这是Unicode版本的入口点。对应的ASCII版本是WinMain。在现代Windows编程中,始终使用Unicode版本(wWinMainLPCWSTR等)是最佳实践。
  • 参数解析
    • hInstance(当前实例句柄):操作系统赋予当前运行程序实例的一个唯一标识符。你可以把它理解为程序在内存中的“身份证号”,很多API调用需要它。
    • hPrevInstance(前一个实例句柄):在16位Windows时代有用,用于判断程序是否已有一个实例在运行。在32/64位系统中,它始终为NULL。可以忽略。
    • lpCmdLine(命令行参数字符串):程序启动时传入的命令行参数,类似于main(int argc, char* argv[])中的argv。注意它不包含程序名本身。
    • nCmdShow(窗口显示状态):指示窗口最初如何显示,比如SW_SHOWNORMAL(正常显示)、SW_MINIMIZE(最小化)、SW_MAXIMIZE(最大化)。这个值通常来自创建这个程序的调用者(例如资源管理器双击打开是SW_SHOWNORMAL)。

3.2 窗口类的注册:告诉系统你的窗口样式

窗口不是凭空变出来的。在创建窗口之前,你必须先向操作系统“注册”一种窗口类型,定义它的基本外观和行为模板。这个模板就是“窗口类”。

WNDCLASSEXW wcex; wcex.cbSize = sizeof(WNDCLASSEX); wcex.style = CS_HREDRAW | CS_VREDRAW; wcex.lpfnWndProc = WndProc; wcex.cbClsExtra = 0; wcex.cbWndExtra = 0; wcex.hInstance = hInstance; wcex.hIcon = LoadIcon(nullptr, IDI_APPLICATION); wcex.hCursor = LoadCursor(nullptr, IDC_ARROW); wcex.hbrBackground = (HBRUSH)(COLOR_WINDOW+1); wcex.lpszMenuName = nullptr; wcex.lpszClassName = szWindowClass; wcex.hIconSm = LoadIcon(nullptr, IDI_APPLICATION);
  • WNDCLASSEXW:结构体WNDCLASSEX的Unicode版本,W代表Wide(宽字符)。
  • 关键字段详解
    • cbSize:结构体自身的大小。必须设置,这是为了让新版本的API能向下兼容。
    • style:窗口类的样式。CS_HREDRAW | CS_VREDRAW表示当窗口的宽度或高度改变时,整个窗口客户区需要重画。这是最常用的组合之一。
    • lpfnWndProc这是灵魂所在。指向“窗口过程函数”的指针,也就是WndProc。所有发送到这个窗口的消息,都会由这个函数来处理。你可以把它想象成窗口的“大脑”或“事件处理器”。
    • hInstance:当前程序实例句柄,从WinMain参数传来。
    • hIcon,hCursor:设置窗口的图标和鼠标光标。这里使用系统标准图标和箭头光标。
    • hbrBackground:设置窗口客户区的背景画刷(颜色)。(HBRUSH)(COLOR_WINDOW+1)是一个技巧,它获取系统定义的窗口背景色(通常是白色)。
    • lpszClassName:窗口类的名称,一个字符串标识符。我们通过szWindowClass(在文件顶部定义为L“MyFirstWindow”)来标识它。后续创建窗口时就要指定这个类名。
  • 注册:填写完这个结构体后,调用RegisterClassExW(&wcex)。如果成功,这个窗口类的信息就被操作系统记录在案了。

3.3 窗口的创建与显示:让窗口现身

注册了蓝图,现在可以盖房子了。

HWND hWnd = CreateWindowExW(0, szWindowClass, // 刚才注册的类名 szTitle, // 窗口标题 WS_OVERLAPPEDWINDOW, // 窗口样式 CW_USEDEFAULT, 0, // 位置X, Y CW_USEDEFAULT, 0, // 宽度,高度 nullptr, // 父窗口句柄 nullptr, // 菜单句柄 hInstance, // 实例句柄 nullptr); // 创建参数
  • CreateWindowExW:创建扩展窗口的函数。Ex代表扩展,提供了更多样式选项。W是Unicode版本。
  • 参数解析
    • 第一个参数0是扩展样式,暂时用默认。
    • szWindowClass:必须和注册时用的类名一致。
    • szTitle:显示在窗口标题栏的文字。
    • WS_OVERLAPPEDWINDOW:这是一个组合样式,包含了标题栏、系统菜单(左上角图标)、可调整边框、最小化/最大化/关闭按钮。这是最典型的主窗口样式。
    • CW_USEDEFAULT:让系统为窗口选择默认的位置和大小。
    • 最后四个nullptr分别表示:无父窗口(顶级窗口)、无菜单、使用传入的实例句柄、无额外创建参数。
  • 返回值hWnd:窗口句柄。如果创建失败,返回NULL。句柄是操作系统管理窗口、文件等各种资源的核心概念,你可以把它看作一个资源的“操作柄”或“引用”,后续所有针对这个窗口的操作(移动、显示、销毁)都需要用到这个hWnd

创建成功后,窗口在内存中已存在,但还不可见。需要两步:

ShowWindow(hWnd, nCmdShow); // 根据nCmdShow参数显示窗口(正常、最大化等) UpdateWindow(hWnd); // 立即发送WM_PAINT消息,触发窗口首次绘制

ShowWindow让窗口变得可见。UpdateWindow则是一个优化,它确保客户区立即被绘制,而不是等到消息循环空闲时才画。

3.4 消息循环:程序的心脏

窗口显示后,程序就进入了核心的“消息循环”。这是一个while循环,它不断地从应用程序的消息队列中获取消息,并将其分发给对应的窗口过程。

MSG msg; while (GetMessage(&msg, nullptr, 0, 0)) { TranslateMessage(&msg); DispatchMessage(&msg); }
  • GetMessage:从调用线程的消息队列中获取一条消息。如果获取到的是WM_QUIT消息(通常由PostQuitMessage发出),则返回0,循环结束,程序退出。如果队列为空,函数会阻塞,等待下一条消息,这不会浪费CPU资源。
  • TranslateMessage:将键盘的击键消息(WM_KEYDOWN/WM_KEYUP)翻译成字符消息(WM_CHAR)。如果你需要处理文本输入,这一步是必要的。
  • DispatchMessage:将消息分发到该消息目标窗口的窗口过程函数(即我们注册的WndProc)中去处理。

这个循环是Windows程序“保持活着并响应”的关键。它让程序从“顺序执行”变成了“事件响应”。

4. 窗口过程函数:事件处理的核心

消息循环负责“送信”,WndProc函数就是“处理信”的地方。它是我们编写程序逻辑的主要战场。

LRESULT CALLBACK WndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam) { switch (message) { case WM_COMMAND: { int wmId = LOWORD(wParam); // 分析菜单或控件命令 switch (wmId) { case IDM_ABOUT: DialogBox(hInst, MAKEINTRESOURCE(IDD_ABOUTBOX), hWnd, About); break; case IDM_EXIT: DestroyWindow(hWnd); break; default: return DefWindowProc(hWnd, message, wParam, lParam); } } break; case WM_PAINT: { PAINTSTRUCT ps; HDC hdc = BeginPaint(hWnd, &ps); // TODO: 在此处添加任何使用 hdc 的绘图代码... EndPaint(hWnd, &ps); } break; case WM_DESTROY: PostQuitMessage(0); break; default: return DefWindowProc(hWnd, message, wParam, lParam); } return 0; }
  • CALLBACK:和APIENTRY一样,指定__stdcall调用约定。
  • 参数
    • hWnd:接收到消息的窗口的句柄。同一个窗口过程函数可以被多个窗口共享,通过这个参数区分。
    • message:消息标识符,例如WM_PAINTWM_SIZEWM_LBUTTONDOWN等。
    • wParamlParam:消息的附加参数,其含义完全取决于message是什么。例如,对于WM_LBUTTONDOWNlParam的低16位和高16位分别包含鼠标点击位置的X和Y坐标。
  • switch-case结构:这是处理消息的标准模式。我们只处理我们关心的消息,对于不处理的消息,必须调用DefWindowProc(默认窗口过程)交给系统进行默认处理。这是Windows窗口机制的要求,如果你不转发,很多基础功能(如拖动边框调整大小)会失效。
  • 几个关键消息的处理
    • WM_COMMAND:来自菜单、按钮、快捷键等控件或命令的消息。LOWORD(wParam)通常标识是哪个控件或菜单项被触发。
    • WM_PAINT:当窗口需要绘制其客户区时发送。你必须调用BeginPaintEndPaint这对函数,否则系统会认为你一直没画完,会持续发送WM_PAINT消息,导致CPU占用率飙升。HDC(设备上下文句柄)是你进行GDI绘图操作的“画笔画布”。
    • WM_DESTROY:当窗口被销毁时发送(例如用户点击了关闭按钮,我们处理了WM_CLOSE并调用了DestroyWindow)。在这里我们调用PostQuitMessage(0),它向消息队列投递一个WM_QUIT消息,导致GetMessage返回0,从而结束消息循环,最终程序退出。

5. 动手改造:让窗口“活”起来

理解了骨架,我们现在来给它加点“肉”,通过修改代码实现几个小功能,感受一下消息驱动的编程模式。

5.1 响应鼠标点击并在窗口上画点

我们想在窗口客户区内点击鼠标左键时,在点击的位置画一个红点。

首先,我们需要一个数据结构来记录所有点击过的点(为了简单,我们只记录最后一个点)。在全局变量区(WndProc函数之前)添加:

POINT g_LastClickPoint = { -1, -1 }; // 初始化为无效位置

然后,修改WndProc函数,添加对WM_LBUTTONDOWN消息的处理:

case WM_LBUTTONDOWN: { // lParam的低16位是x坐标,高16位是y坐标 g_LastClickPoint.x = LOWORD(lParam); g_LastClickPoint.y = HIWORD(lParam); // 标记客户区无效,强制触发WM_PAINT进行重绘 InvalidateRect(hWnd, NULL, TRUE); } break;

InvalidateRect函数告诉系统:窗口的某一矩形区域(这里是NULL表示整个客户区)内容已经失效,需要重画。第三个参数TRUE表示重画前先擦除背景。调用这个函数后,系统会在合适的时机(通常是消息队列空闲时)发送WM_PAINT消息。

接着,修改WM_PAINT消息的处理代码,在BeginPaintEndPaint之间绘制红点:

case WM_PAINT: { PAINTSTRUCT ps; HDC hdc = BeginPaint(hWnd, &ps); // 如果有点被记录,则绘制 if (g_LastClickPoint.x >= 0 && g_LastClickPoint.y >= 0) { // 创建一个红色的画笔 HPEN hRedPen = CreatePen(PS_SOLID, 3, RGB(255, 0, 0)); // 将新画笔选入设备上下文,并保存旧画笔 HPEN hOldPen = (HPEN)SelectObject(hdc, hRedPen); // 移动画笔到点位置,并画一个点(实际上是一个很小的圆) MoveToEx(hdc, g_LastClickPoint.x, g_LastClickPoint.y, NULL); LineTo(hdc, g_LastClickPoint.x, g_LastClickPoint.y); // 画一条长度为0的线,就是一个点 // 恢复旧画笔,并删除我们创建的画笔,防止资源泄漏 SelectObject(hdc, hOldPen); DeleteObject(hRedPen); } // TODO: 在此处添加任何使用 hdc 的绘图代码... EndPaint(hWnd, &ps); } break;

现在运行程序,在窗口客户区点击,你应该能看到一个红色的点出现在点击的位置。每次点击,旧点会被擦除(因为InvalidateRect的擦除背景参数为TRUE),新点被绘制。

5.2 改变窗口背景色和标题

也许你觉得白色背景太单调。我们可以在注册窗口类时改变背景色,或者在运行时动态改变。

方法一:注册时修改。找到WNDCLASSEXW wcex初始化部分,修改hbrBackground

wcex.hbrBackground = CreateSolidBrush(RGB(240, 240, 255)); // 淡蓝色背景

注意,用CreateSolidBrush创建的画刷资源,在程序退出前应该用DeleteObject删除。一个更简单且无需管理资源的方法是使用系统颜色画刷,但颜色选择有限。

方法二:响应WM_CTLCOLOR消息动态改变。这种方法更灵活,但稍复杂,我们后续在讲解控件时会深入。

我们还可以在程序运行时改变窗口标题。例如,在WM_LBUTTONDOWN处理中,加上:

WCHAR szNewTitle[256]; wsprintf(szNewTitle, L“我的窗口 - 点击位置: (%d, %d)”, g_LastClickPoint.x, g_LastClickPoint.y); SetWindowText(hWnd, szNewTitle);

SetWindowTextAPI可以设置窗口的标题文字。现在每次点击,标题栏都会显示最新的点击坐标。

6. 编译、调试与发布:从源码到可执行文件

代码写好了,怎么把它变成别人也能用的程序?

6.1 理解编译配置:Debug与Release

在VS2017的工具栏上方,你会看到一个下拉框,默认可能是“Debug x86”。这代表了当前的“解决方案配置”和“解决方案平台”。

  • Debug(调试):包含完整的调试符号信息,编译器优化级别低,便于设置断点、单步执行、查看变量。生成的文件较大,运行速度较慢。这是开发阶段使用的配置。
  • Release(发布):不包含调试信息,编译器会进行激进优化,去除无用代码。生成的文件较小,运行速度快。这是最终分发给用户使用的配置。

在开发过程中,务必在Debug模式下进行调试。当你认为程序稳定后,切换到Release模式重新编译,进行最终测试和发布。

6.2 处理常见的编译与运行错误

  1. “无法找到 vcruntime140.dll”或类似错误:这就是文章开头提到的“运行时库”缺失。你的程序在Debug模式下可能依赖MSVCP140D.dll(带D表示Debug),在Release模式下依赖VCRUNTIME140.dll。有两种解决方案:

    • 静态链接:在项目属性 -> “配置属性” -> “C/C++” -> “代码生成” -> “运行时库”中,将“多线程调试 DLL (/MDd)”或“多线程 DLL (/MD)”改为“多线程调试 (/MTd)”或“多线程 (/MT)”。这样会将必要的运行时库代码静态打包进你的EXE文件,文件会变大,但无需用户额外安装Redistributable。
    • 动态链接并分发Redistributable:保持/MD或/MDd设置,在分发程序时,引导用户安装对应版本的Microsoft Visual C++ 2017 Redistributable。微软官方建议使用此方式,便于通过Windows Update统一更新库的安全补丁。
  2. “error LNK1561: 必须定义入口点”:检查你的WinMain函数签名是否正确,或者项目属性中设置的“子系统”是否为“Windows (/SUBSYSTEM:WINDOWS)”。

  3. 程序窗口一闪而过:如果你不小心创建了一个控制台子系统项目,或者入口点写成了main,可能会出现这种情况。确保是Windows桌面项目,入口点是wWinMain

6.3 生成独立的可执行文件

在Release模式下编译成功后,你可以在项目目录下的x64\ReleaseWin32\Release文件夹(取决于你的平台)里找到生成的.exe文件。你可以直接把这个文件复制到其他没有安装Visual Studio的Windows 10/11电脑上运行。

重要提示:如果采用动态链接(/MD),目标电脑必须安装对应版本的VC++ Redistributable。你可以将Redistributable安装包(vc_redist.x64.exe等)和你的程序一起打包分发,并提示用户安装。微软允许你免费分发这些运行时库安装包。

7. 进阶思考与扩展方向

掌握了创建基本窗口的流程后,你已经拿到了Windows GUI编程的钥匙。接下来可以探索的方向非常多:

  1. 资源与控件:学习使用资源文件(.rc)来定义菜单、对话框、图标、字符串表。然后在代码中加载它们。尝试在窗口上添加按钮(CreateWindow时指定类名为“BUTTON”)、编辑框、列表框等标准控件,并处理它们的通知消息(如按钮的BN_CLICKED)。

  2. 图形绘制:深入GDI(图形设备接口),学习使用更多的画笔(Pen)、画刷(Brush)、字体(Font)、位图(Bitmap)。尝试绘制几何图形、文本和图像。这是自定义界面外观的基础。

  3. 对话框:模态对话框(DialogBox)和非模态对话框(CreateDialog)是常见的交互方式。学习如何使用对话框编辑器设计界面,并通过WM_INITDIALOG和控件消息与用户交互。

  4. 多窗口与消息传递:一个程序可以有多个窗口。学习如何创建子窗口、弹出窗口,以及如何在窗口之间发送自定义消息(SendMessage,PostMessage)。

  5. 定时器:使用SetTimer函数可以设置一个定时器,定期向窗口发送WM_TIMER消息,用于实现动画、定时任务等。

  6. 文件与注册表操作:学习使用Windows API进行文件读写(CreateFile,ReadFile,WriteFile)和注册表访问(RegOpenKeyEx,RegSetValueEx等),让程序能够保存和加载配置。

理解了这个最基本的窗口程序框架,就像学会了汽车的离合器、油门和方向盘的关系。后续所有复杂的功能,都是在这个“事件驱动”的框架下,通过处理更多的消息、调用更多的API来实现的。当你下次再看到那些复杂的MFC、Qt或者WinForms程序时,你会明白,它们的底层,依然是这个经典的WinMain、窗口类和消息循环在默默地工作。