Qt/.NET — Hosting .NET code in a Qt application (1/3)
Qt/.NET-在Qt应用程序中托管.NET代码(1/3)
June 12, 2023 by Miguel Costa | Comments
2023年6月12日:Miguel Costa |评论
Integration of Qt with .NET is an often sought-after feature. Given the uncertainty regarding the future of WPF, it is not surprising to see stakeholders turning to a time-tested UI framework like Qt as a way to future-proof their projects and existing .NET assets. Since its introduction in the early 2000's, .NET has evolved from its proprietary, Windows-centric origins into a free and open-source software framework, targeting multiple platforms and application domains. This makes it all the more relevant to offer a modern and practical way to integrate .NET and Qt.
将Qt与.NET集成是一个经常受到追捧的功能。考虑到WPF未来的不确定性,看到利益相关者转向像Qt这样经过时间测试的UI框架来证明他们的项目和现有.NET资产的未来性并不奇怪。自2000年代初推出以来,.NET已经从其以Windows为中心的专有起源发展成为一个面向多个平台和应用程序域的免费开源软件框架。这使得提供一种现代实用的集成.NET和Qt的方法变得更加重要。
That is what we're proposing with Qt/.NET, so it will be the topic of this three-part series of blog posts:
这就是我们对Qt/.NET的建议,因此它将成为这三部分系列博客文章的主题:
-
Hosting .NET code in a Qt application (this post)
-
在Qt应用程序中托管.NET代码(本文)
-
Adding a QML view to a WPF application (coming soon)
-
向WPF应用程序添加QML视图(即将推出)
-
Qt and Azure IoT in the Raspberry Pi OS (coming soon)
-
树莓派Pi操作系统中的Qt和Azure物联网(即将推出)
Qt/.NET is a standalone, header-only, C++ library that requires Qt 6 and the .NET runtime (v6 or greater). Project sources are located at code.qt.io and github.
Qt/.NET是一个独立的、仅限标头的C++库,需要Qt 6和.NET运行时(v6或更高版本)。项目来源位于code.qt.io和github。
Native interoperability
本机互操作性
Typically, native interoperability in .NET is achieved through the Platform Invocation Services, or P/Invoke, of which there are two flavors, explicit and implicit. Explicit P/Invoke allows managed code in an assembly (i.e., a .NET DLL) to call directly into C-style functions inside native DLL's. In all but the simplest of cases, explicit P/Invoke requires awareness of low-level integration issues, such as calling convention, type safety, memory allocation, etc. Explicit P/Invoke is, therefore, better suited for making sporadic function calls into native libraries, such as the Windows API.
通常,.NET中的本机互操作性是通过平台调用服务(P/Invoke)实现的,其中有两种类型,显式和隐式。显式P/Invoke允许程序集中的托管代码(即.NET DLL)直接调用本机DLL中的C风格函数。在最简单的情况下,显式P/Invoke需要了解低级集成问题,例如调用约定、类型安全、内存分配等。因此,显式P/Invoke更适合对本机库(如Windows API)进行零星的函数调用。
Interoperability through implicit P/Invoke corresponds to using C++/CLI to link managed and native code, which does have the advantage of hiding many of the low-level integration details exposed by explicit P/Invoke. However, this option relies on a platform-specific ("C++/CLI is a Windows OS specific technology"), closed-source toolchain. Such a limitation effectively defeats the purpose of integrating with a multi-platform, open-source framework like Qt.
通过隐式P/Invoke实现的互操作性对应于使用C++/CLI链接托管代码和本机代码,这确实具有隐藏显式P/Invoke暴露的许多低级别集成细节的优势。但是,此选项依赖于特定于平台(“C++/CLI是特定于Windows操作系统的技术”)的闭源代码工具链。这样的限制实际上违背了与Qt这样的多平台开源框架集成的目的。
Managed/native interoperability using P/Invoke.
使用P/Invoke的托管/本机互操作性。
Custom .NET host
自定义.NET主机
Whichever the flavor, explicit or implicit, P/Invoke assumes that the initiative of managed/native interop is always on the side of .NET code. An alternative to this, one that "flips the script" on P/Invoke, is to implement a custom native host for the .NET runtime, and use that custom host to interact with managed code.
无论是显式还是隐式,P/Invoke都假定托管/本机互操作的主动权始终在.NET代码一边。另一种选择是在P/Invoke上“翻转脚本”,为.NET运行时实现一个自定义的本机主机,并使用该自定义主机与托管代码交互。
.NET applications require a native host as an entry-point, if nothing else, to start up the Common Language Runtime (CLR). The CLR is the application virtual machine that provides the running context for managed code, including a JIT compiler and garbage collector. A default "bootstrap" host is usually a part of the .exe that is generated when building a .NET application. But a native application can also implement its own custom host by means of the .NET native hosting API. The upshot is that, through the .NET hosting API, a native host is able to obtain references to .NET methods, and use those references to call into managed code, effectively achieving native/managed interoperability.
.NET应用程序需要一个本机主机作为启动公共语言运行时(CLR)的入口点(如果没有其他东西的话)。CLR是为托管代码提供运行上下文的应用程序虚拟机,包括JIT编译器和垃圾收集器。默认的“引导程序”主机通常是生成.NET应用程序时生成的.exe的一部分。但本机应用程序也可以通过.NET本机宿主API实现自己的自定义宿主。结果是,通过.NET宿主API,本机主机能够获得对.NET方法的引用,并使用这些引用调用托管代码,从而有效地实现本机/托管互操作性。
Native/managed interoperability through a custom .NET host.
通过自定义.NET主机实现本机/托管互操作性。
From the perspective of native code, references to methods (identified in the figure above with the ⓕ glyph) are function pointers that can be used to directly invoke managed code. From the .NET side of things, a method reference is represented as a delegate. To obtain a reference to a .NET static method, the host calls a lookup function of the hosting API, providing as input the path to the target assembly, the type name, method name, and finally the associated delegate type.
从本机代码的角度来看,对方法的引用(在上图中用ⓕ字形标识)是可以用于直接调用托管代码的函数指针。从.NET的角度来看,方法引用表示为委托。为了获取对.NET静态方法的引用,主机调用宿主API的查找函数,提供目标程序集的路径、类型名、方法名以及最后关联的委托类型作为输入。
At the most fundamental level, the Qt/.NET library exposes an implementation of such a custom .NET host, including facilities for method reference lookup. The result of the lookup is an instance of the QDotNetFunction<TResult, TArg...>
class, which is a functor that encapsulates the resolved function pointer and takes care of any required marshaling of parameters and return value.
在最基本的层次上,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"
Using the Qt/.NET host to resolve a .NET static method into a function pointer.
使用Qt/.NET主机将.NET静态方法解析为函数指针。
Native/managed adapter
本机/托管适配器
The Qt/.NET host implementation is thus, on its own, sufficiently capable of calling into managed code. However, that only works for static methods, and requires that a compatible delegate type be defined in the same assembly as the target method. To work around these limitations, an adapter module is introduced which is able to generate, at run-time, the delegate types needed to instantiate method references and resolve the corresponding function pointers. The adapter is also responsible for several other tasks needed to bridge the native/managed divide, such as:
因此,Qt/.NET主机实现本身就足以调用托管代码。但是,这只适用于静态方法,并且要求在与目标方法相同的程序集中定义兼容的委托类型。为了解决这些限制,引入了一个适配器模块,该模块能够在运行时生成实例化方法引用和解析相应函数指针所需的委托类型。适配器还负责弥合本机/托管鸿沟所需的其他几个任务,例如:
- Life-cycle of references to .NET objects, making sure that managed objects used by native code are not removed by the garbage collector, and also that no dangling references remain which could prevent garbage collection and cause memory leaks.
- 对.NET对象的引用的生命周期,确保本机代码使用的托管对象不会被垃圾收集器删除,并且不会保留任何悬挂引用,这可能会阻止垃圾收集并导致内存泄漏。
- Subscription and notification of .NET events, generating stub event handlers on the .NET side, which will then invoke native callbacks when subscribed events are raised.
- 订阅和通知.NET事件,在.NET端生成存根事件处理程序,然后在引发订阅事件时调用本机回调。
Interoperability based on a custom .NET host and a native/managed adapter.
基于自定义.NET主机和本机/托管适配器的互操作性。
The adapter itself is not intended to be called directly from user code. Instead, the Qt/.NET C++ API encapsulates details of the adapter's interface by providing high-level proxy types (e.g. QDotNetType
, QDotNetObject
, etc.) that map to corresponding managed entities.
适配器本身不打算直接从用户代码中调用。相反,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"
Using the Qt/.NET API to call a static method.
使用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"
Using the Qt/.NET API to create a managed object and call an instance method.
使用Qt/.NET API创建托管对象并调用实例方法。
.NET object as QObject
.NET对象作为QObject
To achieve a seamless integration between native and managed code, it's possible to extend the QDotNetObject
class to define wrapper classes in C++ whose instances can function as proxies for .NET objects. This way, any details of the native/managed interoperability are completely hidden from calling code.
为了实现本机代码和托管代码之间的无缝集成,可以扩展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"
Wrapper for the StringBuilder .NET class.
StringBuilder.NET类的包装器。
Extending both QDotNetObject
and QObject
allows proxies of .NET objects to be used in Qt applications. This includes, for example, mapping notification of .NET events to emission of Qt signals, making it possible to connect .NET events to Qt slots.
扩展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
wrapper for the Ping
.NET class, including conversion of events into signals.
Ping.NET类的QObject包装器,包括将事件转换为信号。
QML UI for a .NET module
用于.NET模块的QML UI
We conclude this post with excerpts from the Chronometer
example project, which is included in the Qt/.NET repository. We'll use these excerpts to illustrate, step by step, how to implement a QML application that provides a UI for an existing .NET module.
最后,我们摘录了Chronometer示例项目,该项目包含在Qt/.NET存储库中。我们将使用这些摘录来逐步说明如何实现为现有.NET模块提供UI的QML应用程序。
public class Chronometer : INotifyPropertyChanged
{
…
public double ElapsedSeconds { get { … } }
public void StartStop()
{
…
}
}
Chronometer
.NET class (excerpt).
NET类(摘录)。
The above snippet of C# code corresponds to an existing .NET asset which we want to provide a UI for. It consists of a model of a chronometer, with properties that correspond to the position of the various hands, and methods that represent the actions that can be taken when using a chronometer. For simplicity, we'll show only the code related to the ElapsedSeconds
property (i.e. the seconds hand of the chronometer), highlighted in yellow, and to the StartStop
method (i.e. the start and stop button), highlighted in orange.
上面的C#代码片段对应于我们想要为其提供UI的现有.NET资产。它包括一个计时器模型,具有与各种指针的位置相对应的属性,以及表示使用计时器时可以采取的行动的方法。为了简单起见,我们将只显示与ElapsedSeconds属性(即计时器的秒针)相关的代码,以黄色突出显示,以及与StartStop方法(即启动和停止按钮)相关的编码,以橙色突出显示。
Note that the Chronometer
class implements the INotifyPropertyChanged
interface, which means it will be able to notify changes to its properties by raising the PropertyChanged event. This mechanism is used for property binding, notably in WPF.
请注意,Chronometer类实现了INotifyPropertyChanged接口,这意味着它将能够通过引发PropertyChanged事件来通知对其属性的更改。这种机制用于属性绑定,特别是在WPF中。
Step 1: Defining a wrapper class
步骤1:定义包装类
We start by defining the interface of the wrapper class (QChronometer
) that will function as a native proxy for the managed Chronometer
class. Properties of the .NET class are mapped to corresponding Qt properties, which ultimately means implementing the associated READ
functions and NOTIFY
signals. Methods of the .NET class are mapped to slots.
我们首先定义包装类(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();
…
};
Step 2: Implementing the wrapper class
步骤2:实现包装器类
The following actions are required of the implementation of each part of the wrapper class.
包装类的每个部分的实现都需要以下操作。
QChronometer
constructor:
QChronometer
构造函数:
- Invoke constructor of the .NET class and store object reference.
- 调用.NET类的构造函数并存储对象引用。
- Subscribe to the
"PropertyChanged"
event. - 订阅“PropertyChanged”事件。
startStop
slot:
startStop槽:
- Invoke the
"StartStop"
method of the referenced .NET object. - 调用引用的.NET对象的“StartStop”方法。
- (All other slots are implemented in the same way.)
- (所有其他槽都以相同的方式实现。)
elapsedSeconds
property read function:
elapsedSeconds属性读取函数:
- Invoke the
"get_ElapsedSeconds"
method of the referenced .NET object. - 调用引用的.NET对象的“get_ElapsedSeconds”方法。
- Return the value that was originally returned by the .NET method.
- 返回.NET方法最初返回的值。
- (All other property read functions are implemented in the same way.)
- (所有其他属性读取函数都以相同的方式实现。)
Event handler (handleEvent
callback):
事件处理程序(handleEvent回调):
- Cast event arguments to the PropertyChangedEventArgs class.
- 将事件参数强制转换为PropertyChangedEventArgs类。
- If the modified property was the
"ElapsedSeconds"
, emit theelapsedSecondsChanged
signal. - 如果修改后的属性是“ElapsedSeconds”,则发出elapsedSecondsChanged信号。
- (All other property change events are handled in the same way.)
- (所有其他属性更改事件的处理方式相同。)
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);
}
…
Step 3: Using the proxy in QML
步骤3:在QML中使用代理
In the QML UI specification, assuming the "chrono
" property corresponds to the wrapper object representing the .NET object, we can use its properties and slots to implement the UI. The elapsedSeconds
property, which will be synchronized with the ElapsedSeconds
property of the .NET object, will be used to calculate the rotation angle of the seconds handle. The clicked signal of a "Start/Stop" button will be connected to the startStop
slot of the wrapper, which invokes the StartStop
method of the .NET object.
在QML UI规范中,假设“chrono”属性对应于表示.NET对象的包装器对象,我们可以使用其属性和槽来实现UI。将与.NET对象的elapsedSeconds属性同步的elapsedSeconds属性将用于计算秒句柄的旋转角度。“启动/停止”按钮的点击信号将连接到包装器的startStop槽,该插槽调用.NET对象的startStop方法。
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()
}
…
}
Step 4: Putting it all together
第4步:把所有的东西放在一起
In the application's main function, an instance of the wrapper class is created, which triggers the creation of the corresponding instance of the managed Chronometer
class. The QChronometer
wrapper is added to the QML engine as the "chrono
" property.
在应用程序的主函数中,将创建包装类的一个实例,这将触发托管Chronometer类的相应实例的创建。QCchroometer包装作为“chrono”属性添加到QML引擎中。
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();
}
The screen capture below shows the Chronometer
example running in a Visual Studio debug session. The Start/Stop button was pressed, starting the chronometer mechanism. The number of elapsed seconds is translated into the rotation of the seconds handle.
下面的屏幕截图显示了在VisualStudio调试会话中运行的Chronometer示例。按下启动/停止按钮,启动计时器机构。经过的秒数转换为秒控制柄的旋转。