『 QT 』QT信号机制深度解析

文章目录


信号概念

QT中的信号与Linux中的信号在概念上是类似的, 本质上是发射一种通知信息通知某个控件或是系统作出一系列的处理;

Linux中的信号通常为signal, 不同的事件将会发出不同的信号;

  • 典型案例 :

    11号信号SIGSEGV为例;

    SIGSEGV信号通常发生在段错误, 当信号发出后系统内部会进行一系列的操作, 最终让进程终止并提示"Segmentation fault (core dumped)";

    通常情况下, 代码由CPU进行执行, 而有关内存的访问通常由MMU内存管理单元进行操作;

    假设一段代码为:

    cpp 复制代码
    #include <iostream>
    
    using namespace std;
    
    int main() {
    
      int *ptr = nullptr;
      *ptr = 10;
    
      return 0;
    }
    1. CPU执行mov指令, 试图向0x0写入

    2. MMU转换0x0地址失败 → 触发页错误异常

    3. CPU硬件: 保存上下文, 切换到内核模式

    4. 内核页错误处理程序:

      • 分析错误原因(无效地址访问)

      • 设置当前进程的信号位图

        cpp 复制代码
        sigaddset(&current->pending.signal, SIGSEGV)
    5. 从异常返回的路径上:

      • 内核检查到有待处理信号
      • 执行SIGSEGV的默认处理(终止进程 + core dump)
    6. 输出 "Segmentation fault (core dumped)"

对QT而言也是类似的, 只不过对QT而言其信号并不涉及这么复杂的系统操作;

当用户操作后, 将会触发对应的事件, 事件被监控后通过类似回调的方式发送信号;

信号被接收后由于控件发射的信号和槽被connect绑定, 槽函数将会进行一系列动作视为对信号的处理;

  • Linux 下的信号(以上文的11号信号SIGSEGV为例)

    • 信号源

      操作系统内核(设置信号位图);

    • 信号类型

      11号信号SIGSEGV;

    • 信号处理方式

      执行SIGSEGV的默认处理(终止进程 + core dump);

  • QT信号

    • 信号源

      发射信号的控件;

    • 信号类型

      clicked, pressed或用户自定义信号;

    • 信号处理方式

      调用槽(回调);

通常情况下, 无论是Linux下的信号还是QT下的信号, 都是先有信号处理方式才能去触发信号, 否则信号会被忽略(未被处理);

这也意味着在QT中进行信号处理一定要使用connect将信号和槽进行关联;


connect 函数

QT中的connect与Linux - socket中的connect函数是两个不同的函数, 属于同名但不同作用;

connect是QT中QObject类提供的一个静态成员函数;

由于QWidget, QPushButton, QLabel等控件都直接或间接继承于QObject类, 因此都可以调用QObjectconnect静态成员函数;

  • 上述类的继承关系为如下:

connect函数因为QT版本的迭代出现了多个版本;

而从QT5开始, 最常用的connect函数采用的是模板函数的方式;

  • connect

    cpp 复制代码
    template <typename Func1, typename Func2>
        static inline QMetaObject::Connection
            connect(const typename QtPrivate::FunctionPointer<Func1>::Object *sender, Func1 signal,
                    const typename QtPrivate::ContextTypeForFunctor<Func2>::ContextType *context, Func2 &&slot,
                    Qt::ConnectionType type = Qt::AutoConnection);

在这个函数中, 模板参数Func1Func2用来接收两个函数:

  • 信号函数
  • 槽函数

本质原因是函数指针虽然都是指针, 如果采用指定的参数来接收的话必须采用void(*)()的方式加上强制类型转换进行, 这样增加了维护成本和使用成本, 而根据cpp的模板可直接通过模板来得到泛型的效果;

  • connect参数解释为如下:

    • const typename QtPrivate::FunctionPointer<Func1>::Object *sender

      在往常的理解以及使用中, 我们通常认为传入的是一个QObject *类型的控件指针, 而此处的参数类型并不为QObject*, 本质原因是文档中所写的QObject*便于使用者理解, 告诉使用者需要传入一个该类型的指针;

      而实际上这里的参数采用的模板特化出来的类型, 通过模板特化的方式对参数进行分离, 通常分离出这几种元素:

      • Object - typedef Obj Object
      • 参数列表 - Arguments
      • 返回类型 - ReturnType

      通过如此的分离以确保能够在编译时识别出代码错误而不是在运行时错误以避免用户因运行时错误(老语法的错误通常在运行时才能检查是否错误)重复对项目进行构建而增加使用成本;

      其中Object用于检查sender 参数的类型, 确保 sender 能调用该信号以确保类型安全;

      Arguments用于检查信号和槽参数兼容(e.g., 槽可接受信号的参数, 或忽略尾部参数);

      ReturnType信号通常为void, 但用于通用性(槽可有返回, 但忽略);

      • 这些元素在 connect 内部用于

        计算参数 QMetaType ID(queued 连接), 创建调用对象(QSlotObject), 验证兼容性;


    • Func1 signal

      传入信号函数;


    • QtPrivate::ContextTypeForFunctor<Func2>::ContextType *context

      传入处理信号线程的对象指针, 此处采用模板特化, 与const typename QtPrivate::FunctionPointer<Func1>::Object *sender解释相同;


    • Func2 &&slot

      传入槽;


    • Qt::ConnectionType type = Qt::AutoConnection

      传入处理时机, 存在缺省参数, 一般情况下使用缺省参数即可;


connect 简单使用

简单案例:

创建一个QWidget框架, 该框架中存在一个QPushButton, 当点击QPushButton按钮后会关闭QWidget窗口;

cpp 复制代码
Widget::Widget(QWidget *parent)
    : QWidget(parent)
    , ui(new Ui::Widget)
{
    ui->setupUi(this);

    QPushButton *mybutton = new QPushButton(this); // 创建一个QPushButton控件
    mybutton->setText("CloseWindow"); // 将QPushButton控件命名为"CloseWindows"
    connect(mybutton, &QPushButton::clicked, this, &QWidget::close); // 将clicked信号与 QWidget::close 槽进行绑定, 当按钮被点击后, 关闭this控件的窗口
}

Widget::~Widget()
{
    delete ui;
}

运行结果如下:

在使用QTCreator进行代码编写时, 通常会进行代码补全, 其中在编写clicked时可能补全窗口会出现以下两种函数;

  • clicked
  • click

这两个函数其中一个是信号函数, 另一个是QT中自带的槽函数;

区别的方式可以从函数名中进行区别, 同时在QTCreator中也可以根据补全函数的样式进行区别;

可以从图中看到两个函数最左侧的标识不同, 一个有信号样式的函数表示信号函数, 另一个则表示槽函数;


左侧信号函数, 右侧槽函数

同时也可以采用函数名的方式去推测, 如click为动词原型, clicked为动词过去分词形式;

在上文中, 我们提到connect函数采用模板特化的方式分离Object, 参数列表以及返回值;

而通常情况下connect函数的第一个参数与第二个参数是要相互匹配的;

假设sender传入的是QPushButton*, 那么对应的第二个single参数信号函数也必须是sender的成员函数, 如QPushButton::clicked(), 不能为其他控件类的信号函数, QT会对这两个参数进行检查;

第三个参数传入一个控件的指针, 通俗的来说这个参数决定谁去调用槽函数, 而实际的是使用哪个控件对象的上下文(线程)来调用这个槽函数(管理这个槽函数的生命周期), 这段代码中采用的是this指针, 即通过QWidget对象的上下文来管理槽函数的生命周期;

第四个参数为传入一个槽函数, 在上文代码中采用的是QWidget::close函数, 这是一个QT中内置的槽函数, 主要的功能是关闭对应的窗口, 由于是使用this的上下文来对槽函数的生命周期进行管理, 因此this(QWidget)的窗口被关闭, 当然也可以将槽函数的指针改成QPushButton;

cpp 复制代码
    connect(mybutton, &QPushButton::clicked, mybutton, &QWidget::close);

如图所示, 将槽的调用放在mybutton中, 点击后mybutton按钮关闭;


QT内置的信号和槽

通常可以在文档中查看到QT中控件所给的信号和槽;

QPushButton为例;

通过QT Assistant的索引找到QPushButton, 通过索引查看QPushButton时并不能看到对应内置的信号/槽;

本质是因为, 信号/槽并不一定属于该控件, 也可能是继承自其父类;

可以看到该类集成自QAbstractButton类;

在该类中可以看到对应内置的信号/槽;

QPushButton为例, 该控件是一个按钮, 而通常情况下在QT下的按钮并不只有QPushButton;

除了QPushButton以外, 还存在QToolButton, QRedioButton等按钮, 而按钮的动作是点击或者其他状态, 因此为了代码的复用, QT对这些控件的相同属性进行了一个抽象, 所有的其他按钮都将继承自这个抽象类以增加代码的复用率;

除此之外, 在.uixml文件中, 通过Designer对控件进行 "右键 -> 转到槽" 进行信号设置, 可以设置内置的信号与自定义的槽;

这里列举的信号将会把其父类控件的信号一并列出;


自定义槽

除了QT内置的槽以外, QT提供能让用户自定义槽的功能;

槽(slot)对connect而言本质上是一个回调函数, 对于控件来说一般情况下是一个成员函数, 通过connect函数与信号进行绑定, 当对应的控件发出了相对的信号后, 该信号所对应的槽将会被调用;

所谓的自定义一个槽函数本质是声明 - 定义一个成员函数;

cpp 复制代码
Widget::Widget(QWidget *parent)
    : QWidget(parent)
    , ui(new Ui::Widget)
    , my_button(new QPushButton(this))
{
    ui->setupUi(this);
    my_button->setText("Button");
    my_button->move(350, 100);
    my_button->setFixedSize(80, 50);
    connect(my_button, &QPushButton::clicked, this, &Widget::button_handler);

}

Widget::~Widget()
{
    delete ui;
}

void Widget::button_handler()
{
    this->setWindowTitle("The button clicked");
    my_button->setFixedSize(300, 100);
    my_button->setText("The button clicked");
    my_button->move(230, 100);
}

以此段代为例, 这段代码中将内置信号clicked与自定义槽button_handler通过connect函数绑定在一起;

效果如图所示;

在过去中的QT在设置自定义槽时, 必须通过slot关键字(public slots:/protect slots:/private slots:)进行声明, 以标示某个成员函数为槽函数;

通过元编程的方式, 使用qmake项目构建工具生成代码;

而从QT5开始, 槽只需要像成员函数进行声明和定义即可;


通过 Designer + 代码的方式绑定信号和槽

通过Designer手动拖动一个控件(此处示例QPushButton控件对象);

右键选择转到槽并选择对应的信号;

当点击对应需要绑定的信号后, QTCreator会自动跳转到xxx(父控件/框架).cpp的源文件中并要求定义一个函数;

QPushButton::clicked()信号为例;

  • 需要完善的槽函数定义为如下:

    cpp 复制代码
    void Widget::on_pushButton_clicked()
    {    }
  • 此处将函数定义完善为如下:

    cpp 复制代码
    void Widget::on_pushButton_clicked()
    {
        ui->pushButton->setFixedSize(150, 60);
        ui->pushButton->setText("The button clicked");
    }

    即当按下按钮后, 按钮的文本设置为"The Button clicked", 大小改为150*60px;

这种方式将信号和槽进行连接时, 并不需要使用connect进行连接;

通常采用一种方式进行连接, 即采用on_[widgetName]_[signalName](严格定义)的方式作为槽的函数名即可将信号与该槽进行连接;

同时在上文中提到, public slots:等关键字在QT5/6中并没有什么太大作用, 客观来说, 当使用connect进行信号和槽的关联时, 通常不需要使用slots关键字;

而当若是需要使用on_[widgetName]_[signalName]的方式设置信号与槽时, 在对应的.h文件中, 该函数是被设置在slots关键字中;

slots关键字被删除后, moc将无法使用slots关键字通过qmake等构建工具生成对应槽函数的相关代码, 对应的信号不被处理;

从图中看到, 当public slots:关键字被注释后重新进行项目构建, 点击按钮将不触发槽;

  • 可以对使用Designer添加控件并使用命名格式来连接信号/槽的方式进行验证:

    1. 使用Qt Designer添加一个控件, 将控件命名为mybutton并设置控件文本为Click Me

    2. widget.h中手动声明槽

      cpp 复制代码
      QT_BEGIN_NAMESPACE
      namespace Ui {
      class Widget;
      }
      QT_END_NAMESPACE
      
      class Widget : public QWidget
      {
          Q_OBJECT
      
      public:
          Widget(QWidget *parent = nullptr);
          ~Widget();
      
      public slots: // 关键字
          void on_mybutton_clicked(); // 命名槽
      
      private:
          Ui::Widget *ui;
      };
      #endif // WIDGET_H
    3. widget.cpp中定义槽

      cpp 复制代码
      void Widget::on_mybutton_clicked(){
          this->setWindowTitle("The Button clicked"); // 点击后标题改为 "The Button clicked"
      }
    4. 结果

如果未使用该规则进行命名将无法连接信号/槽;

该示例为例, 手动将on_mybutton_clicked改为on_mybutton_clickedi(增加一个i)后重新构建并运行;

当项目构建时, 输出将会输出一个异常;

当使用Designer设置控件后且类中存在slots关键字, QT将会尝试调用QMetaObject::connectSlotsByName这个方法来连接信号与槽, 当slots域内不存在对应类型的槽且未使用connect连接信号与槽时, 将抛出这样的异常;

从项目构建后的ui_xxx.h文件中可以看到QMetaObject::connectSlotsByName函数被调用;


自定义信号

在QT中, 除了自定义槽以外还可以自定义信号;

对于信号而言, 其本质上是一个函数的声明, 通常我们只需要对信号进行声明即可;

在声明自定义信号函数的前提, 必须使用signals:Q_SIGNALS:域来标明该域下的函数为信号函数;

否则moc无法对定义的函数声明视为一个信号函数, 则无法对该信号函数进行内部的元编程实现;

当拥有信号时, 需要对信号进行发送, 在上文中的大部分例子中, 我们使用的是clicked信号, 而该信号是在用户点击按钮后, 底层进行一系列事件, 从而发送clicked信号, 因此在信号定义的过程中, 也需要使用对应的方式来对信号进行发送;

通常信号的发送采用的是emit关键字;

假设已经存在了一个信号名为mysignal(), 那么要在对应的需要采用emit mysignal()来对信号进行发送;

  • 示例

    cpp 复制代码
    // -------------- widget.h --------------
    QT_BEGIN_NAMESPACE
    namespace Ui {
    class Widget;
    }
    QT_END_NAMESPACE
    
    class Widget : public QWidget
    {
        Q_OBJECT
    
    public:
        Widget(QWidget *parent = nullptr);
        ~Widget();
        void mysignalHandler();
        void clickedHandler();
    signals: 
    // 也可以使用 Q_SIGNALS:
        void my_signal();
    
    private:
        Ui::Widget *ui;
        QPushButton *mybutton;
    };
    #endif // WIDGET_H
    
    
    // -------------- widget.cpp --------------
    #include "widget.h"
    #include "ui_widget.h"
    
    Widget::Widget(QWidget *parent)
        : QWidget(parent)
        , ui(new Ui::Widget)
        ,mybutton(new QPushButton(this))
    {
        ui->setupUi(this);
        mybutton->setFixedSize(100, 80);
        mybutton->move(300, 200);
        mybutton->setText("Click me");
        connect(mybutton, &QPushButton::clicked, this, &Widget::clickedHandler);
        connect(this, &Widget::my_signal, this, &Widget::mysignalHandler);
    
    }
    
    Widget::~Widget()
    {
        delete ui;
    }
    
    void Widget::mysignalHandler()
    {
        this->setWindowTitle("Get a singnal");
    }
    
    void Widget::clickedHandler()
    {
        emit my_signal();
    }

    在这个例子中, 我们创建了一个名为my_signal()的信号函数, 通过signals:关键字来标明信号函数的域, 同时定义了两个信号处理函数, 分别为:

    • void Widget::clickedHandler()

      用来处理点击信号clicked;

    • void Widget::mysignalHandler()

      用来处理自定义信号my_signal;

    同时通过两个connect函数来绑定对应的信号和槽;

    当用户点击按钮后, QPushButton::clicked()信号将会被发送给连接的该信号的槽们, 从而调用槽函数clickedHandler();

    而在clickedHandler()中将会发送my_signal()自定义信号给关联了该信号的槽, 并进行调用, 该槽的作用为修改this控件的title"Get a signal";

    结果为如下:

在这个例子中, 发射信号时采用了emit关键字对函数进行发送, 而实际上这个关键字是在并不是一个强制使用的关键字, 本身它只是一个空的宏定义;

cpp 复制代码
# define emit

这意味着不需要强制性的写emit关键字;

cpp 复制代码
void Widget::clickedHandler()
{
    my_signal(); // 不使用 emit, 可以但不推荐, 降低代码可读性
}

但为了养成良好的编码习惯与提高代码可读性, 我们同样推荐采用emit关键字来标明这是一个信号发送而不是一个函数调用;

通常情况下, 自定义信号的场景较少, 主要原因是QT中内置的信号已经涵盖了用户的大部分操作, 而若是需要自定义信号的场景, 可能我们较多的用法是通过内置信号来发射自定义信号从而达到其他的自定义效果;


带参数的信号和槽

通常情况下, 我们在设置信号和槽时, 可以对信号/槽进行参数的设置, QT支持这一点;

信号的参数通常要和槽的参数尽量要求匹配, 但这个匹配并不是数量上的匹配, 而是类型上的匹配, 通常情况下, 可以要求信号的参数要槽的参数个数;

本质上是因为QT在创建初期时, 希望采用一种信号与槽多对多的关系;

则一个信号可以被多个槽用connect关联, 一个槽也可以接受多个信号, 从而形成多对多的关系(而在实际开发过程中, 多对多似乎并不被常用);

一个信号发送给多个槽


一个槽可以接收多个信号

既然是一个信号可以发射给多个槽, 那么对应的槽只要接收到和自己类型匹配的参数即可, 其他的参数可以置之不理;

但是有一点需要注意的是, 我们在使用带参的信号和槽时, 由于信号参数的数量可以多于槽参数的数量, 这意味着槽可以选择自己感兴趣的参数进行接收, 但在接收参数的过程中, 需要严格把控参数的顺序;

假设一个信号的参数为value1, value2, value3, 那么槽在接收参数的过程中不可直接跳过value1value2进行接收;

  • 带参的信号和槽示例

    • widget.h

      cpp 复制代码
      #ifndef WIDGET_H
      #define WIDGET_H
      
      #include <QWidget>
      #include <QPushButton>
      #include <QString>
      QT_BEGIN_NAMESPACE
      namespace Ui {
      class Widget;
      }
      QT_END_NAMESPACE
      
      class Widget : public QWidget
      {
          Q_OBJECT
      
      public:
          Widget(QWidget *parent = nullptr);
          ~Widget();
      public slots:
          void mySignalHandler(const QString&);
          void clickedHandler();
      signals:
          void mySignal(const QString&, int, double);
      
      private:
          Ui::Widget *ui;
          QPushButton *mybutton;
      };
      #endif // WIDGET_H

      在这个示例中声明了一个信号函数, 信号函数带了三个参数, 分别为const QString&, int, double类型的三个参数;

      设置了两个槽函数, 其中mySignalHandler函数为自定义信号的槽, clickedHandler为点击信号的槽;

    • widget.cpp

      cpp 复制代码
      #include "widget.h"
      #include "ui_widget.h"
      
      Widget::Widget(QWidget *parent)
          : QWidget(parent)
          , ui(new Ui::Widget)
          , mybutton(new QPushButton(this))
      {
          ui->setupUi(this);
          mybutton->setText("Click Me!!");
          mybutton->setFixedSize(120, 80);
          mybutton->move(280, 200);
      
          connect(mybutton, &QPushButton::clicked, this, &Widget::clickedHandler);
      
          connect(this, &Widget::mySignal, this, &Widget::mySignalHandler);
      
      }
      
      Widget::~Widget()
      {
          delete ui;
      }
      
      void Widget::mySignalHandler(const QString &qstr)
      {
          mybutton->setText(qstr);
          mybutton->setFixedSize(300, 150);
          mybutton->move(200, 100);
      
      }
      
      void Widget::clickedHandler()
      {
          emit mySignal("The Button clicked!", 10, 6.66);
      }

      当用户点击后, 将会触发clicked的槽, 该槽会发射自定义信号, 并带三个参数("The Button clicked!", 10, 6.66);

      当信号被mySignalHandler槽接收到后, 将只会提取该信号的第一个参数, 并使用该参数(改变按钮样式与文本, 将文本改为接收到的The Button clicked!);

    • 运行结果:

    该例子只举例了信号/槽之间参数的差异, 并未举例出多对多的特点, 但是其多对多的特点可以通过该段代码以及上述演示进行分析, 此处不进行赘述;


信号和槽存在的意义

信号和槽本质上解决的是响应用户的操作, 实际上在GUI开发框架中, 信号和槽的这种方式本就不太常见;

在上文中提到, 信号和槽的多对多关系本身是一个比较理想化的开发模式, 解耦了用户的操作和响应的处理, 但在QT中, 因为过度解耦用户操作(状态改变)和响应处理却一定程度上增加了使用成本, 其他的GUI开发框架使用一对一的方式反而使得开发过程中变得更简单;

以网页开发为例, 网页开发通常使用js, dom api等方式进行开发, 而关于用户操作的响应主要以回调函数的方式进行;

其无需像QT一样需要通过connect函数将用户操作和操作响应进行绑定, 从而在一定程度上节省了开发成本, 但其强耦合也导致其无法使用信号与槽的多对多的特性;

因此, 选择哪种模式, 并非孰优孰劣, 而是取决于应用场景和设计目标:是追求快速实现, 还是构建一个坚如磐石的长期项目;

相关推荐
用户805533698036 天前
不止三件套:QObject 属性系统全关键字与运行时反射!
c++·qt
xcyxiner6 天前
DicomViewer (vcpkg Windows和ubuntu编译)7
qt
Quz11 天前
QML Hello World 入门示例
qt
xcyxiner14 天前
DicomViewer (dcmtk读取dcm文件)5
qt
xcyxiner14 天前
DicomViewer (后台线程处理文件)4
qt
xcyxiner15 天前
DicomViewer (添加模型类)3
qt
xcyxiner15 天前
DicomViewer (目录调整) 2
qt
xcyxiner15 天前
dcmtk vtk vtk-dicom(gdcm) 编译(debug) v2
qt
LDR00617 天前
Type-C 快充全面升级!LDR6601 赋能个人护理便携电机,重塑剃须刀 / 理发器新体验
c语言·开发语言
雪碧聊技术17 天前
Tree.js是什么?一文讲透
开发语言·javascript·ecmascript