QT: 什么是事件驱动?
作为此前 QT 笔记的“阑尾”之一,借着近期重返实验课的契机,时隔两年再来谈谈“事件驱动”这一核心概念。
这期间学习了一些其他语言或框架中的特性,也在第六学期结束了软件工程 课程,或许能够用更通俗的方式将这一概念阐述清楚。
几乎任何程序或者软件都可以用 IPO(Input – Process – Output)模型来解释,这也是软工课中 lpj 老师反复强调的基础之一,因此本篇回顾也基于此视角展开叙述。
因为时间间隔太久,加之个人知识面有限,此前及本篇笔记难免出现错误,欢迎大家交流讨论。
Before Event Driven
不妨来回顾一下之前的程序设计是如何处理输入输出的:通过 scanf
、cin
等方式获取输入,然后对数据进行处理,最后再使用 printf
、cout
等方式输出,这些操作均是通过控制台(或称终端、小黑框)完成的。
其输入就是用户在控制台中输入的字符,输出也同样反馈在控制台上。
而程序严格按照从前到后的顺序驱动代码执行,当前调用的函数未返回时就要等待其返回,也就是 OS 中的 Block(阻塞),在程序设计中通常使用“同步”(Sync,Synchronous)来指代这一类程序运行的行为。
这也就是典型的面向过程的程序设计。
如果复杂一点呢?
当程序面临复杂的逻辑,传统的面向过程的程序设计需要花费大量精力维护数据以及处理函数之间的调用,简而言之——耦合太严重了!
例如你可能会遇到下面的情况:
if(cmd == 'help')
{
// Display Help Code
}else if(cmd == 'move')
{
// Move to Next Position Code
}else if(cmd == 'map')
{
// Display Current Map Code
}
如果其中一段 Code 的代码就多达上百行,且不说对逻辑的处理,光是阅读就已是艰巨的任务了。
我们仍坚持 面向过程 的原则,可以简单的将其抽象为:
void displayHelp();
void movePosition();
void displayMap();
if(cmd == 'help')
{
displayHelp();
}else if(cmd == 'move')
{
movePosition();
}else if(cmd == 'map')
{
displayMap();
}
这样至少让我们的清晰地看到主程序的逻辑架构,也就是根据什么命令去执行什么操作。
革新交互方式
程序的数据处理部分暂且抛开,如果将这些“小黑框”程序交付用户,大概他的脸会和屏幕一样黑吧?
这种原始的交互方式自然已经落后时代了,能否设计一种程序,让用户通过键盘按键、鼠标点击就能完成任务呢?
答案当然是肯定的,例如在某些游戏中通过“wasd”可以控制角色移动,通过“Del”显示地图等等,这正是我们需要的效果。
从 IPO 的角度出发,我们的任务就是,将输入的方式从此前的“控制台”改为“直接通过键盘或鼠标”。
因此这里给出一个伪代码:
if Click Help Button:
Display help info.
if Press 'W' or A' or S' or D' Key:
Move to next position.
if Press 'Del' Key:
Display curretn map.
而我们怎么去找到这些新的条件呢?
事件驱动 – 革新输入方式
当我们把比较字符串的条件变为一个具体的“行为”时,就已经逐步走近了事件驱动。
何谓“事件”?在这里就是点击鼠标或者键盘的按键。
而“事件驱动”,则是满足某个“事件”时执行对应的代码,也就是“由事件驱动代码执行”。
在 QT 中我们通过信号与槽来完成这个操作,我们以代码声明组件的方式为例,绑定信号与槽的关键如下:
connect(ui->helpButten, &QPushButton::clicked, this, &Widget::on_helpButten_clicked);
这段代码就是将 helpButten 的 clicked 事件与 on_helpButton_clicked 绑定,可以用伪代码描述为:
if helpButten is clicked:
Display help info.
由此我们就清晰了如何将此前的伪代码转化为事件驱动的形式,进一步的,我们可以将“顺序驱动”的代码按照同样的逻辑修改为事件驱动的形式:
同样的,IPO 中的 I 已经被替换为了“事件”。
面向对象 – 革新输出方式
此时的输出,还是在控制台中进行,想要将其“革新”,还需继续探索。
在引入 QT 时,我们的程序设计就已经从完全的面向过程转化为了面向对象。如 ui->helpButten
&QPushButton::clicked
都是面向对象的语法,窗口、文本框、按钮,都是程序中的一个对象。
如果我们想要点击按钮后能够在我们指定的位置输出结果,或者是将原本“输出文本”转换为“显示图片”,都需要一个载体才可以,这个载体就是程序中的对象。
通过 OOP 的方式,能够对对象的属性进行设置,且可读性较高:
QLabel *infoLabel = new QLabel;
infoLabel->setText("input command:");
上面这段代码自然是将 infoLable
显示的字设置为 "input command:"
。
因此,将输出革新的关键在于:如何合理的添加组件以及为组件设置属性。
组件的属性和方法多如牛毛,因此需要翻阅文档才能找到合适的方法。
至此,IPO 中 O 的输出方式已经被替换为了“操纵对象的属性”
事件驱动的背后
本课程的另一个主线就是 C++ 的 OOP。
聊完了事件驱动的表象,我们不妨进一步的探讨它的一些核心:解耦。
什么是事件驱动?
此前已经了解到了如何完成一个简单的事件驱动的程序,更进一步:
事件不仅限于点击鼠标、按压键盘这一类,某些任务的开始或完成、某些数据的变化也可以看作是事件,也就是“程序中的状态变化”。
所以,我们要设计“事件驱动的程序”的目的,从最初的“革新交互方式”,进一步抽象为了“处理程序中状态变化”。
很多系统或框架都存在事件驱动,例如:
- QT 的 Signal-Slot 机制
- Django 中的 Signal 机制
- JS 对于浏览器事件的处理
在编写逻辑的时候通过“事件驱动”可以大大降低代码的耦合度,让系统变的更易于维护。
如何能够连接
答案就是——订阅-发布模式,也称观察者模式,属于设计模式的一种。
此处留个坑,后续填坑设计模式时再做补充。
面向过程的事件驱动
面向过程同样能实现“事件驱动”,正如我们前面分析的一样,在面向过程中响应事件需要大量的判断逻辑,例如在 C 语言中可以借助 windows API 完成对点击事件的处理,以下是 AI 给出的例子:
#include <windows.h>
LRESULT CALLBACK WndProc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam)
{
switch (msg)
{
case WM_LBUTTONDOWN:
{
// 鼠标左键按下
int x = LOWORD(lParam);
int y = HIWORD(lParam);
char buf[100];
sprintf(buf, "在 (%d, %d) 处点击了鼠标左键!", x, y);
MessageBox(hwnd, buf, "Click Event", MB_OK);
return 0;
}
case WM_DESTROY:
PostQuitMessage(0);
return 0;
}
return DefWindowProc(hwnd, msg, wParam, lParam);
}
int WINAPI WinMain(HINSTANCE hInst, HINSTANCE hPrev, LPSTR lpCmd, int nShow)
{
const char *CLASS_NAME = "MyWindowClass";
WNDCLASS wc = {0};
wc.lpfnWndProc = WndProc;
wc.hInstance = hInst;
wc.lpszClassName = CLASS_NAME;
RegisterClass(&wc);
HWND hwnd = CreateWindowEx(
0, CLASS_NAME, "点击事件示例",
WS_OVERLAPPEDWINDOW, CW_USEDEFAULT, CW_USEDEFAULT,
400, 300, NULL, NULL, hInst, NULL);
ShowWindow(hwnd, nShow);
MSG msg;
while (GetMessage(&msg, NULL, 0, 0))
{
TranslateMessage(&msg);
DispatchMessage(&msg);
}
return 0;
}
除了执行上的区别,两者底层原理也有差异,在 OOP 中通常是基于 订阅-发布 模式,在 C 语言中则是依赖 消息循环,这里不再展开叙述。