Qt 元对象编程。
Qt 元对象编程。
引言
本文将介绍 Qt 用于对象模型操作和线程处理的一些底层机制。对于初学者来说,它能直接了解信号与槽的实际工作原理。对于高级用户来说,它对于处理大型半动态构建的界面或需要反射式编程但在 C++ 环境下的情况是一个很好的参考。
背景
在 Qt 中,每个 QObject
派生实例都有一个名称。您可以以编程方式设置它,或在 IDE 中使用 Qt Designer 设置,或由 Qt Designer 自动分配。在大多数情况下,这使得它(通常)是唯一的。
这意味着整个应用程序中的每个 QObject
(或至少是最顶层的窗口)都(几乎总是)有一个从最顶层的应用程序节点到底层的唯一...路径
祖父母/父母/孩子
这意味着:
- 您可以使用 Qt 的框架找到它并获取指向它的指针(按名称搜索,或按名称和类型/子类型搜索)。
- 您可以使用 Qt 的元对象数据来询问它的类型(以
string
形式),或询问它支持哪些调用。
这意味着您可以通过文本动态地查找、获取调用签名,并通过 Qt 的信号/槽调用一个对象。
- 您可以调用
Q_SLOTS
函数,但这些代码是在您编写代码多年后才创建的,并且对类型没有真正了解(只有调用签名)。 - 您可以跨线程调用(类似于 C# 调用跨线程)。
- 您可以通过重新父化(reparenting)组件并编辑对象树来重用现有
Q_Widgets
的部分,甚至可以在将来使用名称相同的 widget 的版本。 - 您可以根据其他
QWidget
的未来大小、形状和对象树来调整代码的行为,而这些QWidget
甚至还没有被编写出来。 - 您可以将许多概念上相似的对象链接到相同的行为,即使它们派生自不同的基类(所有
checkedChanged
调用和valueChanged
调用都可以通过函数签名检测到,并被重新路由到执行相同或相似操作的处理器)。
Using the Code
连接 & 发送者
假设您有一个 UI 文件定义了大量的 UI 元素,并且您已经完美地设置了布局,它们都显示了大量数据,但是,在编程上您想连接所有的信号和槽。
通常,这需要几十行甚至更多的代码,看起来像这样:
connect(mpUi->mpCheckBox1, SIGNAL(stateChanged(int)), SLOT(checkBox1_OnStateChanged(int)));
connect(mpUi->mpCheckBox1, SIGNAL(stateChanged(int)), SLOT(checkBox2_OnStateChanged(int)));
...
connect(mpUi->mpSpinBox1, SIGNAL(valueChanged(int)), SLOT(spinBox1_OnValueChanged(int)));
...
connect(mpUi->mpDSpinBox1, SIGNAL(valueChanged(double)), SLOT(dspinBox1_OnValueChanged(double)));
...
正常情况下,复选框或微调框等名称应反映其有意义的信息,但这只是一个示例。
关键是,如果您有 20 个复选框、20 个微调框、20 个双精度微调框,您就需要 60 个连接,以及 60 个函数处理器。这是大量的敲击键盘。
假设您按类型折叠,并且也折叠了 SLOTS,那么只需要 9 行代码就可以完成连接(每种类型 3 个),并且每种类型只有一个槽。
QList<QCheckBox *> checkBoxes = source->findChildren<QCheckBox *>("");
foreach (QCheckBox *cb, checkBoxes)
connect(cb, SIGNAL(stateChanged(int)), SLOT(onCheckBoxStateChanged(int)));
这会将所有复选框映射到同一个槽。在槽内部,我们可以恢复发送者的指针。
void myClass::onCheckBoxStateChanged(int) {
QCheckBox * whoSentThisSignal = sender();
if (whoSentThisSignal == NULL) { return; }
}
我们可以根据名称的正则表达式,将某些复选框映射到这个槽或那个槽。
类型 / 签名发现
假设应用程序更高级一些。假设我正在编写其中的一部分,作为数据与 UI 之间的一个代理,我不确定确切的类型是什么。这仍然没关系,我仍然可以连接它们。
QList<QWidget *> widgets = source->findChildren<QWidget *>("");
foreach (QWidget *w, widgets)
{
const QMetaObject *p = w->metaObject();
if (p->indexOfMethod(QMetaObject::normalizedSignature("stateChanged(int)")) != -1)
{
// Don't need to know actual type,
// It means it has a Q_SLOT or Q_INVOKABLE marked call stateChanged(int)
// So I can connect to it even though it may not be derived from QCheckbox!
connect(w, SIGNAL(stateChanged(int)), SLOT(onCheckBoxStateChanged(int)));
}
if (p->indexOfMethod(QMetaObject::normalizedSignature("valueChanged(int)")) != -1)
{
// Don't need to know actual type,
// It means it has a Q_SLOT or Q_INVOKABLE marked call stateChanged(int)
// So I can connect to it even though it may not be derived from QCheckbox!
connect(w, SIGNAL(valueChanged(int)), SLOT(onValueChanged(int)));
}
}
在这种情况下,我的代码只知道并依赖代码签名。Connect 不会去查找 vtable
中的条目,它会将 SIGNAL
和 SLOT
宏生成的文本与 Qt moc 工具生成的字符串表进行匹配。这意味着所有的绑定都不是类型安全的,甚至不是类型感知的,而且所有这些都是在运行时完成的。
调用
在面试时,我经常让人们“解释 Qt 线程”这个概念。现实情况是,有几十篇文章都在宣称“最好”或“正确”的线程处理方式,而它们实际上是在提倡风格。问题是 Qt 的线程处理在很大程度上留给了开发者,它非常灵活,因此也容易引起混淆。我将简要解释如何处理,并提供一个快速技巧来让生活更轻松。
在 Qt 中,对象不是线程安全的(除非您自己创建),并且它们默认都在主 Qt UI 线程上运行。所有定时器,一切都默认在同一个线程中。当您尝试使用更多线程时,会立即遇到运行时问题,因为工作线程与 UI 线程不是同一个线程,您不能直接调用 UI 对象。这与 .NET 框架的问题相同,并且有一个类似的解决方案(稍后会讲到)。
假设您有两个工作线程,“A
”和“B
”。在 Qt 中,QThreads
是 QObjects
,您将其他 QObjects
“移动”到该线程。这意味着“A
”拥有一些对象,“B
”拥有一些对象。这也意味着所有 UI 对象都不能被工作线程调用(会崩溃),但“A
”和“B
”上的其他对象可以相互调用(但这是异步的,所以您通常需要互斥锁)。
Qt 的一般范例是“移动”一个对象到一个工作线程,然后创建一个信号/槽连接,以便工作线程“A
”中的对象向 UI 对象发出信号,反之亦然。Qt 会检测它们属于不同的线程,并将连接排队,这样当信号发生时,它会被转换为文本,放入应用程序事件队列,然后当另一个线程唤醒时,它会从事件队列中获取字符串,通过匹配文本将其转换为一组参数和调用签名,找到(通过文本匹配)对象和函数指针,然后进行调用。
这很慢,但这是 Qt 所要求的。您可以通过添加互斥锁来添加自己的线程安全调用,但对于任何与 UI 相关的内容,您最终都必须在 Qt 内部更改内容以影响绘图,这通常意味着(在某个地方)进行排队连接。
这也意味着您通常需要继承 Qt 中的某些类,添加额外的槽,并在您的代码中添加信号和连接。
或者,您可以这样做:
QTimer::singleShot(0, mpSpinBox, SLOT(functionName()));
QTimer::singleShot
在后台获取指针和 SLOT 宏生成的文本,将其放入应用程序事件队列(延迟为零),然后调用函数。如果函数没有参数,这会非常方便。
或者,如果它有参数:
QMetaObject::invokeMethod(mpSpinBox, "setValue", Qt::QueuedConnection, Q_ARG(int, mValue));
这会发布一个请求,最终执行 mpSpinBox->setValue(mValue);
如果我想让此线程阻塞直到完成,我可以使用 Qt::BlockingQueuedConnection
。
这适用于任何是槽的东西(Q_INVOKABLE
、Q_SLOT
、Q_PROPERTY
)。
在后台,它会创建一个 QMetaCallEvent
并将其作为事件发布到 mpSpinBox
,通过应用程序事件泵,然后由正确的线程进行处理。
这类似于 C# 的 BeginInvoke
,它看起来像:
mpSpinBox.BeginInvoke( delegate { this.setValue(mValue); });
或
mpSpinBox.BeginInvoke( () => { mpSpinBox.setValue(mValue); });
或者对于阻塞:
mpSpinBox.Invoke( () => { mpSpinBox.setValue(mValue); });
需要调用,但在 Qt 中:
另一个很好的技巧是让非线程安全或存在父子线程问题的函数在跨线程甚至脚本化操作中工作。
class className : public QObject {
...
public:
Q_INVOKABLE void crossThreadSafe(int delay);
protected:
void _InternalImplimentation(int delay);
...
QTimer myTimer;
};
然后在主体中,我们检查我们被调用的线程是我们需要的线程,然后直接调用工作函数或调用它。请注意,它不能是 Qt::BlockingQueuedConnection,因为 Qt 会认为这是一个死锁(即使不是)并且不会进行调用。如果 _InternalImplimentation(delay) 返回了什么东西,我们将需要自旋等待结果而不是进行阻塞连接。
void className::crossThreadSafe(int delay) { if (QThread::currentThread() == this->thread()) { _InternalImplimentation(delay); } else { QMetaObject::invokeMethod( this, "_InternalImplimentation", Qt::QueuedConnection, // Can't be blocking! Q_ARG(int, delay)); QThread::yieldCurrentThread(); // Make it likely the work is done now. } } void className::_InternalImplimentation(intdelay) { // The below call only works if it is made on the same thread that made this timer. // // Things like Timers, Com ports, Script environments and most QWidgets have // member functions that either malfunction or make run time checks. myTimer.start(delay); }
另一种更简洁的方式,但……对于 Qt 新手来说有点难读:
void className::crossThreadSafe(int delay) { if (QThread::currentThread() != this->thread()) { QMetaObject::invokeMethod(this, "crossThreadSafe", Qt::QueuedConnection, Q_ARG(int, delay)); QThread::yieldCurrentThread(); // Make it likely the work is done now. return; } myTimer.start(delay); }
这样,函数会调用自身,但会在正确的线程上(如果线程错误),否则它会执行工作。这种风格在 C# 应用程序中更常见,但概念上与检查 C# 中的 invokeRequired 是否为 true 相同。
无论哪种情况,开发人员都必须意识到调用可能几乎立即激活,或者……在操作系统最终(通常是毫秒,极少数情况是秒)处理完成时激活。使用互斥锁 tryLock() 等技术在多个东西可能尝试启动同一操作时非常有用。它们尝试锁定,如果失败,则表示操作已被启动;否则,它们获得锁并成为发起者,然后当代码实际完成时,锁将被释放。
QMutex mMutex(true); // recursive is a must. void className::crossThreadSafe(int delay) { if (QThread::currentThread() != this->thread()) { // Wrong thread, post a request. QMetaObject::invokeMethod(this, "crossThreadSafe", Qt::QueuedConnection, Q_ARG(int, delay)); while (mMutex.tryLock() == false) { QThread::msleep(100); // Sleep 100 msec until the work is done in a loop. } // It completed, so release it for the next operation to begin. mMutex.unlock(); } else { // Correct thread, do it (still works if recursive). myTimer.start(delay); mMutex.unlock(); } }
上面的代码做了两件事:
- 许多线程可以同时调用 crossThreadSafe,并且这些事件会被合并成(通常)对 myTimer.start(delay); 的一次调用。
- 所有同时的调用都会阻塞直到操作完成。
重新父化
假设您有一个遗留控件,它有一个带有旧公司徽标的横幅,底部有一个边框,并且您想重用它。您可以包装该控件并隐藏横幅和边框,但这可能不起作用,因为该控件具有监视可见性的private
函数。您可以在运行时回收该控件。
首先,一个小的辅助函数:
QString uniqueName(QObject * w, int depth = 20)
{
if (depth < 0) return "";
if (w == NULL) return "";
return uniqueName(w->parent(), depth - 1) + "/" + w->objectName();
}
现在我创建一个 widget,找到一个名为 mpCommandButton
的按钮,但我通过检查其“唯一”名称来确保它是我想要的那个。
QWidget *source = new oldWidgetToRecycle(this);
QList<QWidget *> possibleParts = source->findChildren<QWidget *>("mpCommandButton");
foreach (QWidget *w, possibleParts)
{
if (uniqueName(w) ==
"/MainWindow/swapOut/SourceWidget/frame/frame_2/mpCommandButton")
{
// Found it.
mpUi->mpDestination->addWidget(w);
break;
}
}
此时,这可能听起来像是一种非常有限的技术,但它有一些很好的应用:
- 您可以编辑现有的 Qt 控件而不进行子类化。
- 您可以编辑旧控件并调整布局样式。
- 您可以将两个旧控件组合起来,交错它们的布局,以获得新的体验。
- 您可以使通常布局矮而宽的旧控件在窗口调整大小时变得高而瘦。
- 您可以使应用程序布局可由用户编辑(将可移动的内容重新父化到面板,将面板 ID 与
uniqueName
、size
、location
一起保存)。
历史
- 2016年6月29日:初始版本