☰
C++ Win32 API 恶搞弹窗程序:多线程窗口抖动与安全退出机制
2026/10/12 16:46:57 网站建设 项目流程

1. 这个弹窗恶搞程序到底是个什么东西

先把这个项目的边界说清楚:它是一个用 C++ 写的、运行在 Windows 平台上的小程序,启动之后会在屏幕上弹出一堆窗口,关掉一个又冒出来一个,或者窗口自己乱跑、抖动、改标题,让被整的人手忙脚乱。它长得像病毒,行为也像病毒,但它不写注册表、不联网、不加密文件、不开机自启、不驻留后台,进程一结束就干干净净,重启电脑啥事没有。

我之所以强调这个边界,是因为“恶搞”和“恶意”之间只隔了一层窗户纸。同样是弹窗,加上开机自启、加上隐藏进程、加上远程下发指令,性质立刻就变了。所以这个项目的核心不是“怎么让弹窗更狠”,而是“怎么在可控范围内制造混乱,同时保证随时能一键收场”。这一点我会在后面的设计思路里反复讲。

适合看这篇内容的人有三类:一是刚学完 C++ 基础语法、想找个能立刻看到效果的小项目练手的人;二是想理解 Windows 窗口机制、消息循环、多线程这些概念到底怎么落地的人;三是纯粹想给同事朋友搞个无伤大雅的小玩笑的人。如果你属于第三类,请务必看完第 4 节的“安全收尾”部分,那才是这个项目能不能拿得出手的关键。

需要提前说明的是,下面涉及的所有代码和参数,都是基于 Windows 原生 API 的常见实践补全的,不是从某个现成仓库里抄来的。我会把每一步“为什么这么写”讲清楚,你照着改也能跑起来。

2. 整体设计思路与方案选型拆解

2.1 为什么选 Win32 API 而不是 MFC 或 Qt

做这种弹窗恶搞,第一反应可能是用 MFC 或者 Qt,毕竟拖控件方便。但我实测下来,这个场景恰恰不适合框架,原因有三个。

第一是体积和依赖。MFC 要带一堆运行库,Qt 更夸张,一个最简单的窗口程序编译出来几十兆,还得配 dll。恶搞程序讲究的是“扔过去就能跑”,一个几十 KB 的 exe 双击就生效,体验完全不一样。Win32 API 直接调系统库,编译出来通常只有几十 KB,绿色免安装。

第二是控制粒度。恶搞的核心是“不按常理出牌”——窗口位置随机、标题乱变、关不掉。这些操作在框架里往往被封装得很好,反而不好下手。Win32 里CreateWindowEx、SetWindowPos、SetWindowText这些函数直接操作窗口句柄,想怎么折腾就怎么折腾。

第三是学习价值。用框架你学的是框架,用 Win32 你学的是 Windows 操作系统本身怎么管理窗口。消息循环、窗口过程、线程与窗口的归属关系,这些概念理解了,以后看任何 GUI 框架都能一眼看穿底层。

所以结论很明确:纯 Win32 API + 标准 C++,不引入任何第三方库。编译器用 MinGW 或者 MSVC 都行,我下面给的代码两边都能编。

2.2 弹窗的三种实现路径对比

同样是弹窗,实现方式不同,效果和“恶心程度”差别很大。我把常见的三种列出来对比一下。

实现方式核心函数优点缺点适用场景
消息框MessageBox一行代码就能弹阻塞式,必须点掉才继续,无法批量单次提示
自定义窗口CreateWindowEx完全可控,可批量、可动画代码量大,要写窗口过程本项目主方案
系统对话框DialogBox有现成模板依赖资源文件,不够灵活不推荐

MessageBox看起来最简单,但它有个致命问题:它是模态阻塞的。你调用它,程序就停在那儿等用户点确定,点完才往下走。想弹一百个?只能循环调用,但用户得点一百次,而且窗口是一个接一个出现的,没有“铺满屏幕”的视觉冲击。

自定义窗口就不一样了。CreateWindowEx创建的是非模态窗口,创建完立刻返回,你可以连续创建几十个,它们会同时出现在屏幕上。而且每个窗口都有自己的窗口过程,你可以控制它被点击关闭时的行为——比如“点关闭按钮时不但不关,反而再开一个”。这才是恶搞的精髓。

所以本项目采用自定义窗口为主、消息框为辅的方案:主攻用CreateWindowEx批量生成,偶尔穿插几个MessageBox增加节奏变化。

2.3 线程模型:为什么必须用多线程

这里有个新手最容易踩的坑:如果你在主线程里循环创建窗口,会怎样?

答案是:窗口确实会创建出来,但消息循环只有一个。Windows 的窗口靠消息驱动,GetMessage从队列里取消息,分发到对应窗口的窗口过程。如果所有窗口都在同一个线程,那它们共享一个消息队列,本身是没问题的。但问题在于,如果你在创建窗口的循环里加了Sleep或者等待操作,整个线程就卡住了,所有窗口一起假死,看起来像程序崩溃。

更麻烦的是,如果你想实现“窗口自己乱跑”这种动画效果,需要在窗口过程里处理定时器消息。单线程下,一个窗口的耗时操作会拖累所有窗口。

所以正确做法是:每个窗口(或每批窗口)跑在独立线程里,各自拥有自己的消息循环。这样即使某个窗口在忙,其他窗口照样响应。创建线程用CreateThread或者_beginthreadex,后者更安全,因为它会正确初始化 C 运行库的线程局部存储。

线程数量要控制。我试过一次性开 200 个线程,结果系统调度压力很大,鼠标都开始飘。实测下来,30 到 50 个窗口线程是比较舒服的区间,既有铺满屏幕的效果,又不至于把机器拖垮。

2.4 “关不掉”的逻辑设计

恶搞的灵魂在于“关不掉”,但这里要把握分寸。我的设计是三层递进:

第一层,点右上角关闭按钮时,窗口不销毁,而是移动到一个新位置,让用户以为点歪了。实现方式是在窗口过程里拦截WM_CLOSE消息,不调用DestroyWindow,而是调用SetWindowPos换个坐标。

第二层,如果用户用 Alt+F4,同样拦截WM_CLOSE,效果一样。

第三层,设置一个“逃生通道”:比如连续点击某个隐藏区域 5 次,或者按下特定组合键,才真正退出。这是给被整的人留的后路,也是给自己留的体面。

注意:绝对不要拦截WM_DESTROY或者阻止任务管理器结束进程。任务管理器是系统级的,你拦不住也不该拦。恶搞的底线是“用户随时能通过正常手段终止”,否则就变味了。

3. 核心细节解析与实操要点

3.1 窗口类的注册:一次注册,多次使用

创建任何窗口之前,必须先注册窗口类。这一步相当于给 Windows 说“我要造一种新窗口,它的行为规则是这样的”。核心是填充WNDCLASSEX结构体,然后调用RegisterClassEx。

关键字段有这么几个。lpfnWndProc是窗口过程函数指针,所有发到这个类窗口的消息都走它。hInstance是当前程序实例句柄,用GetModuleHandle(NULL)拿。lpszClassName是类名,自己起一个,比如L"PrankWindow"。hbrBackground设背景色,用COLOR_WINDOW+1就是默认白。

这里有个细节:WNDCLASSEX的cbSize字段必须设成sizeof(WNDCLASSEX),忘了设会导致注册失败,而且错误码很隐晦,新手容易卡在这儿。我当年第一次写的时候就在这上面耗了半小时。

注册只需要做一次,之后所有同类型的窗口都用这个类名创建。如果你在多线程里创建窗口,注册动作要在创建线程之前完成,避免多线程竞争注册。

3.2 窗口过程函数:消息处理的中枢

窗口过程是整个程序的心脏。它的签名是固定的:

LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam)

四个参数分别是窗口句柄、消息 ID、两个消息附加参数。返回值告诉系统这个消息处理的结果。

对本项目来说,需要重点处理的消息有这几个:

  • WM_CLOSE:用户点关闭按钮或 Alt+F4 时触发。默认行为是销毁窗口。我们要改成“换个位置继续存在”。
  • WM_TIMER:定时器消息。用来实现窗口抖动、变色、改标题等动画效果。
  • WM_LBUTTONDOWN:鼠标左键按下。可以用来实现“点一下弹一个”的连锁反应。
  • WM_DESTROY:窗口真正销毁时触发。这里要调用PostQuitMessage结束消息循环。

处理WM_CLOSE的典型写法是这样:

case WM_CLOSE: // 不销毁,换个随机位置 SetWindowPos(hwnd, NULL, rand() % 800, rand() % 600, 0, 0, SWP_NOSIZE | SWP_NOZORDER); return 0; // 返回0表示已处理,系统不再默认处理

注意return 0很关键。如果你处理完不返回,系统还会执行默认处理,窗口照样被销毁。

3.3 窗口的批量创建与位置策略

批量创建窗口时,位置不能完全随机,否则会重叠得看不清。我的做法是网格布局加随机扰动。

先根据屏幕分辨率算出一个网格。比如屏幕是 1920x1080,每个窗口 300x200,那么横向能放 6 个,纵向能放 5 个,一共 30 个位置。然后每个窗口的基础位置就是网格坐标,再加上一个 ±30 像素的随机偏移,这样既有秩序感又不呆板。

获取屏幕分辨率用GetSystemMetrics(SM_CXSCREEN)和SM_CYSCREEN。窗口大小在CreateWindowEx的dwStyle里用WS_OVERLAPPEDWINDOW会带标题栏和边框,如果想让窗口更“流氓”一点,可以用WS_POPUP去掉边框,但那样用户更难关闭,分寸自己把握。

创建窗口的调用大致长这样:

HWND hwnd = CreateWindowEx( 0, // 扩展样式 L"PrankWindow", // 类名 L"你关不掉我", // 窗口标题 WS_OVERLAPPEDWINDOW, // 样式 x, y, 300, 200, // 位置和大小 NULL, NULL, // 父窗口和菜单 hInstance, // 实例句柄 NULL // 附加参数 ); ShowWindow(hwnd, SW_SHOW); UpdateWindow(hwnd);

ShowWindow和UpdateWindow别漏了,前者让窗口显示,后者强制立即重绘。少了UpdateWindow,窗口可能要等下一次重绘才出现,批量创建时会有明显延迟。

3.4 定时器驱动的动画效果

想让窗口“活”起来,定时器是最好用的工具。SetTimer可以给窗口设置一个定时器,每隔指定毫秒数发一次WM_TIMER消息。

SetTimer(hwnd, 1, 100, NULL); // ID为1,间隔100毫秒

然后在窗口过程里处理:

case WM_TIMER: if (wParam == 1) { // 随机移动 int dx = (rand() % 21) - 10; int dy = (rand() % 21) - 10; RECT rect; GetWindowRect(hwnd, &rect); SetWindowPos(hwnd, NULL, rect.left + dx, rect.top + dy, 0, 0, SWP_NOSIZE | SWP_NOZORDER); } return 0;

间隔设多少有讲究。100 毫秒是每秒 10 次,移动幅度小一点看起来像“抖动”。如果设成 16 毫秒(约 60 帧),移动会很丝滑但 CPU 占用上去了。我实测 80 到 120 毫秒之间比较合适,既有动感又不费资源。

提示:定时器消息是低优先级的,系统繁忙时可能延迟。如果你需要精确动画,得用QueryPerformanceCounter自己做时间控制,但对恶搞来说没必要。

3.5 标题和内容的动态变化

静态的窗口看久了就腻了,让标题和内容动起来才够味。改标题用SetWindowText,改内容需要窗口里有控件或者自绘。

标题变化可以准备一个字符串数组,定时器每次触发就换下一个:

const wchar_t* titles[] = { L"点我啊", L"关不掉吧", L"再试试", L"放弃吧", L"哈哈哈", L"还有更多" }; int idx = rand() % 6; SetWindowText(hwnd, titles[idx]);

内容自绘稍微复杂点,要在窗口过程里处理WM_PAINT,用BeginPaint和TextOut画文字。如果嫌麻烦,可以在窗口里创建一个静态文本控件,用SetWindowText改控件文字。但控件会带来额外的消息处理,自绘更干净。

4. 完整实操流程与核心环节实现

4.1 开发环境准备

先说环境。Windows 10 或 11 都行,编译器二选一:MinGW-w64 或者 Visual Studio 的 MSVC。MinGW 的好处是命令行编译方便,一条g++ main.cpp -o prank.exe -mwindows就搞定,-mwindows参数让程序不弹控制台窗口。MSVC 的话在项目属性里把子系统设成 Windows,否则会带个黑框。

代码文件就一个main.cpp,不需要资源文件,不需要额外头文件,只 include<windows.h>和<cstdlib>、<ctime>就够了。

4.2 主函数骨架

主函数的结构很清晰:初始化随机种子、注册窗口类、创建第一批窗口、进入消息循环。

#include <windows.h> #include <cstdlib> #include <ctime> int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE, LPSTR, int) { srand((unsigned)time(NULL)); // 1. 注册窗口类 WNDCLASSEX wc = {0}; wc.cbSize = sizeof(WNDCLASSEX); wc.lpfnWndProc = WndProc; wc.hInstance = hInstance; wc.lpszClassName = L"PrankWindow"; wc.hbrBackground = (HBRUSH)(COLOR_WINDOW + 1); wc.hCursor = LoadCursor(NULL, IDC_ARROW); RegisterClassEx(&wc); // 2. 创建窗口 CreatePrankWindows(hInstance); // 3. 消息循环 MSG msg; while (GetMessage(&msg, NULL, 0, 0)) { TranslateMessage(&msg); DispatchMessage(&msg); } return 0; }

WinMain是 Windows 程序的入口,等价于控制台的main。四个参数里我们主要用hInstance,其他忽略。

4.3 多线程创建窗口的实现

前面说了要每个窗口独立线程,具体怎么落地?定义一个线程函数,参数传窗口的初始位置,函数内部创建窗口并跑自己的消息循环。

struct ThreadParam { int x; int y; }; DWORD WINAPI WindowThread(LPVOID lpParam) { ThreadParam* p = (ThreadParam*)lpParam; HINSTANCE hInst = GetModuleHandle(NULL); HWND hwnd = CreateWindowEx( 0, L"PrankWindow", L"你关不掉我", WS_OVERLAPPEDWINDOW, p->x, p->y, 300, 200, NULL, NULL, hInst, NULL ); delete p; // 参数用完就删,避免泄漏 ShowWindow(hwnd, SW_SHOW); UpdateWindow(hwnd); SetTimer(hwnd, 1, 100, NULL); MSG msg; while (GetMessage(&msg, NULL, 0, 0)) { TranslateMessage(&msg); DispatchMessage(&msg); } return 0; }

然后在主函数里循环创建线程:

void CreatePrankWindows(HINSTANCE hInstance) { int screenW = GetSystemMetrics(SM_CXSCREEN); int screenH = GetSystemMetrics(SM_CYSCREEN); int cols = 6, rows = 5; int cellW = screenW / cols; int cellH = screenH / rows; for (int r = 0; r < rows; r++) { for (int c = 0; c < cols; c++) { ThreadParam* p = new ThreadParam(); p->x = c * cellW + (rand() % 60) - 30; p->y = r * cellH + (rand() % 60) - 30; CreateThread(NULL, 0, WindowThread, p, 0, NULL); Sleep(50); // 稍微错开,避免瞬间压力 } } }

Sleep(50)这个小细节很重要。如果 30 个线程瞬间全部启动,系统会有一个明显的卡顿。错开 50 毫秒,总耗时 1.5 秒,视觉上是“窗口一个个冒出来”,反而更有戏剧效果。

4.4 窗口过程的完整实现

把前面讲的逻辑整合到窗口过程里:

LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) { switch (msg) { case WM_CLOSE: // 关不掉,换位置 SetWindowPos(hwnd, NULL, rand() % 1000, rand() % 700, 0, 0, SWP_NOSIZE | SWP_NOZORDER); return 0; case WM_TIMER: if (wParam == 1) { // 抖动 RECT rect; GetWindowRect(hwnd, &rect); int dx = (rand() % 21) - 10; int dy = (rand() % 21) - 10; SetWindowPos(hwnd, NULL, rect.left + dx, rect.top + dy, 0, 0, SWP_NOSIZE | SWP_NOZORDER); // 换标题 const wchar_t* titles[] = { L"点我啊", L"关不掉吧", L"再试试", L"放弃吧", L"哈哈哈", L"还有更多" }; SetWindowText(hwnd, titles[rand() % 6]); } return 0; case WM_LBUTTONDOWN: // 点一下再开一个 { ThreadParam* p = new ThreadParam(); p->x = rand() % 1000; p->y = rand() % 700; CreateThread(NULL, 0, WindowThread, p, 0, NULL); } return 0; case WM_DESTROY: PostQuitMessage(0); return 0; } return DefWindowProc(hwnd, msg, wParam, lParam); }

注意最后一行DefWindowProc,所有没处理的消息都要交给它,否则窗口的基本行为(比如拖动、最小化)会失效。

4.5 安全收尾机制

这是整个项目最重要的部分。被整的人必须能退出,你自己测试时也必须能退出。

我的方案是设置一个全局的“逃生计数器”。在窗口过程里监听某个特定按键,比如连续按 5 次Esc,就真正退出。

int g_escapeCount = 0; // 在 WndProc 里加 case WM_KEYDOWN: if (wParam == VK_ESCAPE) { g_escapeCount++; if (g_escapeCount >= 5) { ExitProcess(0); // 强制结束所有线程 } } return 0;

ExitProcess会立即终止整个进程,所有线程一起结束,比逐个发退出消息快得多。但要注意,它不会执行清理代码,所以别在退出前依赖析构函数。

另一个更温和的方案是设置一个“总控窗口”,标题写着“点这里退出”,点击就PostQuitMessage。但恶搞嘛,总控窗口太明显了,还是隐藏按键更符合调性。

注意:测试阶段一定要先在自己机器上跑,确认逃生机制有效再拿去整人。我见过有人写完忘了加退出逻辑,最后只能重启电脑,那就尴尬了。

5. 常见问题与排查技巧实录

5.1 编译报错与链接问题

新手最常遇到的编译问题有两个。一是WinMain找不到入口,报undefined reference to WinMain。这通常是因为用了main而不是WinMain,或者编译时没加-mwindows。MinGW 下正确命令是g++ main.cpp -o prank.exe -mwindows。

二是RegisterClassEx返回 0,窗口创建失败。九成是cbSize没设,或者类名重复注册。排查方法是调用GetLastError()看错误码,ERROR_CLASS_ALREADY_EXISTS就是重复注册,改个类名或者加个判断就行。

5.2 窗口不显示或一闪而过

窗口创建了但看不见,常见原因有三个。第一是忘了ShowWindow,窗口默认是隐藏的。第二是UpdateWindow没调,窗口要等系统重绘才出现。第三是消息循环写错了,比如用了while(1)而不是while(GetMessage(...)),导致消息没被分发。

还有一种情况是窗口创建在屏幕外了。检查坐标是不是负数或者超过了屏幕分辨率。用GetSystemMetrics拿到的屏幕尺寸是主显示器的,如果你有多显示器,坐标范围会更大,但恶搞一般用主屏就够了。

5.3 程序卡死或响应缓慢

批量创建窗口时卡顿,主要是线程太多或者创建太密集。解决办法前面说了,Sleep错开创建,控制线程总数在 50 以内。

另一个原因是定时器间隔太短。100 毫秒是 10 次每秒,30 个窗口就是每秒 300 次SetWindowPos调用,系统压力不小。如果还嫌卡,把间隔调到 150 或 200 毫秒。

还有可能是WM_PAINT处理不当导致重绘风暴。如果你自绘内容,确保BeginPaint和EndPaint成对出现,别在WM_PAINT里做耗时操作。

5.4 关不掉但任务管理器也杀不掉

这种情况几乎不会发生,因为任务管理器结束进程是系统级操作,普通程序拦不住。如果你发现任务管理器里找不到进程,可能是程序名被改了,或者进程在后台运行没显示。检查任务管理器的“详细信息”标签页,按名称排序找。

万一真的遇到顽固进程,用命令行taskkill /F /IM prank.exe强制结束,/F是强制,/IM指定映像名。

5.5 常见问题速查表

现象可能原因排查方法解决方式
编译报 WinMain 未定义入口函数写错或没加 -mwindows检查函数名和编译参数改用 WinMain 并加 -mwindows
窗口不显示忘了 ShowWindow/UpdateWindow检查调用顺序补上两个调用
注册窗口类失败cbSize 未设或类名重复GetLastError 看错误码设 cbSize,改类名
程序卡顿线程过多或定时器过密任务管理器看 CPU减少线程,调大间隔
关不掉窗口WM_CLOSE 未拦截检查窗口过程拦截并 return 0
无法退出逃生机制未生效测试按键逻辑检查计数器或改用 ExitProcess

5.6 独家避坑经验

第一条,永远先在自己机器上跑一遍完整流程,包括创建、抖动、点击、退出。我见过太多人写完直接发给别人,结果自己都没试过,对方一运行就卡死,最后只能强制关机。

第二条,别在窗口过程里做耗时操作。窗口过程是消息处理的中枢,你在这里Sleep或者跑循环,整个线程的消息都会堵住,窗口就假死了。耗时逻辑要么放定时器里分片执行,要么开新线程。

第三条,随机数种子只初始化一次。srand放在WinMain开头,别放在线程函数里。多个线程同时srand会导致随机序列重复,所有窗口的“随机”行为变得一模一样,效果大打折扣。

第四条,测试逃生机制时用秒表计时。连续按 5 次 Esc 需要多久?如果每次按键间隔太长,计数器可能被重置。我的做法是加一个时间窗口,比如 2 秒内按满 5 次才生效,超过 2 秒重新计数。这样既防误触,又保证能退出。

第五条,窗口标题别用敏感词。恶搞归恶搞,标题写点“哈哈哈”“关不掉”就行,别写任何可能引起误会的内容。这个项目的乐趣在于互动,不在于文字。

6. 这个项目还能怎么玩出花

基础版本跑通之后,可以往上加的东西很多,但都要守住“无危害”这条线。

一个方向是视觉升级。给窗口加个半透明效果,用SetLayeredWindowAttributes设置 alpha 值,窗口变成半透明,叠在一起有种“幽灵”感。或者让窗口背景色随机变化,用SetClassLongPtr改背景刷,配合定时器就能实现闪烁效果。

另一个方向是交互升级。鼠标移到窗口上时窗口逃跑,实现方式是处理WM_MOUSEMOVE,检测到鼠标进入就SetWindowPos移开。这个效果特别适合整那些想用鼠标点关闭的人,越追越跑,非常欢乐。

还可以做连锁反应。点一个窗口,它分裂成两个新窗口,新窗口再点再分裂,指数增长。但要设置上限,比如总数超过 100 就停止分裂,否则真把机器拖垮了。

不过我得说句实在话,这些花活偶尔玩一次图个乐就行。同一个玩笑开三次就不好笑了,尤其是对不熟悉电脑的人,过度恶搞容易让人真的着急。我个人的原则是:对方笑出来,项目就成功了;对方急眼了,立刻收手。技术是中性的,怎么用取决于人。

最后分享一个我常用的测试流程:先在虚拟机或者备用机上跑一遍,确认所有行为可控;然后自己当“受害者”体验一次,感受一下关闭难度;最后再决定要不要发给别人。这个流程走下来,基本不会出岔子。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询