Qt/.NET — Hosting .NET code in a Qt application (1/3)

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.

That is what we're proposing with Qt/.NET, so it will be the topic of this three-part series of blog posts:

  1. Hosting .NET code in a Qt application (this post)

  2. Adding a QML view to a WPF application (coming soon)

  3. Qt and Azure IoT in the Raspberry Pi OS (coming soon)

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.

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.

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.

Managed/native interoperability using P/Invoke.Managed/native interoperability using P/Invoke.

Custom .NET host

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.

.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.

Native/managed interoperability through a custom .NET host.Native/managed interoperability through a custom .NET host.

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.

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.

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.

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:

  • 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.
  • 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.

Interoperability based on a custom .NET host and a native/managed adapter.

Interoperability based on a custom .NET host and a native/managed adapter.

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.

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.

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.

.NET object as 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.

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.

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.

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.

QML UI for a .NET module

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.

public class Chronometer : INotifyPropertyChanged

{

   

    public double ElapsedSeconds { get { } }

    public void StartStop()

    {

       

    }

}

Chronometer .NET class (excerpt).

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.

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.

Step 1: Defining a wrapper class

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.

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

The following actions are required of the implementation of each part of the wrapper class.

QChronometer constructor:
  • Invoke constructor of the .NET class and store object reference.
  • Subscribe to the "PropertyChanged" event.
startStop slot:
  • Invoke the "StartStop" method of the referenced .NET object.
  • (All other slots are implemented in the same way.) 
elapsedSeconds property read function:
  • Invoke the "get_ElapsedSeconds" method of the referenced .NET object.
  • Return the value that was originally returned by the .NET method.
  • (All other property read functions are implemented in the same way.) 
Event handler (handleEvent callback):
  • Cast event arguments to the PropertyChangedEventArgs class.
  • If the modified property was the "ElapsedSeconds", emit the elapsedSecondsChanged signal.
    • (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

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.

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

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.

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.

Chronometer example running in a Visual Studio debug session.

 


Blog Topics:

Comments