Windows API 窗口绘图程序设计-实验二
Win32 窗口程序设计实验总结 · 鼠标绘制随机红色正方形
课程:网络协议分析
实验:实验 2 —— Windows API 窗口绘图程序设计
小组:第 10 组 | 日期:2026.9.24
用 Win32 API 实现"在窗口内任意位置按下鼠标左键,绘制边长 10~100 随机的红色正方形",围绕 数据与绘制分离、双缓冲防闪烁 两个核心设计展开。
目录
实验概览
这次实验把 消息机制 + 窗口过程 + GDI 绘制 + 数据持久化 串了一遍。题目四个要求:
- 设计一个简单的 Windows 窗口程序;
- 在程序窗口内的任意位置按下鼠标左键,即可绘制一个正方形;
- 正方形的边长取 10~100 像素之间的随机值;
- 正方形用红色填充。
逐条对照完成情况:
| 题目要求 | 程序实现 |
|---|---|
| 设计一个简单的 Windows 窗口程序 | wWinMain + RegisterClassExW + CreateWindowExW,标准 Win32 窗口,客户区 900×600 |
| 在窗口内任意位置按下鼠标左键 | 处理 WM_LBUTTONDOWN,以点击位置为中心绘制 |
| 可绘制正方形 | 由点击位置算出左上角坐标,FillRect 填充 |
| 大小在 10~100 之间随机 | 10 + rand() % 91,每次按下重新取值 |
| 用红色填充 | CreateSolidBrush(RGB(255, 0, 0)) |
额外功能:除题目要求外,程序还附带了撤销(
Ctrl+Z)、清空(Ctrl+Shift+C)、右键菜单、标题栏实时计数等辅助功能。
实验环境
| 项目 | 配置 |
|---|---|
| 操作系统 | Windows 11 |
| 编译器 | MinGW-w64 GCC 16.1.0 |
| 编辑器 | VS Code |
程序结构
源码位置:src/randsquare.c,整体是 Win32 三件套:
wWinMain
├─ RegisterClassExW 注册窗口类
├─ CreateWindowExW 创建窗口
└─ GetMessageW 消息循环
└─ WndProc 窗口过程(所有事情都在这里发生)
窗口过程里处理的消息:
| 消息 | 作用 |
|---|---|
WM_LBUTTONDOWN |
左键按下 → 画一个随机正方形(题目核心) |
WM_PAINT |
把内存数组里所有正方形重画一遍(双缓冲) |
WM_SIZE |
窗口尺寸变了,释放旧的内存位图 |
WM_ERASEBKGND |
直接返回 1,背景交给双缓冲画,防闪烁 |
WM_RBUTTONUP / WM_CONTEXTMENU |
弹右键菜单:撤销 / 清空 / 退出 |
WM_KEYDOWN |
Ctrl+Z 撤销,Ctrl+Shift+C 清空 |
WM_DESTROY |
释放所有 GDI 对象和内存 |
核心实现
左键按下与随机正方形
case WM_LBUTTONDOWN:
{
RECT rcClient;
GetClientRect(hWnd, &rcClient);
DrawOneSquare(GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam),
rcClient.right, rcClient.bottom);
}
return 0;
❗坐标取值要用
windowsx.h的宏
GET_X_LPARAM / GET_Y_LPARAM(来自windowsx.h)负责从lParam里取出鼠标坐标。注意不要用LOWORD / HIWORD,它们在多显示器负坐标场景下有坑,windowsx.h里的宏才是正确姿势。
随机边长只有一行,但 % 91 里的 +1 不能漏——漏了的话 100 这个边长永远摇不出来:
nSide = SIDE_MIN + rand() % (SIDE_MAX - SIDE_MIN + 1); /* 10~100 */
另外窗口被拖得很小的时候,随出来的边长可能比客户区还大,所以画之前还要和客户区宽高各比一次取较小值,保证任何窗口尺寸下都画得出完整方块。
贴边处理
直接以点击点为中心画,点在窗口边缘时方块会被切掉一半。修正逻辑是四行取界:
x = nClickX - nSide / 2; /* 以点击位置为中心 */
y = nClickY - nSide / 2;
if (x < 0) x = 0;
if (y < 0) y = 0;
if (x > nClientW - nSide) x = nClientW - nSide;
if (y > nClientH - nSide) y = nClientH - nSide;
效果是:在左上角 (6,6) 点一下,方块会贴着 (0,0) 完整显示;右下角同理(见图 6)。
数据与绘制分离:重绘不丢失的关键
这是整个实验最重要的设计决策。
一开始的想法是:收到 WM_LBUTTONDOWN,直接拿 HDC 画一个方块就完事。但这样会带来一个致命问题——
❌ 窗口被别的程序挡住再露出来、被缩放、最小化再还原,画面会被擦除,方块全没了。
因为 Windows 的窗口本身不保存画面内容,每次需要显示时都会给你发 WM_PAINT 消息,让你"重画一遍"。
所以正确的姿势是:
✅ 把每个正方形的参数(左上角坐标 + 边长)存进内存动态数组,收到
WM_PAINT时把所有正方形重新画一遍。
typedef struct tagSquare
{
int x;
int y;
int side;
} SQUARE;
数组容量不够了就成倍扩容(和 vector 一个思路):
static BOOL ReserveOneSlot(void)
{
if (g_app.nCount < g_app.nCapacity)
return TRUE;
int nNewCap = (g_app.nCapacity > 0) ? g_app.nCapacity * 2 : 64;
SQUARE *pNew = (SQUARE *)realloc(g_app.pSquares,
(size_t)nNewCap * sizeof(SQUARE));
if (pNew == NULL)
return FALSE; /* 内存不够就放弃这一个,不影响已经画好的 */
g_app.pSquares = pNew;
g_app.nCapacity = nNewCap;
return TRUE;
}
有了这份数据,撤销(nCount--)、清空(nCount = 0)、重绘(遍历数组画一遍)都是顺理成章的事。
双缓冲:告别闪烁
如果直接在 WM_PAINT 给的 HDC 上先画白底再逐个画方块,窗口稍微一复杂就会闪。标准解法是双缓冲:先在内存 DC 上把整幅画好,再一次性 BitBlt 贴到屏幕。
case WM_PAINT:
{
PAINTSTRUCT ps;
HDC hdc = BeginPaint(hWnd, &ps);
RECT rcClient;
GetClientRect(hWnd, &rcClient);
if (EnsureBackBuffer(hdc, rcClient.right, rcClient.bottom))
{
PaintScene(g_app.hMemDC, rcClient.right, rcClient.bottom);
BitBlt(hdc, 0, 0, rcClient.right, rcClient.bottom,
g_app.hMemDC, 0, 0, SRCCOPY);
}
EndPaint(hWnd, &ps);
}
return 0;
配套还有两个细节:
① WM_ERASEBKGND 直接返回 1
默认情况下,Windows 在重画前会先用窗口背景刷把画面擦一遍(发 WM_ERASEBKGND),然后 WM_PAINT 再画内容——"擦"和"画"两次操作间隔虽然短,但足以被眼睛捕捉到,造成闪烁。既然 PaintScene 第一步就是自己填白底,背景这步就干脆跳过了:
case WM_ERASEBKGND:
return 1; /* 背景交给 WM_PAINT 里的双缓冲 */
② 窗口大小变化时,旧的内存位图尺寸就不对了,必须重建
在 WM_SIZE 里直接释放掉旧缓冲,等下次 WM_PAINT 时按新尺寸重新建:
case WM_SIZE:
ReleaseBackBuffer();
InvalidateRect(hWnd, NULL, FALSE);
return 0;
❗ 还有一个隐蔽的坑
窗口最小化时客户区尺寸是
0×0,CreateCompatibleBitmap会失败,所以EnsureBackBuffer一进来就先判断宽高是否大于 0。
右键菜单
case WM_RBUTTONUP:
{
POINT pt = { GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam) };
ClientToScreen(hWnd, &pt); /* 客户区坐标 → 屏幕坐标 */
ShowContextMenu(hWnd, pt);
}
return 0;
TrackPopupMenu 配合 TPM_RETURNCMD 会阻塞到用户点选并返回命令 ID。注意两点:
- 调用前要先
SetForegroundWindow(hWnd),否则点菜单外面菜单不会自动消失; - 选完后按 MSDN 的建议补发一条
WM_NULL消息收尾。
SetForegroundWindow(hWnd);
uCmd = (UINT)TrackPopupMenu(hMenu,
TPM_RETURNCMD | TPM_RIGHTBUTTON,
ptScreen.x, ptScreen.y, 0, hWnd, NULL);
DestroyMenu(hMenu);
DoCommand(hWnd, uCmd);
PostMessageW(hWnd, WM_NULL, 0, 0);
调试与验证
踩坑记录
🔴 坑 1:undefined reference to WinMain
程序入口用的是宽字符版 wWinMain,GCC 默认去找 WinMain。加 -municode 解决。
🔴 坑 2:undefined reference to '__imp_CreateSolidBrush'
忘了链接 GDI 库。命令行编译要带 -luser32 -lgdi32。
源码里虽然写了
#pragma comment(lib, "gdi32.lib"),但那只对 MSVC 有效,GCC 不认识,所以还是得在命令行里显式指定。
🔴 坑 3:运行时多弹出一个黑色控制台窗口
加 -mwindows(GUI 子系统)。它的反面是调试时 printf 看不到输出,调试期可以先去掉,发布前再加回来。
🔴 坑 4:srand 种子导致每次运行随机序列一样
光用 time(NULL) 做种子,同一秒内反复启动程序会得到一模一样的方块序列。做法是把开机毫秒数也异或进去:
srand((unsigned)time(NULL) ^ (unsigned)GetTickCount());
🔴 坑 5:GDI 对象泄漏
SelectObject 进内存 DC 的位图,退出时必须先 SelectObject 换回去再 DeleteDC / DeleteObject。GDI 对象泄漏多了整个系统都会变卡,而且任务管理器还看不出来。
if (g_app.hMemDC != NULL)
{
if (g_app.hOldBmp != NULL)
SelectObject(g_app.hMemDC, g_app.hOldBmp);
DeleteDC(g_app.hMemDC);
}
if (g_app.hMemBmp != NULL)
DeleteObject(g_app.hMemBmp);
🔴 坑 6:高 DPI 屏幕下方块尺寸不对
在 wWinMain 开头加一句 SetProcessDPIAware(),让 1 个逻辑像素就是屏幕上的 1 个物理像素,否则在开了缩放的显示器上画出来的方块会偏大(定义 _WIN32_WINNT 0x0601 是为了兼容 Windows 7)。
附:Windows 11 圆角会吃掉 25 个像素
测试过程中发现一个有意思的现象:
Windows 11 窗口是圆角的,客户区左下角和右下角各有约 25 个像素会被系统(DWM)裁掉。
证据是空窗口截图里紧贴客户区右下角的那个像素颜色是 RGB(20,20,20)(窗口阴影),而不是白色背景——往里 10 像素才恢复成白色。
所以方块正好贴着右下角时会有 25 个像素显示不出来,那是系统把圆角外的部分裁掉了,程序本身画的是完整的正方形。
效果图片
图 1 带标题栏的整窗图片

图 2 随机散布点击 40 次

图 3 右键弹出菜单

团队分工
| 成员 | 分工 |
|---|---|
| 058 | 整体架构与消息机制 |
| 057 | 界面与绘制:画刷、FillRect、双缓冲 |
| 044 | 随机数与边界处理 |
| 043 | 重绘不丢图、菜单与快捷键 |
| 046 | 程序状态管理与窗口生命周期 |
总结收获
这个实验题目把 Win32 窗口程序设计的核心概念都串起来了:
| 核心概念 | 要点 |
|---|---|
| 消息驱动模型 | WndProc 里一条 switch 处理所有消息,程序不是顺序执行的,而是"事件驱动"的 |
| 重绘机制 | Windows 不替你保存画面,想持久显示就必须自己存数据、响应 WM_PAINT |
| 双缓冲 | 内存 DC 画好再 BitBlt,配合 WM_ERASEBKGND 返回 1,是防闪烁的标准套路 |
| GDI 对象生命周期 | CreateXXX 必须有对应的 DeleteObject,SelectObject 进去的要换回来 |
| 资源管理 | 内存位图要跟着客户区尺寸走,窗口尺寸变了就重建 |
本文为网络协议分析课程实验 2 总结,第 10 组,2026.9.24

浙公网安备 33010602011771号