🔥 本文专栏:Qt
🌸作者主页:努力努力再努力wz
💪今日博客励志语录:
你现在写下的每一篇博客、敲下的每一行代码,可能不会立刻改变结果,但它们都在悄悄抬高你的下限。
★★★本文前置知识:
信号与槽(上)
思维导图
引入
在之前的学习中,我们已经认识了 Qt 中信号与槽机制的基本作用。此前编写的 Qt 程序,最终呈现出来的主要是一个静态的页面结构:页面上有哪些组件、组件位于什么位置、组件具有什么初始属性,这些内容决定了界面的基本外观。
但是,一个真正的 GUI 程序并不只是“显示一个页面”,更重要的是能够对用户操作作出响应。也就是说,当用户点击按钮、输入文本、移动鼠标或触发其他操作时,程序需要根据这些操作执行对应的逻辑。要让 Qt 页面具备这种动态交互能力,就需要借助信号与槽机制。
从整体流程上看,用户的一次操作并不是直接调用某个槽函数。以鼠标点击为例,当用户按下鼠标左键时,底层硬件会产生对应的输入信号,相关硬件控制器会通过中断机制通知 CPU。CPU 响应中断后进入内核态,由操作系统中的设备驱动读取输入数据,并将其交给输入子系统进行统一整理,最终形成操作系统能够识别的输入事件。
随后,操作系统的窗口系统会根据鼠标坐标、窗口位置等信息,将该输入事件分发给对应的应用程序窗口。对于 Qt 程序来说,Qt 的事件循环会接收这些来自操作系统的事件,并将其进一步封装为 Qt 自身的事件对象,例如鼠标事件、键盘事件等。接着,Qt 会根据事件发生的位置,将事件分发给具体的窗口组件。
需要注意的是,组件接收到事件之后,并不意味着一定会立刻发射信号。事件更偏向于底层输入通知,而信号则是组件在处理事件之后抽象出来的高级语义。例如,对于一个QPushButton来说,Qt 不只是简单地接收一次鼠标按下事件,而是会综合判断鼠标按下、鼠标释放、鼠标位置等信息,确认这是否构成一次有效的按钮点击。只有当这个操作满足按钮点击的语义时,QPushButton才会发射clicked()信号。
当信号被发射之后,Qt 会根据此前通过connect()建立的连接关系,调用对应的槽函数。槽函数中的代码才是真正用于完成业务逻辑或界面响应的部分,例如修改文本、关闭窗口、切换页面、更新数据等。
因此,可以将整个过程理解为:
用户操作 ↓ 操作系统生成输入事件 ↓ 窗口系统分发事件 ↓ Qt 事件循环接收并派发事件 ↓ 具体组件处理事件 ↓ 组件在合适时机发射信号 ↓ 信号触发已连接的槽函数 ↓ 执行具体的交互逻辑所以,静态页面结构只是描述了“界面长什么样”,而信号与槽机制则进一步描述了“用户操作发生之后,程序应该做什么”。前面我们已经认识了信号与槽的基本机制,接下来本文将继续补充信号与槽相关的一些细节。
Qt 信号与槽进阶:自定义信号、connect()与连接管理
Qt 信号与槽进阶:从元对象系统到自定义信号机制
在此前的学习中,我们已经知道,C++ 本身的运行时反射能力相对有限。当我们定义一个普通 C++ 类之后,程序在运行期间并不能方便地获取这个类的结构信息,例如类名、父类信息、有哪些可调用方法、成员函数的函数名、参数个数、参数类型以及参数顺序等。
但是,Qt 的信号与槽机制需要在运行时根据对象之间的连接关系完成函数调用。如果没有额外的类型描述信息,Qt 就很难在运行时识别某个对象拥有哪些信号、哪些槽函数,以及这些函数的参数信息。为了解决这个问题,Qt 在标准 C++ 之外提供了一套元对象系统。
当一个类继承自QObject,并且在类定义中添加Q_OBJECT宏之后,Qt 的元对象编译器moc会在编译阶段扫描这个类,并为它生成额外的元对象代码。这部分代码会保存该类的元信息,例如类名、父类元对象、信号列表、槽函数列表、属性信息、可调用方法以及方法参数等。随后,moc生成的代码会和其他 C++ 源文件一起参与编译,最终进入程序。
这里需要进一步区分一个容易混淆的点:Qt 对象的运行时数据和moc生成的类级别元对象信息并不是同一类东西。
对于每一个QObject对象实例来说,Qt 内部会通过类似d_ptr的私有指针维护一份对象级别的运行时数据。这份数据主要描述“当前这个对象的运行期状态”,例如父对象指针、子对象列表、对象名称、线程归属、动态属性以及信号与槽的连接记录等。
而moc生成的QMetaObject更偏向于描述“这个类本身具有什么能力”。它记录的是类级别的信息,例如这个类叫什么、它的父类元对象是谁、它声明了哪些信号、哪些槽函数、有哪些属性、这些方法的参数列表是什么等。
为了便于理解,可以用下面这段简化代码来模拟它们之间的关系。需要注意的是,这段代码不是 Qt 源码,而是帮助理解的教学模型:
classQObjectPrivate{public:QObject*parent;// 父对象指针std::vector<QObject*>children;// 子对象列表std::string objectName;// 对象名称// 这里用伪代码表示信号与槽连接关系// 可以理解为:某个信号 -> 对应的一组槽函数连接记录std::unordered_map<int,std::vector<Connection>>connections;};classQMetaObject{public:constchar*className;// 类名constQMetaObject*superMetaObject;// 父类元对象std::vector<MethodInfo>signalList;// 信号列表std::vector<MethodInfo>slots;// 槽函数列表std::vector<PropertyInfo>properties;// 属性列表};classQObject{private:QObjectPrivate*d_ptr;// 对象级别的运行时数据public:virtualconstQMetaObject*metaObject()const;};因此,可以将这里的结构理解为三层:
QObject 对象实例 ↓ 对象级别运行时数据 - parent 指针 - children 列表 - objectName - 线程归属 - 动态属性 - 信号与槽连接记录 类级别元对象信息 QMetaObject - 类名 - 父类元对象 - 信号列表 - 槽函数 / 可调用方法列表 - 属性列表 - 参数类型、参数个数、参数顺序 moc 生成的辅助代码 - 生成 staticMetaObject - 提供 metaObject() 等元对象访问能力 - 生成信号函数相关代码 - 让信号发射进入 Qt 的信号激活流程也就是说,父对象和子对象关系属于对象实例之间的运行时关系,而父类元对象属于类和类之间的继承关系。前者描述的是“这个对象挂在哪个对象下面”,后者描述的是“这个类继承自哪个类”。
在早期的 Qt 代码中,我们经常会看到signals和slots这样的标记。它们并不是普通 C++ 语法中的关键字,而是 Qt 为元对象系统提供的扩展标记。signals用来声明信号函数,slots用来声明槽函数,moc会根据这些标记生成或补充对应的元对象信息。
不过,在新版 Qt 中,slots的使用频率已经没有过去那么高。一个重要原因是,新版connect()本身是模版函数。我们可以直接将信号函数指针和槽函数指针作为参数传递给connect(),编译器会在编译期根据这些实参推导出对应的函数类型,并检查信号和槽的参数是否匹配。因此,很多普通成员函数即使没有写在 slots 区域中,也可以作为槽函数被连接使用。
不过需要注意的是,signals 标记并没有被弱化。信号函数的实现完全由 moc 生成,开发者只写声明,因此信号必须声明在signals:区域下,moc 才会为它生成对应的函数体。也就是说,"槽端可以是普通成员函数"和"信号必须在 signals 区域声明"这两件事并不矛盾。
但是需要注意的是,槽函数的函数体定义仍然需要开发者自己编写。因为槽函数真正描述的是程序的业务逻辑,比如点击按钮之后要修改文本、切换界面等。这些逻辑由具体业务决定,Qt 不可能替开发者自动生成。
而信号函数则有所不同。信号函数虽然也可以由开发者自定义,但开发者通常只需要写出信号函数的声明,而不需要自己编写信号函数的函数体。因为信号函数内部的执行逻辑相对固定,它的作用不是完成具体业务,而是让当前信号进入 Qt 的信号激活流程。Qt 会根据当前发送者对象、具体信号以及运行时已经建立好的连接记录,找到对应的接收者和槽函数,并触发它们。
从逻辑上看,信号与槽的连接关系可以近似理解为一种运行时维护的映射关系。当我们调用connect()时,Qt 会在运行时建立一条连接记录。例如:
connect(button,&QPushButton::clicked,this,&Widget::handleLogin);这条语句可以理解为:将button对象的clicked()信号和当前对象的handleLogin()槽函数连接起来。后续当button发射clicked()信号时,Qt 就会根据这条连接记录找到并调用对应的槽函数。
这个过程可以用下面的流程表示:
button 发射 clicked() 信号 ↓ Qt 确认当前发射的是 clicked() 信号 ↓ 查找 button 对象上 clicked() 信号对应的连接记录 ↓ 发现它连接到了 this 对象的 handleLogin() 槽函数 ↓ 调用 this->handleLogin()因此,moc生成的代码大致可以理解为两类:一类是类级别的元对象信息,用来描述类名、父类、信号、槽函数、属性和方法参数等;另一类是与信号发射相关的辅助代码,用来让信号函数进入 Qt 的信号激活流程。
不过,这里还需要进一步区分内置信号和自定义信号的使用场景。
对于QPushButton这类 Qt 内置组件来说,Qt 已经为常见的用户操作提供了现成的信号。这里需要进一步说明的是,Qt 内置组件中的信号,通常并不是底层输入事件的一比一映射。比如用户点击按钮时,底层可能会经历鼠标按下、鼠标释放、鼠标位置判断等多个事件过程。但是对于开发者来说,我们通常并不需要直接关心这些零散的底层事件细节,而是更关心这些事件最终能否构成一次有效的用户操作。
以QPushButton为例,用户按下鼠标并不一定就代表按钮被成功点击。只有当鼠标按下、鼠标释放以及释放位置等条件共同满足一次有效点击的要求时,按钮才会发射clicked()信号。也就是说,clicked()并不是简单等同于某一次鼠标按下事件,而是QPushButton在处理一系列底层输入事件,并结合自身状态判断之后,抽象出来的一个具有明确用户交互含义的信号,它表达的是“按钮被成功点击”这一操作结果。
因此,对于 Qt 内置组件来说,Qt 并不是简单地把某个底层输入事件直接包装成信号,而是由组件先接收和处理底层事件,再根据这些事件是否满足特定的用户操作含义,在合适的时机发射对应信号。开发者只需要通过connect()将这些信号和自己的槽函数连接起来,就可以在用户完成某类操作时执行指定的交互逻辑。
但是,自定义信号通常并不是为了描述底层输入事件,而是为了描述更上层的业务语义。也就是说,自定义信号表达的往往不是“鼠标点击了哪里”,而是“某个业务状态发生了变化”。
例如,在一个用户登录界面中,界面中可能包含用户名输入框、密码输入框和登录按钮。当用户点击登录按钮时,底层鼠标事件会先被操作系统接收,再交给 Qt 程序。Qt 程序会将事件分发给对应的QPushButton,按钮在确认这是一次有效点击之后,会发射clicked()信号。
随后,clicked()信号会触发已经连接好的槽函数,例如登录处理函数。在这个槽函数中,程序可以获取用户输入的用户名和密码,并将其封装为请求报文,通过网络发送给服务端。对于客户端程序来说,它主要负责界面展示、用户交互以及请求发送,真正的登录认证逻辑通常不应该放在客户端完成,而应该交给服务端处理。服务端收到请求后,可以查询 Redis、数据库或其他认证系统,并将认证结果返回给客户端。
客户端收到服务端返回的结果之后,就可以根据结果执行不同的界面逻辑。如果登录成功,程序可以切换到主界面;如果登录失败,程序可以在当前页面显示错误提示、清空密码输入框或者恢复登录按钮状态。
此时,我们就可以定义更加贴近业务语义的自定义信号,例如:
signals:voidloginSuccess();voidloginFailed(QString reason);当服务端返回登录成功之后,登录处理逻辑只需要发射loginSuccess()信号;当服务端返回登录失败之后,登录处理逻辑只需要发射loginFailed(QString reason)信号。至于这些信号最终会触发哪个槽函数,则由此前通过connect()建立的连接关系决定。
整个登录流程可以理解为:
用户点击登录按钮 ↓ QPushButton 判断这是一次有效点击 ↓ 发射 clicked() 信号 ↓ 调用登录处理槽函数 handleLogin() ↓ 获取用户名和密码 ↓ 封装请求报文并发送给服务端 ↓ 服务端进行登录认证 ↓ 服务端返回登录结果 ↓ 客户端判断登录结果 ┌───────┴───────┐ │ │ 登录成功 登录失败 │ │ 发射 发射 loginSuccess() loginFailed(QString reason) │ │ 触发界面切换槽函数 触发错误提示槽函数 │ │ 切换到主界面 显示错误信息 / 清空密码框 / 保持登录页这里的好处在于,程序不需要在登录处理逻辑中直接调用某个具体的界面切换函数或界面更新函数。登录处理逻辑只需要在认证结果确定之后,发射一个明确的业务信号即可。
也就是说,在调用connect()时,我们已经提前将某个信号和对应的槽函数绑定起来了。一旦认证成功并发射loginSuccess()信号,Qt 就会根据这条连接关系自动调用该信号绑定的槽函数,例如执行页面切换逻辑;如果认证失败并发射loginFailed(QString reason)信号,Qt 也会自动调用对应的槽函数,例如显示错误提示或更新当前页面状态。
因此,信号与槽机制将“业务状态的产生”和“界面更新逻辑的执行”拆分开了。登录处理逻辑只负责判断认证结果并发射信号,而具体的界面切换、错误提示、页面状态更新等操作,则交给对应的槽函数完成。
所以,自定义信号的典型价值在于:它可以把某个对象内部发生的业务状态变化,以一种低耦合的方式通知给外部对象。信号负责表达“发生了什么”,槽函数负责处理“发生之后要做什么”。这也正是 Qt 信号与槽机制能够实现对象之间解耦的重要原因。
自定义信号的定义规则:signals声明、void返回值与参数匹配
根据上文,我们已经认识了自定义信号的应用场景。接下来,就可以进一步学习如何在 Qt 中自定义信号。
在定义自定义信号时,需要将信号函数声明在signals区域中。原因在于,信号函数和普通成员函数不同,开发者只需要写出信号函数的声明,而不需要自己编写信号函数的函数体。信号函数真正的执行代码会由moc在编译阶段自动生成。
但是,moc要想为某个函数生成信号相关的辅助代码,前提是它必须知道这个函数是一个信号函数。因此,自定义信号必须写在signals区域中。这样moc在扫描类定义时,才能识别出这些函数是信号函数,并为它们生成对应的元对象信息和信号激活代码。
同时,信号函数的返回值必须是void。这是因为信号本质上是一种对象之间的通知机制,它的作用是表达“某个状态或事件已经发生”,而不是像普通函数那样通过返回值向调用者反馈结果。
当一个信号被发射时,Qt 会根据当前发送者对象、具体信号以及运行时维护的连接记录,找到这个信号绑定的槽函数,并触发这些槽函数。也就是说,信号发射关注的是“通知哪些对象”和“触发哪些槽函数”,而不是“这个信号函数最终返回什么”。
所以,Qt 将信号函数设计为void返回值更加合理。信号只负责表达“发生了什么”,至于发生之后要执行什么逻辑,则交给已经连接好的槽函数完成。
信号函数也可以携带参数。信号参数的作用是:在发射信号时,把相关信息一起传递给槽函数。例如登录失败时,可以通过loginFailed(QString reason)将失败原因传递出去。
从执行过程上看,当信号被发射时,Qt 会将信号函数中传入的参数按照声明顺序组织起来。之后,Qt 在根据连接关系找到对应槽函数时,Qt 在调用槽函数时,会根据槽函数声明中需要接收的参数个数,从信号参数列表的开头开始,按顺序取出对应的参数,并作为实参传递给槽函数。也就是说,信号参数并不是单独存在的,而是会随着信号的发射一起进入信号激活流程,并最终传递给被调用的槽函数。
不过,信号和槽的参数需要满足一定的匹配关系。一般来说,信号的参数个数要大于或等于槽函数的参数个数,并且槽函数所接收的那部分参数类型需要和信号参数按照顺序匹配。也就是说,槽函数可以少接收一部分参数,但不能凭空接收信号没有提供的参数。
例如:
signals:voidloginFailed(QString reason,interrorCode);它可以连接到接收两个参数的槽函数:
voidshowLoginError(QString reason,interrorCode);也可以连接到只接收前一个参数的槽函数:
voidshowLoginError(QString reason);因为槽函数可以忽略信号后面多出来的参数。但是不能连接到下面这种槽函数:
voidshowLoginError(interrorCode);因为这个槽函数虽然只接收一个参数,但它接收的是第一个参数位置上的实参,而信号的第一个参数是QString reason,不是int errorCode。所以这里不是“槽函数想要哪个参数就拿哪个参数”,而是从信号参数列表的开头开始,按照顺序进行匹配和传递。
connect()的灵活用法:成员函数槽与 lambda 槽
接下来需要注意的是,connect()并不只有一种固定写法。在最常见的写法中,connect()通常接收四个参数,分别是发送对象、信号函数、接收对象以及槽函数。例如:
connect(button,&QPushButton::clicked,this,&Widget::handleClick);这句代码的含义是:当button对象发射clicked()信号时,调用当前Widget对象中的handleClick()成员函数。在这种写法下,槽函数通常需要是接收对象所属类中的成员函数。
但是,在新版 Qt 中,connect()本身是模板函数。它可以根据传入的实参推导信号和槽的类型,因此第四个参数并不一定只能是成员函数指针,也可以是 lambda 表达式这类可调用对象。例如:
connect(button,&QPushButton::clicked,this,[=](){qDebug()<<"button clicked";});这里的 lambda 表达式就可以理解为一个直接写在connect()语句中的临时槽函数。也就是说,当button发射clicked()信号时,Qt 不再去调用某个提前声明好的成员槽函数,而是直接执行这里写好的 lambda 表达式。
这样做的好处是,对于一些比较短、比较局部的交互逻辑,我们不需要专门在头文件中声明一个槽函数,再到源文件中定义这个槽函数,而是可以直接把处理逻辑写在connect()附近。这样代码会更加集中,也更容易看出某个信号触发后具体执行了什么逻辑。
同时,lambda 表达式还可以通过捕获列表使用其所在作用域中的变量。例如:
QString text="hello Qt";connect(button,&QPushButton::clicked,this,[=](){qDebug()<<text;});这里[=]表示按值捕获,也就是 lambda 会保存一份text的副本。后续按钮被点击时,lambda 内部仍然可以使用这个变量。
不过,使用 lambda 时也要注意被捕获对象的生命周期问题。如果使用引用捕获,例如[&],lambda 内部保存的是这些局部变量的引用,而不是拷贝一份新的对象。引用捕获不会延长被引用对象的生命周期。如果信号真正触发时,这些被引用的局部变量所在作用域已经结束,那么 lambda 执行时就可能访问到已经销毁的对象,从而产生未定义行为。
因此,在 Qt 的信号与槽连接中,如果 lambda 可能在当前函数返回之后才执行,通常更建议使用按值捕获,或者只捕获生命周期足够长的对象。对于一些临时的局部变量,尤其要避免随意使用引用捕获。
所以,lambda 形式的connect()更适合处理一些逻辑较短、只在当前位置使用、不值得单独抽成成员函数的场景。如果槽函数逻辑比较复杂,或者这段逻辑后续可能被多个地方复用,那么仍然建议定义一个普通成员函数作为槽函数。
简单来说,可以这样区分:
成员函数槽: 适合逻辑较复杂、需要复用、需要单独维护的场景。 lambda 槽: 适合逻辑较短、只在当前 connect 附近使用的场景。因此,新版connect()的灵活性更高。它既可以连接传统的成员槽函数,也可以连接 lambda 表达式这类可调用对象。对于初学阶段来说,可以先建立一个简单模型:短逻辑可以写 lambda,复杂逻辑仍然抽成成员函数。
disconnect()的作用:解除信号与槽连接
除了connect()函数之外,Qt 还提供了disconnect()函数。connect()用来建立信号与槽之间的连接关系,而disconnect()则用来解除已经建立好的连接关系。
不过在实际开发中,disconnect()的使用频率通常没有connect()高。原因在于,大多数信号与槽连接一旦建立之后,往往会伴随对象的整个生命周期存在。例如按钮的clicked()信号连接到某个槽函数之后,只要这个按钮和接收对象还存在,这条连接通常就会一直有效,并不需要我们手动解除。
同时,Qt 本身也会在对象销毁时自动清理相关连接。也就是说,如果信号发送者对象或者接收者对象被销毁,那么它们之间已经建立的信号与槽连接也会被自动移除。因此,在普通场景下,我们通常不需要专门调用disconnect()来清理连接关系。
当然,这并不代表disconnect()没有作用。当程序希望在运行过程中临时取消某个交互逻辑时,就可以使用disconnect()。例如,某个按钮在特定状态下不再希望触发原来的槽函数,或者某个对象暂时不再关心另一个对象发出的信号,就可以通过disconnect()解除对应连接。
从理解角度来看,disconnect()做的事情就是从 Qt 运行时维护的连接记录中删除某条连接关系。原来信号发射时,Qt 可以根据连接记录找到对应的槽函数;而当这条连接被解除之后,即使信号再次发射,Qt 也不会再调用原来绑定的槽函数。
可以简单理解为:
connect() 建立连接记录: 某个对象的某个信号 → 某个槽函数 / 可调用对象 disconnect() 删除连接记录: 后续该信号发射时,不再触发被解除的槽函数例如:
connect(button,&QPushButton::clicked,this,&Widget::handleClick);// 后续如果不再希望点击按钮时调用 handleClick()disconnect(button,&QPushButton::clicked,this,&Widget::handleClick);信号与槽机制总结:多对多连接与对象解耦
文章的最后,我们可以对信号与槽机制做一个简单收尾。
Qt 是一个用于开发 GUI 图形化界面程序的应用框架。需要注意的是,Qt 并不是唯一能够开发图形化界面的框架,其他框架同样也可以实现可视化界面和用户交互。也就是说,不同 GUI 框架最终要达到的目标是类似的,都是构建一个可以显示界面、接收用户操作并做出响应的程序。
但是,在实现动态交互逻辑时,不同框架的设计思路可能会有所区别。对于 Qt 来说,它主要通过信号与槽机制来完成对象之间的交互。当某个对象发生特定事件或状态变化时,它可以发射信号;而通过connect()建立连接关系之后,这个信号就可以触发对应的槽函数或可调用对象。
信号与槽之间并不是严格的一对一关系,而是可以形成多对多关系。也就是说,一个信号可以连接多个槽函数;同时,一个槽函数也可以被多个信号触发。相比之下,一些框架中更常见的交互方式是回调函数或事件处理函数,即某个事件发生后,直接调用对应的处理逻辑。
当然,在很多简单的界面交互场景中,一对一的处理方式已经足够使用。例如,点击一个按钮后执行一个对应函数,这就是最常见的场景。但是 Qt 仍然提供了多对多的信号与槽连接能力,这并不是设计缺陷,而是为了让对象之间的通信更加灵活。
更重要的是,信号与槽机制实现了一种解耦。信号的发出者只需要表达“发生了什么”,并不需要关心“谁来处理”以及“具体怎么处理”;而槽函数则负责处理信号发出之后要执行的逻辑。二者之间通过connect()建立连接关系。
因此,Qt 的信号与槽机制不仅仅是为了完成界面交互,更重要的是提供了一种对象之间低耦合通信的方式。它将“事件或状态的发生”和“具体处理逻辑的执行”分离开来,使得程序结构更加清晰,也更方便后续维护和扩展。
结语
那么这就是本篇文章的全部内容,带你深入理解Qt的信号与槽机制,我会持续更新,希望你能够多多关注,如果本文有帮助到你的话,还请三连加关注,你的支持就是我创作的最大动力!