Windows API 窗口绘图程序设计-实验二

Win32 窗口程序设计实验总结 · 鼠标绘制随机红色正方形

课程:网络协议分析
实验:实验 2 —— Windows API 窗口绘图程序设计
小组:第 10 组 | 日期:2026.9.24

用 Win32 API 实现"在窗口内任意位置按下鼠标左键,绘制边长 10~100 随机的红色正方形",围绕 数据与绘制分离、双缓冲防闪烁 两个核心设计展开。

Windows
Language
Toolchain
API


目录


实验概览

这次实验把 消息机制 + 窗口过程 + GDI 绘制 + 数据持久化 串了一遍。题目四个要求:

  1. 设计一个简单的 Windows 窗口程序;
  2. 在程序窗口内的任意位置按下鼠标左键,即可绘制一个正方形;
  3. 正方形的边长取 10~100 像素之间的随机值;
  4. 正方形用红色填充。

逐条对照完成情况:

题目要求 程序实现
设计一个简单的 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。注意两点:

  1. 调用前要先 SetForegroundWindow(hWnd),否则点菜单外面菜单不会自动消失;
  2. 选完后按 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 带标题栏的整窗图片

图1 带标题栏的整窗图片

图 2 随机散布点击 40 次

图2 随机散布点击40次

图 3 右键弹出菜单

图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

posted @ 2026-09-25 01:39  Jbuckle  阅读(26)  评论(0)    收藏  举报