99、实战:GUI框架(Win32/Qt基础+事件循环)

GUI编程,说白了就是让程序跟用户「面对面」交流。你写个控制台程序,用户只能敲命令;但有了GUI,用户点个按钮、拖个滑块,程序就能响应。这件事背后的核心机制,就是事件循环。

我最早接触GUI是在大学,用Win32 API写了个计算器。那时候不懂事件循环,代码写得跟屎山一样——用户点一下按钮,界面就卡死。后来才明白,嗯,事件循环没处理好。

什么是事件循环?

事件循环,你可以把它想象成一个「邮局」。用户的操作(鼠标点击、键盘输入、窗口移动)就是寄来的信件。程序不断检查有没有新信件,有就拆开处理,处理完继续等下一封。

伪代码长这样:

while (GetMessage(&msg, nullptr, 0, 0))
{
    TranslateMessage(&msg);
    DispatchMessage(&msg);
}

这段代码就是Win32事件循环的骨架。GetMessage从消息队列里取消息,如果取到WM_QUIT就返回false,循环结束。TranslateMessage负责把键盘消息翻译成字符消息,DispatchMessage把消息分发给对应的窗口过程函数。

核心要点:事件循环不是「轮询」,而是「阻塞等待」。没有消息时,线程挂起,不占CPU。这是Windows消息驱动的精髓。

Win32 GUI:从零搭建一个窗口

我个人习惯用Win32做底层教学,因为它让你看到GUI的「裸体」——没有框架帮你封装,每一步都是操作系统API的直接调用。

创建一个窗口,需要这几步:

  1. 注册窗口类(RegisterClassEx)
  2. 创建窗口(CreateWindowEx)
  3. 显示窗口(ShowWindow)
  4. 进入消息循环(GetMessage循环)
  5. 在窗口过程函数里处理消息

看个完整的例子:

#include <windows.h>

LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam)
{
    switch (msg)
    {
    case WM_DESTROY:
        PostQuitMessage(0);
        return 0;
    case WM_PAINT:
    {
        PAINTSTRUCT ps;
        HDC hdc = BeginPaint(hwnd, &ps);
        TextOut(hdc, 10, 10, L"Hello, Win32 GUI!", 17);
        EndPaint(hwnd, &ps);
        return 0;
    }
    }
    return DefWindowProc(hwnd, msg, wParam, lParam);
}

int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance,
                   LPSTR lpCmdLine, int nCmdShow)
{
    const wchar_t CLASS_NAME[] = L"SampleWindowClass";

    WNDCLASS wc = {};
    wc.lpfnWndProc = WndProc;
    wc.hInstance = hInstance;
    wc.lpszClassName = CLASS_NAME;

    RegisterClass(&wc);

    HWND hwnd = CreateWindowEx(
        0, CLASS_NAME, L"我的第一个窗口",
        WS_OVERLAPPEDWINDOW,
        CW_USEDEFAULT, CW_USEDEFAULT,
        800, 600,
        nullptr, nullptr, hInstance, nullptr
    );

    if (!hwnd) return 0;

    ShowWindow(hwnd, nCmdShow);

    MSG msg = {};
    while (GetMessage(&msg, nullptr, 0, 0))
    {
        TranslateMessage(&msg);
        DispatchMessage(&msg);
    }

    return 0;
}

这段代码我当年调试了整整一下午。为什么?因为忘记调用RegisterClass了,窗口死活创建不出来。你想想看,这种低级错误,现在看觉得可笑,但当时就是找不到原因。

注意:Win32的窗口过程函数是回调函数,由操作系统在消息分发时调用。不要在窗口过程里做耗时操作,否则界面会卡死。我曾经在一个WM_PAINT处理里做了文件读写,结果窗口拖拽时一卡一卡的,用户体验极差。

Qt的事件循环:更优雅的封装

Qt把事件循环封装得更高级。你不需要手动写GetMessage循环,QApplication::exec()帮你搞定一切。

Qt的事件循环底层其实还是调用了操作系统的消息机制(Windows上就是GetMessage),但上层做了信号与槽的抽象。你想想看,这比Win32的switch-case优雅了多少?

#include <QApplication>
#include <QPushButton>

int main(int argc, char *argv[])
{
    QApplication app(argc, argv);

    QPushButton button("点击我");
    QObject::connect(&button, &QPushButton::clicked, [&]() {
        button.setText("被点击了!");
    });
    button.show();

    return app.exec();
}

app.exec()就是Qt的事件循环入口。它会一直运行,直到你调用QApplication::quit()或者窗口被关闭。

小技巧:在Qt中,如果你需要执行耗时操作,千万别放在主线程的事件循环里。用QThread或者QtConcurrent跑异步任务,否则界面会「假死」。我有个项目就是因为在按钮点击事件里直接做了网络请求,结果用户点一下按钮,界面卡了5秒,用户以为程序崩溃了。

事件循环的底层对比

特性 Win32 Qt
消息获取 GetMessage QEventLoop::processEvents
消息分发 DispatchMessage QCoreApplication::notify
自定义消息 WM_USER + n QEvent子类 + 事件过滤器
异步处理 PostMessage QTimer / QMetaObject::invokeMethod
线程安全 SendMessage/PostMessage 信号槽跨线程(队列连接)

这张表是我在实际项目中总结出来的。Win32的SendMessage是同步的,会阻塞直到消息被处理;PostMessage是异步的,把消息丢进队列就返回。Qt的信号槽默认是同步调用(直接连接),跨线程时自动切换为队列连接。

事件循环的常见陷阱

我遇到过不少坑,挑几个典型的说说:

  • 嵌套事件循环:在消息处理里再开一个事件循环(比如QDialog::exec()),会导致外层循环暂停。这在某些场景下是需要的,但滥用会让程序逻辑混乱。
  • 消息积压:如果某个消息处理太慢,后续消息会排队等待。Win32的消息队列默认能存10000条消息,但UI响应会越来越迟钝。
  • 跨线程发消息:Win32里跨线程发消息要小心,PostMessage是线程安全的,但SendMessage如果目标线程卡死,当前线程也会卡死。

避坑指南:我曾经在Win32项目里用SendMessage从工作线程向主线程发消息,结果工作线程里有个死循环,主线程被阻塞,整个界面卡死。后来改成PostMessage + 自定义消息,问题解决。记住:跨线程通信,能用异步就别用同步。

知识体系结构图

下面这张图展示了GUI框架的核心逻辑:

GUI框架核心逻辑 用户操作 操作系统消息队列 事件循环(GetMessage / exec) 窗口过程(Win32) / 信号槽(Qt) 流程:用户操作 → 消息队列 → 事件循环 → 消息分发 → 处理函数

这张图把整个流程串起来了。用户点鼠标、敲键盘,操作系统把这些事件塞进消息队列。事件循环不断从队列里取消息,分发给对应的处理函数。Win32里是窗口过程,Qt里是信号槽。说白了,万变不离其宗。

总结

GUI编程的核心就是事件循环。Win32让你看到底层细节,Qt让你写得更快。我个人建议初学者先用Win32写一个简单窗口,感受一下消息是怎么来的、怎么走的。然后再用Qt做实际项目,效率会高很多。

嗯,最后提醒一句:事件循环里别做耗时操作。这个坑我踩过,你也别踩。


公众号:蓝海资料掘金营,微信deep3321