本文专栏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::vectorQObject*children;// 子对象列表std::string objectName;// 对象名称// 这里用伪代码表示信号与槽连接关系// 可以理解为某个信号 - 对应的一组槽函数连接记录std::unordered_mapint,std::vectorConnectionconnections;};classQMetaObject{public:constchar*className;// 类名constQMetaObject*superMetaObject;// 父类元对象std::vectorMethodInfosignalList;// 信号列表std::vectorMethodInfoslots;// 槽函数列表std::vectorPropertyInfoproperties;// 属性列表};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 texthello 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的信号与槽机制我会持续更新希望你能够多多关注如果本文有帮助到你的话还请三连加关注你的支持就是我创作的最大动力