Qt精品级项目——手撕信号槽机制的拳皇摇杆demo
Qt精品级项目——手撕信号槽机制的拳皇摇杆demo
1.前言&资源
大家好,我是程序员Akgry。几天没见,阿克的Qt能力又是突飞猛进,阿克现在每天从睁眼学到闭眼,没了杂七杂八的东西掣肘,学起东西来非常舒适,虽然假期偷偷内卷很反人类,但是阿克今年就要参加秋招了,技术上的东西又岂敢怠慢。好了废话不多说了,介绍一下今天的项目,这个项目准确来说算是阿克在CSDN上发表的第一篇精品级项目,虽然篇幅不长,代码量不多,但其中涉及的技术是非常有深度的,阿克通过剖析Qt的信号与槽机制,将信号槽从元对象系统剥离了出来,独立实现了一个信号槽的demo,效果呢也是相当不错,我们接下来就一起看下吧。
工具:Qt5.9.6、Visual Studio 2022; 源码连接:GitHub仓库,项目名SiGNAL&SLOT_demo
2.底层原理&设计思路
要设计信号与槽的框架,首先要搞清楚信号与槽到底是什么。可能大家立刻脱口而出,信号与槽很简单啊,就是Qt部件间的通信机制。但如果再深入一点,信号与槽的本质是什么,可能此时大家就有点懵了,虽然经常使用信号槽,但信号槽到底是如何实现出来的还真是有点不太了解。在弄清楚这个问题之前,我们先思考一点,Qt是基于C++技术的纵向扩展,也就是说Qt的众多技术是在C++的基础之上进行实现的,那么我们不难猜出,Qt的信号槽机制也是基于C++实现的。那么Qt项目中都有一个重要的宏Q_OBJECT,这个宏是Qt项目能够跑起来的关键,所以抱着这个态度我们可以翻阅一下Qt的官方文档,首先查找一下QObject这个类到底是什么东西
文档中给出的介绍很简单,说这个类是所有Qt类的base类,然后接着介绍了众多QObject派生的子类,这些都没有什么太大的参考价值,接着我们突然看到了文档中给出了两个note,第一个note是说所有方法都具有reentrant性,这没什么稀奇的,很多函数都能递归。第二个note的东西我们就熟悉了,没错这正是Qt连接信号与槽的方法,也就是说我们所用的信号与槽连接方法都是基于QObjiect这个类继承而来的,那是不是就意味着信号与槽这个机制就是通过QObject这个类来实现的呢,其实不然,因为整个信号槽机制不仅仅只有一个connect和disconnect,虽然我们在Qbject类中找到了连接信号槽的方法,但这只能说QObject具有处理信号槽的能力,并不能说明QObject实现了信号槽机制,那线索到这里是不是就断了,不然。我们上文已经说过,任何Qt项目想要运行起来都必须包含一个名为Q_OBJECT的宏,这意味着Q_OBJECT这个宏对于信号槽机制的产生有着不可或缺的作用,我们这次直接通过Ctrl的方式进到底层代码里去看看这个宏到底是怎么回事
我们进入到Q_OBJECT宏的定义界面后发现了一大堆编译宏,此时我们恍然大悟,明白这个总是令我们莫名其妙的Q_OBJECT的作用了,这个宏对应了一种特殊的编译工具,当编译工具发现这个宏的时候,就能够做出一些列的操作,这里我就不再卖关子耽误时间了,这个编译工具正是Qt官方的MetaObjectCompiler,也称元对象编译器moc,Qt各种的所有纵向拓展正是通过moc编译出能够正常链接的代码来保证运行的。但是我们仔细看机会发现,这个Q_OBJECT里冒出来一摊奇怪的东西
一个好端端的编译宏里突然创建了一个名为QMetaObject的对象,还是static的,而且还声明了一些操作它的方法,怎么想都有点不对劲,结合前面的moc,这个QMetaObject到底是何方神圣,我们通过Ctrl去底层代码看一下,结果这不看不知道,一看吓一跳,QMetaObject的代码极其庞大,阿克我只是看了一会就急忙退出来了,整整205行全是代码,倒不是说阿克技术太菜看不懂,而是这个代码量阿克实在有点顶不住,最后阿克决定还是找Qt文档帮忙吧(PS:一般这种技术类问题最好先翻底层代码,这样技术提升才会快,阿克这种情况属于是没办法了才去看文档),我们直接左键点击代码中QMetaObject这个类名,按F1索引到对应文档位置,我们简单粗暴一点,既然都看文档了,直接翻到Detailed Description里
此时我们直接醍醐灌顶,看到了QMetaObject的庐山真面目,如上图所示,文档指出QMetaObject类包含了Qt对象里的元信息(这也难怪Q_OBJECT里的非要将它声明为static类型的),然后指明了信号槽机制正是通过元对象系统实现的。此时我们再继续翻看QMetaObject的文档,会看到更多关于信号与槽的信息,比如indexOfSlot()、indexOfSignal()、methodCount() 等等,再结合此前我们在Q_OBJECT宏里看到的对QMetaObject的操作函数,此时我们彻底弄清楚了信号与槽机制的原理,它的本质是对元对象也就是QMetaObject这个对象的操作。最后我们梳理一下思路:(1)为什么所有的类要继承自QObject,因为它具有连接信号槽的能力,注意只是能力;(2)为什么所有的类里都要加入Q_Object这个宏,因为它是编译宏,能够让我们的Qt代码正常编译链接;(3)为什么Q_Object这个编译宏里要声明静态的QMetaObject成员和它的操作方法,因为信号、槽函数、连接表这些元信息都保存在这个QMetaObject对象里,它的操作方法也都是通过直接操作元对象达到通信的效果;好了,知道了信号槽机制的底层原理之后再设计起来是不是问题就迎刃而解了,不好意思,那是不可能的,因为我们要实现一个好的成熟的框架demo,就必须要舍弃一些东西才行,就拿QMetaObject来讲,它只不过是恰巧保存了与信号槽机制有关的信息,但这并不代表整个元对象QMetaObject只保存信号槽的元信息,它保存的东西是海量的,比如我们本篇博客没有涉及到的动态属性机制和对象树机制的元信息也是保存在这里面的,所以我们设计信号槽demo的时候就要有所优化,如果将成熟的框架原封不动地设计成demo,虽然耦合度优异,但内聚性也低到可怕,这对于一个demo来说是需要避免的。那么接下来,就跟着我一起将信号槽设计出来吧。
3.设计自己的QObject类
这里声明一下,为了便于展示,我就不专门用工具画UML图了,我会用表格详细介绍类的具体实现内容,这样也方便我写博客。好了,我们废话不多说,直接开始设计,首先我们如果自己设计QObject类的话,肯定不用考虑QObject的全部内容,所以我决定将元对象直接扣除,改为用容器保存连接表,至于连接方法,我们直接使用友元函数进行访问即可,类表如下所示:
AObject类
方法 描述 限定
void connect() 用于连接信号与槽的方法 public:friend
void active() 用于激活信号与槽的方法 public:friend
void call() 供子类实现的调用接口 public:virtual
成员 描述 限定
multimap<in,_des> connectMap 用于保存连接时的映射,取代了元对象复杂的机制 private:
有了上述的设计表之后,写起代码来是不是就容易多了,这里不卖关子了,具体代码实现如下:
// 头文件AObject.h代码 #pragma once #include <map> using std::multimap; class AObject; struct _des { AObject* receiver; int slot; }; class AObject { public: // 信号槽的连接函数 friend void connect(AObject* sender, int signal, AObject* receiver, int slot); // 信号槽的激活函数 friend void active(AObject* sender, int signal); // 子类的调用函数 virtual void call(int slot) = 0; private: // 用于保存连接的映射,取代了元对象 multimap<int, _des> connectMap; }; void connect(AObject* sender, int signal, AObject* receiver, int slot); void active(AObject* sender, int signal); // 源文件AObject.cpp代码 #include "AObject.h" void connect(AObject* sender, int signal, AObject* receiver, int slot) { // 构造一个临时的pair对象 _des des = { receiver, slot }; std::pair<int, _des> temp(signal, des); // 将pair对象插入到映射中 sender->connectMap.insert(temp); } void active(AObject* sender, int signal) { for (auto it = sender->connectMap.find(signal); it != sender->connectMap.end() && it->first == signal; it++) { _des des = it->second; des.receiver->call(des.slot); } }
上述代码大致实现了QObject的整体架构,需要注意的一点是这里模拟元对象QMetaObject用的映射容器时multimap而不是map,这是因为map的键值不能够重复,这是不行的,所以这里需要使用multimap来进行实现。
4.设计自己的Sender类和Receiver类
有了AObject类后,发送者和接收者肯定需要继承自它,但是由于我们缺少了QMetaObject这个元对象,所以具体的实现方式肯定会有所差异 。这里我们还是通过两张表格的方式,展示一下类的实现过程(其中JoyStick摇杆类是发送者,Fighter人物类是接收者):
JoyStick类
方法 描述 限定
JoyStick() 构造函数 public:
void emit() 用于模拟发送的方法 public:
void call() 实现父类的调用接口 public:
成员 描述 限定
enum
保存摇杆能够发出的命令
{←|↖|↑|↗|→|↘|↓|↙}
public:
string name 摇杆名字,比如玩家1的摇杆 private:
Fighter类
方法 描述 限定
Fighter() 构造函数 public:
void emit() 用于模拟发送的方法 public:
void call() 实现父类的调用接口 public:
成员 描述 限定
enum
保存人物能够做出的指令
{←|↖|↑|↗|→|↘|↓|↙}
public:
string name 人物姓名,如草薙京、八神庵 private:
以上就是这两个核心类的设计,主要还是采用了继承自AQbject的功能,当然大家也可以把emit做成和call一样的纯虚函数,因为demo去除了复杂的元对象系统,所以这里就需要大家手动实现了,这里就不再详细设计了。然后具体的代码实现如下:
// JoyStick.h代码: #pragma once #include "AObject.h" #include <string> #include <iostream> using std::string; class JoyStick : public AObject { public: // 摇杆能够发出的操作 enum{ 左, 左上, 上, 右上, 右, 右下, 下, 左下, ALL }; // 构造函数 JoyStick(string str) : name(str) {}; // 模拟发送信号的方法 void emit(int signal); // 实现继承过来的虚函数call void call(int slot); private: string name = "摇杆"; }; // JoyStick.cpp的代码: #include "JoyStick.h" void JoyStick::emit(int signal) { string str; switch (signal) { case 左: str = "向左"; break; case 左上: str = "向左上"; break; case 上: str = "向上"; break; case 右上: str = "向右上"; break; case 右: str = "向右"; break; case 右下: str = "向右下"; break; case 下: str = "向下"; break; case 左下: str = "向左下"; break; } std::cout << name << str << "方滑动, "; active(this, signal); } void JoyStick::call(int slot) { } // Fighter.h的代码: #pragma once #include "AObject.h" #include <string> #include <iostream> using std::string; class Fighter : public AObject { public: // 人物能做出的指令 enum { 左, 左上, 上, 右上, 右, 右下, 下, 左下, ALL }; // 构造函数 Fighter(string str) : name(str) {}; // 模拟发送信号的方法 void emit(int signal); // 实现继承过来的虚函数call void call(int slot); private: string name = "人物"; }; // Fighter.cpp的代码: #include "Fighter.h" void Fighter::emit(int signal) { active(this, signal); } void Fighter::call(int slot) { string str; switch (slot) { case 左: str = "后撤步"; break; case 左上: str = "后空翻"; break; case 上: str = "升龙跳"; break; case 右上: str = "飞身跳"; break; case 右: str = "前冲步"; break; case 右下: str = "下前突"; break; case 下: str = "下防蹲"; break; case 左下: str = "下防退"; break; } std::cout << name << "做出了" << str << std::endl; }
上述的代码中我将四个文件的代码整合到了一块,用注释的方式简单做了个隔离,没有做具体的分类,大家在看的时候注意一下。
5.最终测试
终于来到了最后的阶段了,我们已经有了摇杆类和人物类,现在只需要像Qt里一样在我们的main.cpp目录下编写下列这段代码即可:
#include <iostream> #include "Fighter.h" #include "JoyStick.h" using namespace std; int main(void) { Fighter man1("八神庵"); Fighter man2("草薙京"); JoyStick rocker1("玩家一的摇杆"); JoyStick rocker2("玩家二的摇杆"); connect(&rocker1, JoyStick::右, &man1, Fighter::右); connect(&rocker1, JoyStick::上, &man1, Fighter::上); connect(&rocker2, JoyStick::右上, &man2, Fighter::左上); rocker1.emit(JoyStick::右); rocker1.emit(JoyStick::上); rocker2.emit(Fighter::右上); return 0; }
我们运行项目观察到结果:
完美!
6.总结
至此整个拳皇摇杆项目结束了,在这里想告诉大家,重要的是从项目的角度理解Qt信号槽机制的原理以及与真正的信号槽机制的差异,比如项目使用的connectMap其实是元对象系统中静态对象QMetaObject的平替,但QMetaObject自身还存有更多如动态属性、对象树等机制的元信息。关注博主,后续更多精品级项目持续分享!
————————————————
版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
原文链接:https://blog.csdn.net/qq_63303370/article/details/135579261