4.3 自定义信号和槽

本节首先介绍一下 C++ 编程中常用的传递数据机制,包括类对象的公有成员变量、友元类/函数 、公有函数、回调函数等,这些机制在 Qt 程序中也是可以使用的。然后重点介绍如何在 Qt 类里面自定义信号和槽,通过手动触发信号来调用槽函数,完成两个对象之间的信息传递,本节最后示范一个信号接力触发的例子。本节内容较多,可以分两三次来学。

4.3.1 C++ 的沟通方式

C++ 编程中常遇到各个对象之间进行沟通的情景,需要将数据从一个对象传递给另一个对象来处理。大致的方法有如下几种:
这些方式都是基于标准 C++ 的,在 Qt 中都可以用,但由于 Qt 有更好的信号和槽机制,因此一般更推荐使用信号和槽机制实现通信。下面对 C++ 常见的传递数据方式依次举例示范,主要是让读者大致了解一下传递数据的过程。

本小节的示例是多个独立的 cpp 文件,可以通过 2.1 节命令行方式编译运行,这几个 cpp 文件都放到 D:\QtProjects\ch04\cppcom 文件夹里的。
首先是公有成员变量的例子,publicmember.cpp 代码:
//publicmember.cpp
#include <iostream>
using namespace std;
//接收端类
class Dst
{
public:
    double m_dblValue;
    int m_nCount;

    //处理数据函数
    double DoSomething()
    {
        double dblResult = m_dblValue / m_nCount;
        return dblResult;
    }
};
//源端类
class Src
{
public:
    void SendDataTo( Dst &theDst)
    {
        //设置接收端公有变量
        theDst.m_dblValue = 2.0;
        theDst.m_nCount = 3;
    }
};

int main()
{
    //定义两个对象
    Dst theDst;
    Src theSrc;
    //传递数据
    theSrc.SendDataTo(theDst);
    //接收端处理
    cout<<theDst.DoSomething();
    //
    return 0;
}
接收端的类对象 theDst 有两个公有成员 m_nCount 和 m_dblValue,源端对象 theSrc 在 SendDataTo 函数里面修改了接收端的成员变量,然后接收端再对数据进行处理。
这种传递数据方式最大的问题是谁都可以修改接收端的公有成员变量,如果有其他代码也修改了 theDst,那么处理结果是很难预知的,尤其是在多线程程序里,公有变量在另一个线程被修改了,本线程很可能都不知道。如果通过全局变量传递数据的情景,这种负面效果是 类 似的,而且更严重。
公有成员变量和全局变量方式都会破坏类数据的封装特性,谁都能修改,结果不可控。另外,这种方式不方便做数值有效性鉴定,比如把 m_nCount 设置为 0, 除 0 会直接导致程序出错。
所以不建议使用这种方式。

接下来是友元类的例子,通过友元声明,源端可以直接设置接收端的私有成员变量,如 friend.cpp 代码:
//friend.cpp
#include <iostream>
using namespace std;

//接收端类
class Dst
{
private:    //私有变量
    double m_dblValue;
    int m_nCount;

public:
    //处理数据函数
    double DoSomething()
    {
        double dblResult = m_dblValue / m_nCount;
        return dblResult;
    }
    //友元类
    friend class Src;
};
//源端类
class Src
{
public:
    void SendDataTo( Dst &theDst)
    {
        //因为是友元类,所以能设置接收端私有变量
        theDst.m_dblValue = 2.0;
        theDst.m_nCount = 3;
    }
};

int main()
{
    //定义两个对象
    Dst theDst;
    Src theSrc;
    //传递数据
    theSrc.SendDataTo(theDst);
    //接收端处理
    cout<<theDst.DoSomething();
    //
    return 0;
}
接收端的成员变量是私有的,因此不用担心成员变量被其他代码段胡乱修改,只有友元授权的 Src 类对象才能修改接收端对象私有变量。这种方式的缺陷是 Src 类和 Dst 类是紧耦合的,它们息息相关,修改了一个类的代码很可能影响另一个。除非程序员确定需要这种紧耦合设计,否则一般也不建议使用友元。另外,对于传递的数值有效性鉴定,需要 友元类对象确保不把 m_nCount 设置成 0。

再来看看第三种,使用 get 和 set 函数对的方式,在 Qt 类库里面,对于设置数值的函数以 set 字样打头,而获取数值的函数默认省略 get 字样,比如 QLabel 对象,设置文本函数为 setText(),获取文本函数为 text() 。下面的示例也按照 Qt 命名风格, getset.cpp 代码:
//getset.cpp
#include <iostream>
using namespace std;

//接收端类
class Dst
{
private:    //私有变量
    double m_dblValue;
    int m_nCount;

public:
    //get函数
    double value()
    {
        return m_dblValue;
    }
    int count()
    {
        return m_nCount;
    }
    //set函数
    void setValue(double v)
    {
        m_dblValue = v;
    }
    void setCount(int n)
    {
        if( n < 1 ) //防止除 0 ,并且计数限定为正整数
        {
            m_nCount = 1;
        }
        else
        {
            m_nCount = n;
        }
    }

    //处理数据函数
    double DoSomething()
    {
        double dblResult = m_dblValue / m_nCount;
        return dblResult;
    }
};
//源端类
class Src
{
public:
    void SendDataTo( Dst &theDst)
    {
        //通过set函数传递数据
        theDst.setValue(2.0);
        theDst.setCount(3);
    }
};

int main()
{
    //定义两个对象
    Dst theDst;
    Src theSrc;
    //传递数据
    theSrc.SendDataTo(theDst);
    //接收端处理
    cout<<theDst.DoSomething();
    //
    return 0;
}
代码里通过 value()和 setValue() 函数封装私有变量 m_dblValue,通过 count()和 setCount() 函数封装 m_nCount 私有变量,在 set 函数里面可以对输入的数据做判断,确认是否合法,数据的可控性大大增强。get/set 方式是推荐的做法,下一节有规范的 Qt 属性封装介绍,与这种方式是类似的。

接下来示范一个回调函数的例子,多线程编程和 Windows 编程会经常遇到类似的回调函数,通常源端会给出通用的回调函数类型声明,接收端按照该格式定义自己的回调函数。因为回调函数一般是通用的,所以参数里常用的是 void * 指针,而不会针对某一个固定的类对象传参。因为类的普通成员函数需要隐藏的 this 指针,会导致不符合回调函数类型声明,所以回调函数只能用静态成员函数或全局函数定义。回调函数示范 callback.cpp:
//callback.cpp
#include <iostream>
using namespace std;

//源端约定回调函数类型
typedef void (*PFUNC)(double v, int n, void *pObject);

//接收端类
class Dst
{
private:
    double m_dblValue;
    int m_nCount;

public:
    //处理数据函数
    double DoSomething()
    {
        double dblResult = m_dblValue / m_nCount;
        return dblResult;
    }
    //回调函数
    static void FuncCallBack(double v, int n, void *pObject)
    {
        //转换成 Dst 指针
        Dst *pDst = (Dst *)pObject;
        //静态成员函数也是可以设置私有变量的,但需要手动传对象指针
        //设置 value
        pDst->m_dblValue = v;
        //设置count
        if( n < 1)
        {
            pDst->m_nCount = 1;
        }
        else
        {
            pDst->m_nCount = n;
        }
    }

};
//源端类
class Src
{
public:
    void SendDataTo( Dst *pDst, PFUNC pFunc)
    {
        //通过回调函数传数据
        pFunc(2.0, 3, pDst);
    }
};

int main()
{
    //定义两个对象
    Dst theDst;
    Src theSrc;
    //传递数据
    theSrc.SendDataTo(&theDst, Dst::FuncCallBack);
    //接收端处理
    cout<<theDst.DoSomething();
    //
    return 0;
}
在上面示例代码中,源端先给出了回调函数类型 PFUNC 的声明,参数为 double 和 int,返回值为空。
接收端的类就按照这个声明定义参数、返回值都一样的静态成员函数 FuncCallBack,作为实际执行的回调函数。回调函数只能是全局函数或静态成员函数,因为类的普通成员函数需要隐藏的 this 指针参数。
在 main 函数里,程序执行流程为:
①theSrc 调用 SendDataTo 函数,参数有目标对象 theDst 指针和目标类里定义好的 回调函数 Dst::FuncCallBack。
②在 SendDataTo 函数内部,回调函数 Dst::FuncCallBack 作为 pFunc ,被调用执行,三个参数值为 2.0、3 和目标对象 指针。
③pFunc 就是 Dst::FuncCallBack 回调函数,这个回调函数会根据三个参数里数值,首先将 void * 指针转为目标对象指针 pDst,然后设置目标对象里的两个私有成员变量,并且可以直接做数据有效性检查。
④theDst 对象里的成员变量被设置好之后,就可以调用 DoSomething 函数做处理了。

回调函数机制是很常见的,Windows 消息机制本身也是回调函数的应用,多线程编程也使用回调函数作为新线程里的任务函数。我们上一节示范的三个例子,信号与槽函数可以一对一关联,一对多关联,多对一关联,如 果用回调函数实现这些复杂的映射,那会是非常头疼的事。比如希望 theSrc 同时把数据传递给 A、B、C 三个目标对象,那 SendDataTo 函数必须手动执行三次。回调函数难以实现同时一发多收、多发一收,而信号和槽机制是完全可以的,并且代码非常简洁明了。另外信号与槽函数可以在运行时解除关联关系,这也是回调函数不好实现的特性。
介绍完常规的传递数据方法之后,下面来看看 Qt 自定义信号和槽的通信过程。

4.3.2 通过自定义信号和槽沟通

通过信号和槽机制通信,通信的源头和接收端之间是松耦合的:
源头和接收端是非常自由的,connect 函数决定源头和接收端的关联关系,并会自动根据信号里的参数传递给接收端的槽函数。
因为源头是不关心谁接收信号的,所以 connect 函数一般放在接收端类的代码中,或者放在能同时访问源端和接收端对象的代码位置。
下面开始自定义信号和槽的例子,打开 QtCreator,新建一个 Qt Widgets Application 项目,在新建项目的向导里填写:
①项目名称 qobjcom,创建路径 D:\QtProjects\ch04,点击下一步;
②套件选择里面选择全部套件,点击下一步;
③基类选择 QWidget,点击下一步;
④项目管理不修改,点击完成。
建好项目之后,打开窗体 widget.ui 文件,进入设计模式,向其中拖入一个按钮控件,按钮 objectName 默认为 pushButton,将其显示文本的 text 属性设置为“发送自定义信号”,并调整按钮宽度将文本都显示出来,如下图所示:
ui
编辑好之后保存界面,回到代码编辑模式,打开 widget.h,添加处理按钮 clicked 信号的槽函数,和新的自定义的信号 SendMsg:
#ifndef WIDGET_H
#define WIDGET_H

#include <QWidget>

namespace Ui {
class Widget;
}

class Widget : public QWidget
{
    Q_OBJECT

public:
    explicit Widget(QWidget *parent = 0);
    ~Widget();

signals:    //添加自定义的信号
    void SendMsg(QString str);  //信号只需要声明,不要给信号写实体代码

public slots:   //接收按钮信号的槽函数
    void ButtonClicked();

private:
    Ui::Widget *ui;
};

#endif // WIDGET_H
signals: 就是信号代码段的标志,这个标志不带 public 、protected、private 等前缀,那是因为信号默认强制规定为公有类型,这样才能保证其他对象能接收到信号。
我们定义了 SendMsg 信号,带一个 QString 参数,这个声明与普通函数声明类似。注意信号只是一个空壳,只需要声明它,而不要给它写实体代码。自定义信号的全部代码就是头文件这里的两行(包括 signals: 行),不需要其他的。signals: 标识的代码段只能放置信号声明,不能放其他任何东西,普通的函数或变量、槽函数都不要放在这里。

public slots: 是公有槽函数代码段的标志,定义了 ButtonClicked 槽函数,接收按钮被点击的信号,这个槽函数以后会触发我们自定义的信号。槽函数代码段也只能放槽函数声明的代码,不要把其他的东西放在这个代码段里。

下面来编写 widget.cpp 里面的代码,实现发送我们自定义信号的槽函数,并和按钮的信号关联起来:
#include "widget.h"
#include "ui_widget.h"

Widget::Widget(QWidget *parent) :
    QWidget(parent),
    ui(new Ui::Widget)
{
    ui->setupUi(this);

    //关联
    connect(ui->pushButton, SIGNAL(clicked()), this, SLOT(ButtonClicked()));
}

Widget::~Widget()
{
    delete ui;
}
//槽函数
void Widget::ButtonClicked()
{
    //用 emit 发信号
    emit SendMsg( tr("This is the message!") );
}
在 Widget 构造函数里,我们将按钮的 clicked 信号关联到槽函数 ButtonClicked,当按钮被点击时,ButtonClicked 会自动被调用。
ButtonClicked 里面只有一句代码,就是
emit SendMsg( tr("This is the message!") );
emit 是发信号的关键字,然后接下来就与调用函数是一样的格式,SendMsg 里面放置我们想传递的字符串参数。除了 emit 字样,触发信号就与函数调用一样。这样简单一句就实现了触发信号的过程,同之前所说的,源端就顾自己发信号,至于谁接收 SendMsg 信号,源端是不管的。
Widget 窗体代码就是上面那么多,发送我们自定义的 SendMsg 信号的过程如下图所示:
emit
对于上图左半段,按钮的点击信号和相应的槽函数我们之前两节已经用过多次了,这个不是关注的重点。重点就是在主窗体槽函数里面,我们发出了自定义的信号 SendMsg ,谁来接收它呢?

接下来我们造一个接收 SendMsg 信号的类对象和槽函数,并将收到的字符串参数弹窗显示。我们先为该项目添加一个新的类,并编写接收 SendMsg 信号的槽函数。
打开 QtCreator 菜单“文件”-->“新建文件或项目”,在“新建”对话框里,左边部分选择“C++”,中间部分选 “C++ Class”,如下图所示:
new
然后点击右下角 Choose,进入新建 C++ 类的向导界面,将 Class name 修改为 ShowMsg,基类选择 QObject,其他的就用自动填充的,选择基类 QObject 之后,会自动包含相应头文件。要使用信号和槽机制,必须直接或间接从 QObject 类派生,我们这里是直接从 QObject 派生了子类 ShowMsg:
newclass
然后点击“下一步”,进入项目管理界面:
manage
这里就按照默认的值,不用修改,自动添加到项目 qobjcom.pro 里面,版本控制默认是没有。点击“完成”,稍等一会,QtCreator 就会生成好 ShowMsg 类的两个文件 showmsg.h 和 showmsg.cpp,并添加到项目里。

接下来,我们编辑 showmsg.h ,声明接收 SendMsg 信号的槽函数 RecvMsg:
#ifndef SHOWMSG_H
#define SHOWMSG_H

#include <QObject>

class ShowMsg : public QObject
{
    Q_OBJECT
public:
    explicit ShowMsg(QObject *parent = 0);
    ~ShowMsg();

signals:

public slots:
    //接收 SendMsg 信号的槽函数
    void RecvMsg(QString str);
};

#endif // SHOWMSG_H
RecvMsg 槽函数声明的参数类型和返回类型要与 SendMsg 信号保持一致,所以参数是 QString,返回 void。

然后我们编辑 showmsg.cpp,实现 RecvMsg 槽函数:
#include "showmsg.h"
#include <QMessageBox>

ShowMsg::ShowMsg(QObject *parent) : QObject(parent)
{

}

ShowMsg::~ShowMsg()
{

}

//str 就是从信号里发过来的字符串
void ShowMsg::RecvMsg(QString str)
{
    QMessageBox::information(NULL, tr("Show"), str);
}
添加头文件 <QMessageBox> 包含之后,我们添加槽函数 RecvMsg 的实体代码,里面就是一句弹窗的代码,显示收到的字符串。QMessageBox::information 函数第一个参数是父窗口指针,设置为 NULL,代表没有父窗口,就是在系统桌面直接弹窗的意思。
信号和槽机制有三步,一是有源头对象发信号,我们完成了;第二步是要有接收对象和槽函数,注意,上面只是类的声明,并没有定义对象。我们必须定义一个接收端的对 象,然后才能进行第三步 connect。

编辑项目里 main.cpp,向其中添加代码,定义接收端对象,然后进行 connect:
#include "widget.h"
#include <QApplication>
#include "showmsg.h"

int main(int argc, char *argv[])
{
    QApplication a(argc, argv);
    Widget w;   //①主窗体对象,内部会发送 SendMsg 信号
    ShowMsg s;  //②接收端对象,有槽函数 RecvMsg
    //③关联,信号里的字符串参数会自动传递给槽函数
    QObject::connect(&w, SIGNAL(SendMsg(QString)), &s, SLOT(RecvMsg(QString)));

    //显示主界面
    w.show();

    return a.exec();
}
首先添加 "showmsg.h" 头文件包含,然后在主窗体对象 w 定义之后,定义了接收端对象 s。主窗体对象 w 会发 SendMsg 信号,接收端 s 有对应的槽函数 RecvMsg,这样完成了信号和槽机制的头两步。接下来第三步就是调用关联函数 QObject::connect,将源头对象、信号、接收端对象、槽函数关联。connect 函数是通用基类 QObject 里面定义的,之前用 connect 函数都没有加类前缀,是因为在 QObject 派生类里面自动继承了 connect 函数,不需要额外的前缀。在 main 函数里,需要手动加 QObject:: 前缀来调用 connect 函数。
关联完成之后,一旦用户点击主窗体里的按钮,我们自定义的 SendMsg 信号就会发出去,然后 接收端对象 s 里的槽函数就会执行,并且信号里的字符串也会自动传递给 RecvMsg 槽函数,然后会出现弹窗显示传递的字符串。
这个示例的运行效果如下图所示:
run
这个例子完整的执行流程如下图所示:
allprocess
本小节需要大家学习的就是右半段的部分,我们在主窗体 ButtonClicked 函数里触发自定义的信号 SendMsg,然后通过 connect 函数关联,自动调用了接收端对象 s 的槽函数 RecvMsg,并弹窗显示了传递的字符串。

也许有读者会问,费这么大劲,为什么不直接在 ButtonClicked 里面弹窗?那不简单多了?
因为本小节的目的不是弹窗,而是为了展现自定义信号和槽函数的代码写法,理解信号和槽机制的运行流程。以后遇到复杂多窗口的界面程序,在多个窗体对象之间就可以用 上图示范的流程,来进行通信、传递数据。

4.3.3 信号关联到信号示例

信号除了可以关联到槽函数,还可以关联到类型匹配的信号,实现信号的接力触发。上个示例中因为 clicked 信号没有参数,而 SendMsg 信号有参数,所以不方便直接关联。本小节示范一个信号到信号的关联,将按钮的 clicked 信号关联到一个参数匹配的 SendVoid 信号。
重新打开 QtCreator,新建一个 Qt Widgets Application 项目,在新建项目的向导里填写:
①项目名称 signalsconn,创建路径 D:\QtProjects\ch04,点击下一步;
②套件选择里面选择全部套件,点击下一步;
③基类选择 QWidget,点击下一步;
④项目管理不修改,点击完成。
建好项目之后,打开窗体 widget.ui 文件,进入设计模式,向窗体拖一个按钮,按钮默认对象名称为 pushButton,将显示的文本属性 text 修改为“信号接力触发”,如下图所示:
ui
编辑好界面之后保存,回到代码编辑模式,编辑 widget.h ,添加我们自定义的信号:
#ifndef WIDGET_H
#define WIDGET_H

#include <QWidget>

namespace Ui {
class Widget;
}

class Widget : public QWidget
{
    Q_OBJECT

public:
    explicit Widget(QWidget *parent = 0);
    ~Widget();

signals:    //只添加一个信号,不需要按钮对应的槽函数
    void SendVoid();

private:
    Ui::Widget *ui;
};

#endif // WIDGET_H
新添加的 SendVoid 信号声明,没有参数,所以能和按钮的 clicked 信号匹配,实现信号到信号的关联。
然后我们编辑 widget.cpp ,添加关联函数调用:
#include "widget.h"
#include "ui_widget.h"

Widget::Widget(QWidget *parent) :
    QWidget(parent),
    ui(new Ui::Widget)
{
    ui->setupUi(this);

    //关联按钮信号到自定义的信号
    connect(ui->pushButton, SIGNAL(clicked()), this, SIGNAL(SendVoid()));
}

Widget::~Widget()
{
    delete ui;
}
仅在构造函数里加了一句 connect 调用,注意 connect 函数第四个参数是 SIGNAL(SendVoid()),这就是关联到信号的用法。以前都是关联到槽函数,这里直接关联到自定义的信号,而不需要槽函数中转。
关联之后,一旦按钮的 clicked 信号触发,主窗体的信号 SendVoid() 紧跟着自动触发,实现信号触发的接力过程。

自定义信号的触发过程编完之后,下面为项目添加新的 ShowVoid 类,也是从 QObject 派生,和上面小节添加 C++ 类是类似的:
class
然后我们声明自定义的槽函数,用于接收 SendVoid() 信号,打开 showvoid.h,编辑如下:
#ifndef SHOWVOID_H
#define SHOWVOID_H

#include <QObject>

class ShowVoid : public QObject
{
    Q_OBJECT
public:
    explicit ShowVoid(QObject *parent = 0);
    ~ShowVoid();

signals:

public slots:
    //接收 SendVoid() 信号的槽函数
    void RecvVoid();
};

#endif // SHOWVOID_H
头文件增加了与 SendVoid() 信号匹配的槽函数 RecvVoid() 声明。然后我们编辑 showvoid.cpp,添加槽函数实体代码:
#include "showvoid.h"
#include <QMessageBox>

ShowVoid::ShowVoid(QObject *parent) : QObject(parent)
{

}

ShowVoid::~ShowVoid()
{

}
//槽函数,弹窗
void ShowVoid::RecvVoid()
{
    QMessageBox::information(NULL, tr("Show"), tr("Just void."));
}
有了 ShowVoid 类声明是不够的,接收信号需要一个对象实体,然后才能关联,所以同样地,编辑 main.cpp 文件,添加代码如下:
#include "widget.h"
#include <QApplication>
#include "showvoid.h"

int main(int argc, char *argv[])
{
    QApplication a(argc, argv);
    Widget w;   //①源头对象,内部发送信号 SendVoid()
    ShowVoid s; //②接收对象,有对应槽函数 RecvVoid()
    //③关联源头的信号和接收端的槽函数
    QObject::connect(&w, SIGNAL(SendVoid()), &s, SLOT(RecvVoid()));

    //显示主界面
    w.show();

    return a.exec();
}
这一小节大部分代码都和上一小节类似,所以不做过多重复描述了。程序运行效果如下图:
run
本小节示例的执行流程如下图所示:
process
主窗体里将信号关联到信号,是需要大家学会用的。也许有读者会问,为什么不直接将 ui->pushButton 的信号关联到最终的目的端 s 呢?因为 ui 是主窗体对象 w 的私有成员变量,在类外不可访问,无论是 main 函数还是 ShowVoid 类的代码里,都是看不到 ui->pushButton 这个按钮的,源头都找不到,是没法关联的。如果把私有变量 ui 改成公有的,那会破坏类的封装性,不建议这么弄。在面对私有成员无法访问的情况下,使用信号接力是比较科学的方法。

关于自定义信号和槽的通信就讲这些,最后教大家一个小技巧,如果向项目新添加了类文件,如 showvoid.h 和 showvoid.cpp,如果这时候 QtCreator 左下角的按钮变成了长时间持续灰色,无法使用,那么可以通过来回切换 Debug 和 Release 构建模式,让 QtCreator 重新解析一下项目文件就可以了:
mode



prev
contents
next