Qt 与 .NET 的集成是一项备受追捧的功能。鉴于WPF 未来的不确定性,利益相关者转向像 Qt 这样久经考验的 UI 框架来确保其项目和现有 .NET 资产的未来发展,这并不令人意外。自 2000 年代初推出以来, .NET已从其专有的、以 Windows 为中心的起源发展成为一个面向多个平台和应用领域的免费开源软件框架。这使得提供一种现代且实用的方法来集成 .NET 和 Qt 变得更加重要。
这就是我们在 Qt/.NET 中提出的建议,因此它将成为本系列三篇博客文章的主题:
在 Qt 应用程序中托管 .NET 代码(本帖)
向 WPF 应用程序添加 QML 视图(即将推出)
Raspberry Pi OS 中的 Qt 和 Azure IoT(即将推出)
Qt/.NET 是一个独立的、仅包含头文件的 C++ 库,需要 Qt 6 和 .NET 运行时(v6 或更高版本)。项目源位于code.qt.io和github。
本机互操作性
通常,.NET 中的本机互操作性是通过平台调用服务 (P/Invoke)实现的,它有两种形式:显式和隐式。显式 P/Invoke 允许程序集 (即 .NET DLL) 中的托管代码直接调用本机 DLL 中的 C 样式函数。除了最简单的情况外,显式 P/Invoke 都需要注意低级集成问题,例如调用约定、类型安全、内存分配等。因此,显式 P/Invoke 更适合对本机库 (例如 Windows API) 进行零星函数调用。
通过隐式 P/Invoke 实现互操作相当于使用C++/CLI链接托管代码和本机代码,这确实具有隐藏显式 P/Invoke 公开的许多低级集成细节的优势。但是,此选项依赖于特定于平台(“ C++/CLI 是 Windows 操作系统特定的技术”)的闭源工具链。这样的限制实际上违背了与 Qt 等多平台开源框架集成的目的。
使用 P/Invoke 实现托管/本机互操作性。
自定义 .NET 主机
无论是显式还是隐式,P/Invoke 都假定托管/本机互操作的主动权始终在 .NET 代码一方。另一种方法是“翻转 P/Invoke 脚本”,即为.NET 运行时实现自定义本机主机,并使用该自定义主机与托管代码进行交互。.NET
应用程序需要本机主机作为入口点(如果没有其他入口点的话)来启动公共语言运行时 (CLR)。CLR 是应用程序虚拟机,它为托管代码提供运行上下文,包括 JIT 编译器和垃圾收集器。默认“引导”主机通常是构建 .NET 应用程序时生成的 .exe 的一部分。但本机应用程序也可以通过 .NET本机托管 API实现自己的自定义主机。结果是,通过 .NET 托管 API,本机主机能够获取对 .NET 方法的引用,并使用这些引用调用托管代码,从而有效地实现本机/托管互操作性。
从本机代码的角度来看,对方法的引用(在上图中用ⓕ符号标识)是可用于直接调用托管代码的函数指针。从 .NET 方面来看,方法引用表示为委托。为了获取对 .NET 静态方法的引用,主机调用托管 API 的查找函数,提供目标程序集的路径、类型名称、方法名称以及最终关联的委托类型作为输入。
从最基本的层面上讲,Qt/.NET 库公开了这种自定义 .NET 主机的实现,包括方法引用查找功能。查找的结果是类的一个实例QDotNetFunction<TResult, TArg…>,该实例是一个函子,它封装了解析的函数指针并负责处理任何所需的参数和返回值编组。
namespace FooLib
{
public class Foo
{
public static string FormatNumber(string format, int number)
{
return string.Format(format, number);
}
public delegate string FormatNumberDelegate(string format, int number);
}
}
QDotNetHost host;
QDotNetFunction<QString, QString, int> formatNumber;
QString fileName = "FooLib.dll";
QString typeName = "FooLib.Foo, FooLib";
QString methodName = "FormatNumber";
QString delegateTypeName = "FooLib.Foo+FormatNumberDelegate, FooLib";
host.resolveFunction(formatNumber, fileName, typeName, methodName, delegateTypeName);
QString answer = formatNumber("The answer is {0}", 42); // --> "The answer is 42"
使用 Qt/.NET 主机将 .NET 静态方法解析为函数指针。
本机/托管适配器
因此,Qt/.NET 主机实现本身就足以调用托管代码。但是,这仅适用于静态方法,并且需要在与目标方法相同的程序集中定义兼容的委托类型。为了解决这些限制,引入了一个适配器模块,该模块能够在运行时生成实例化方法引用和解析相应函数指针所需的委托类型。适配器还负责弥合本机/托管鸿沟所需的其他几项任务,例如:
.NET 对象引用的生命周期,确保本机代码使用的托管对象不会被垃圾收集器删除,并且不会留下任何可能阻止垃圾收集并导致内存泄漏的悬垂引用。
.NET 事件的订阅和通知,在 .NET 端生成存根事件处理程序,然后在引发订阅的事件时调用本机回调。
基于自定义 .NET 主机和本机/托管适配器的互操作性。
适配器本身不打算直接从用户代码调用。相反,Qt/.NET C++ API 通过提供映射到相应托管实体的高级代理类型(例如 、 等)来封装适配器接口的QDotNetType细节QDotNetObject。
QDotNetType string = QDotNetType::find("System.String");
QDotNetFunction concat = string.staticMethod<QString, QString, QString>("Concat");
QString answer = concat("The answer is ", "42"); // --> "The answer is 42"
使用 Qt/.NET API 调用静态方法。
QDotNetFunction<QDotNetObject> newStringBuilder
= QDotNetObject::constructor("System.Text.StringBuilder");
QDotNetObject stringBuilder = newStringBuilder();
QDotNetFunction append = stringBuilder.method<QDotNetObject, QString>("Append");
append("The answer is ");
append("42");
QString answer = stringBuilder.toString(); // --> "The answer is 42"
使用 Qt/.NET API 创建管理对象并调用实例方法。
.NET 对象作为 QObject
为了实现本机代码和托管代码之间的无缝集成,可以扩展该类QDotNetObject以在 C++ 中定义包装器类,其实例可以用作 .NET 对象的代理。这样,本机/托管互操作性的任何细节都对调用代码完全隐藏。
class StringBuilder : public QDotNetObject
{
public:
Q_DOTNET_OBJECT_INLINE(StringBuilder, "System.Text.StringBuilder");
StringBuilder() : QDotNetObject(constructor<StringBuilder>().invoke())
{ }
StringBuilder append(const QString &str)
{
return method("Append", fAppend).invoke(*this, str);
}
private:
QDotNetFunction<StringBuilder, QString> fAppend;
};
StringBuilder sb;
sb.append("The answer is ").append("42");
QString answer = sb.toString(); // --> "The answer is 42"
tringBuilder .NET 类的包装器。
扩展两者QDotNetObject并QObject允许在 Qt 应用程序中使用 .NET 对象的代理。例如,这包括将 .NET 事件的通知映射到 Qt 信号的发射,从而可以将 .NET 事件连接到 Qt 插槽。
class Ping : public QObject, public QDotNetObject, public QDotNetObject::IEventHandler
{
Q_OBJECT
public:
Q_DOTNET_OBJECT_INLINE(Ping, "System.Net.NetworkInformation.Ping, System");
Ping() : QDotNetObject(constructor<Ping>().invoke())
{
subscribeEvent("PingCompleted", this);
}
void sendAsync(const QString &hostNameOrAddress)
{
method("SendAsync", fnSendAsync).invoke(*this, hostNameOrAddress, nullptr);
}
signals:
void pingCompleted(QString address, qint64 roundtripTime);
private:
void handleEvent(
const QString &evName, QDotNetObject &evSrc, QDotNetObject &evArgs) override
{
auto reply = evArgs.method<QDotNetObject>("get_Reply");
auto replyAddress = reply().method<QDotNetObject>("get_Address");
auto replyRoundtrip = reply().method<qint64>("get_RoundtripTime");
emit pingCompleted(replyAddress().toString(), replyRoundtrip());
}
QDotNetFunction<void, QString, QDotNetNull> fnSendAsync;
};
Ping ping;
bool waiting = true;
QObject::connect(&ping, &Ping::pingCompleted,
[&waiting](QString address, qint64 roundtripMsecs)
{
qInfo() << "Reply from" << address << "in" << roundtripMsecs << "msecs";
waiting = false;
});
for (int i = 0; i < 4; ++i) {
waiting = true;
ping.sendAsync("www.qt.io");
while (waiting)
QCoreApplication::processEvents();
}
Console output:
// Reply from "..." in 18 msecs
// Reply from "..." in 14 msecs
// Reply from "..." in 13 msecs
// Reply from "..." in 12 msecs
QObject .NET 类的包装器Ping,包括将事件转换为信号。
.NET 模块的 QML UI
我们用 Qt/.NET 存储库中包含的示例项目的摘录来结束这篇文章Chronometer。我们将使用这些摘录逐步说明如何实现为现有 .NET 模块提供 UI 的 QML 应用程序。
public class Chronometer : INotifyPropertyChanged
{
…
public double ElapsedSeconds { get { … } }
public void StartStop()
{
…
}
}
Chronometer.NET 类(摘录)。
上面的 C# 代码片段对应于我们想要为其提供 UI 的现有 .NET 资产。它由一个天文钟模型组成,其属性对应于各个指针的位置,方法表示使用天文钟时可以采取的操作。为简单起见,我们将仅显示与属性ElapsedSeconds(即天文钟的秒针)相关的代码(以黄色突出显示)和与StartStop方法(即开始和停止按钮)相关的代码(以橙色突出显示)。
请注意,该类Chronometer实现了INotifyPropertyChanged接口,这意味着它将能够通过引发事件来通知其属性的更改PropertyChanged。此机制用于属性绑定,尤其是在 WPF 中。
步骤 1:定义包装类
我们首先定义包装器类 ( QChronometer) 的接口,该接口将充当托管类的本机代理Chronometer。.NET 类的属性映射到相应的 Qt 属性,这最终意味着实现相关的READ函数和NOTIFY信号。.NET 类的方法映射到插槽。
class QChronometer : public QObject, public QDotNetObject
{
Q_OBJECT
…
Q_PROPERTY(double elapsedSeconds READ elapsedSeconds NOTIFY elapsedSecondsChanged)
public:
Q_DOTNET_OBJECT(QChronometer, "WatchModels.Chronometer, ChronometerModel");
QChronometer();
~QChronometer() override;
…
double elapsedSeconds() const;
signals:
…
void elapsedSecondsChanged();
public slots:
void startStop();
…
};
第 2 步:实现包装器类
包装类的各个部分的实现都需要进行如下的操作。
QChronometer构造函数:
调用.NET 类的构造函数并存储对象引用。
订阅该"PropertyChanged"活动。
startStop投币口:
调用"StartStop"引用的.NET 对象的方法。
(所有其他插槽都以相同方式实现。)
elapsedSeconds属性读取函数:
调用"get_ElapsedSeconds"引用的.NET 对象的方法。
返回 .NET 方法最初返回的值。
(所有其他属性读取函数都以相同的方式实现。)
事件处理程序(handleEvent回调):
将事件参数投射给PropertyChangedEventArgs类。
如果修改的属性是"ElapsedSeconds",则发出elapsedSecondsChanged信号。
(所有其他属性改变事件都以相同的方式处理。)
struct QChronometerPrivate : QDotNetObject::IEventHandler
{
…
QDotNetFunction<double> elapsedSeconds = nullptr;
QDotNetFunction<void> startStop = nullptr;
…
void handleEvent(
const QString &eventName, QDotNetObject &sender, QDotNetObject &args) override
{
if (args.type().fullName() != QDotNetPropertyEvent::FullyQualifiedTypeName)
return;
const auto propertyChangedEvent = args.cast<QDotNetPropertyEvent>();
…
if (propertyChangedEvent.propertyName() == "ElapsedSeconds")
emit q->elapsedSecondsChanged();
…
}
};
Q_DOTNET_OBJECT_IMPL(QChronometer,
Q_DOTNET_OBJECT_INIT(d(new QChronometerPrivate(this))));
QChronometer::QChronometer() : d(new QChronometerPrivate(this))
{
*this = constructor<QChronometer>().invoke();
subscribeEvent("PropertyChanged", d);
}
…
double QChronometer::elapsedSeconds() const
{
return method("get_ElapsedSeconds", d->elapsedSeconds).invoke(*this);
}
…
void QChronometer::startStop()
{
method("StartStop", d->startStop).invoke(*this);
}
…
步骤 3:在 QML 中使用代理
在 QML UI 规范中,假设“ chrono”属性对应于代表 .NET 对象的包装器对象,我们可以使用它的属性和插槽来实现 UI。该elapsedSeconds属性将与 .NET 对象的属性同步ElapsedSeconds,用于计算秒针手柄的旋转角度。“开始/停止”按钮的点击信号将连接到startStop包装器的插槽,从而调用StartStop.NET 对象的方法。
Window {
property QtObject chrono
…
//
// Stopwatch seconds hand
Image {
id: secondsHand;
source: "second_hand.png"
transform: Rotation {
origin.x: 250; origin.y: 250
angle: chrono.elapsedSeconds * 6
Behavior on angle {
SpringAnimation { spring: 3; damping: 0.5; modulus: 360 }
}
}
}
…
//
// Stopwatch start/stop button
Button {
id: buttonStartStop
x: 425; y: 5
buttonText: "Start\n\nStop"; split: true
onClicked: chrono.startStop()
}
…
}
步骤 4:整合所有内容
在应用程序的主函数中,创建包装器类的实例,从而触发创建托管类的相应实例Chronometer。包装器作为“ ”属性QChronometer添加到 QML 引擎中。chrono
int main(int argc, char *argv[])
{
QGuiApplication app(argc, argv);
QQmlApplicationEngine engine;
QChronometer chrono;
engine.setInitialProperties({ {"chrono", QVariant::fromValue(&chrono)} });
engine.load(QUrl(QStringLiteral("qrc:/main.qml")));
if (engine.rootObjects().isEmpty())
return -1;
return app.exec();
}
下面的屏幕截图显示了Chronometer在 Visual Studio 调试会话中运行的示例。按下“开始/停止”按钮,启动计时器机制。经过的秒数被转换为秒针的旋转。
以上文章翻译自 qt官方博客