Building Cross-Language Apps: Direct QML to gRPC Without C++

- qt grpc qml golang

TLDR: A recent Qt update let you do gRPC calls directly from QML, allowing to interact with gRPC from the UI without requiring C++.

Qt is a rich ecosystem for UI development, but its roots are in C++.

QML is a declarative language specialized in designing user interface applications on top of Qt.

Qt supported gRPC for a long time but only via C++ code, recently they added gRPC calls directly from QML/Qt Quick.
It made me want to try if I could create an app with the UI being 100% QML for the frontend and use any other languages as “backend”.

I’m using Go here but any languages capable of being called by C would do.

gRPC

Let’s define a very simple gRPC API; it will output a latitude and longitude with a unary call, and a stream call outputting the same data:

service LocationService {
    rpc Position(PositionRequest) returns (PositionResponse);
    rpc StreamPosition(PositionRequest) returns (stream PositionResponse);
}

Implement the two functions defined in LocationService using vanilla gRPC & Go:

func (s *Server) Position(ctx context.Context, req *pb.PositionRequest) (*pb.PositionResponse, error) {
	lat, lng, _, _ := s.prop.Position(time.Now())
	return &pb.PositionResponse{
		DeviceId: req.GetDeviceId(),
		Longitude: float32(lng),
		Latitude:  float32(lat),
	}, nil
}

The application will output the current position of the ISS, to make it more fun.

The code that is computing the position behind the scene is actually a Go wrapper to a C++ library, full circle, but this is not important here.

Let’s create some Go code that can be exposed as a bridging C library:

var cs *CServer

type CServer struct {
	gServer *grpc.Server
	*locationsrv.Server
}

// Start starts the listening grpc server and
// returns the tcp port on which it is listening to
//
//export Start
func Start() int {
	cs = &CServer{
		Server: locationsrv.New(),
	}

	lis, err := net.Listen("tcp", "localhost:0") // Port 0 means random available port
	if err != nil {
		log.Printf("failed to listen: %v\n", err)
		return 0
	}

	addr := lis.Addr().(*net.TCPAddr)
	port := addr.Port

	go func() {
		grpcServer := grpc.NewServer()
		pb.RegisterLocationServiceServer(grpcServer, cs)
		cs.gServer = grpcServer
		reflection.Register(grpcServer)
		grpcServer.Serve(lis)
	}()

	return port
}

This function starts a gRPC server and return the port as an int.
Note the comment keyword export, it will export Start() int to the C world.

Build the code as a C library:

CGO_ENABLED=1 go build -buildmode=c-archive -o cposlib.a ./cposlib.go

Qt

Let’s switch to the Qt world now. We just need four more lines to the default C++ main.cpp.

...
#include "cposlib.h" // import the header for the Go library

int main(int argc, char *argv[])
{
    QGuiApplication app(argc, argv);
    QQmlApplicationEngine engine;

    QObject::connect(
        &engine, &QQmlApplicationEngine::objectCreationFailed, &app,
        []() {
            QCoreApplication::exit(-1);
            Stop(); // stop backend here
        }, Qt::QueuedConnection);

    int port = Start(); // start backend here

    engine.rootContext()->setContextProperty("initialPort", port); // set the port value into the QML context
    engine.loadFromModule("goqtgrpc", "Main");

    return app.exec();
}

Invoke the C library using Start() and pass the port into QML using a property named initialPort.

CMake

Point the grpc plugin to the proto file:

qt_add_protobuf(goqtgrpc_plugin
    GENERATE_PACKAGE_SUBFOLDERS
    PROTO_FILES
        api/proto/locationsvc/v1/pos.proto
    QML
)

qt_add_grpc(goqtgrpc_plugin CLIENT
    PROTO_FILES
        api/proto/locationsvc/v1/pos.proto
    QML
)

Link to the grpcplugin and the C library:

target_link_libraries(MyApp
    PRIVATE
        Qt6::Protobuf
        Qt6::Grpc
        Qt6::GrpcQuick
        ...
        ${CMAKE_CURRENT_SOURCE_DIR}/cposlib/cposlib.a
        goqtgrpc_plugin
)

target_include_directories(MyApp PRIVATE cposlib)

QML

From now on anything else will be done using QML.

At first, following the documentation, I was defining the GrpcHttp2Channel from the app root, but the hostUri can’t be modified after instantiation, and the port is still 0 when the app starts.
It needs to be dynamically instantiated later with the port we received from initPort:

    function createGrpcClient(hostUri: string): LocationServiceClient {
        var grpcChannel = Qt.createQmlObject(`
            import QtGrpc
            GrpcHttp2Channel {
                hostUri: "${hostUri}"
            }
        `, root, "dynamicGrpcChannel");

        // Create the LocationServiceClient and explicitly set its channel
        var grpcClient = Qt.createQmlObject(`
            import locationsvc.v1
            LocationServiceClient {
                id: client
            }
        `, root, "dynamicGrpcClient");

        // Explicitly set the channel for the client
        grpcClient.channel = grpcChannel.channel;

        return grpcClient;
    }

Let’s try a unary call:

    property int port: initialPort || 0
    property LocationServiceClient grpcClient

    function requestUnaryPosition(deviceId: string): void {
        root.responseText = "";
        root.posReq.deviceId = deviceId;

        // Create a new gRPC client with the desired hostUri only if the root one is nil
        if (!root.grpcClient) {
            root.grpcClient = createGrpcClient(`http://localhost:${port}`);
        }

        // Define the callbacks for the stream
        var unaryCallbacks = {
            // Callback for handling unary errors
            errorOccurred: function(error) {
                root.responseText += "error: " + error.message + "\n";
            },

            // Callback for handling unary completion
            finished: function(response) {
                receivedPosition.latitude = response.latitude;
                receivedPosition.longitude = response.longitude;
                map.center = receivedPosition; // Update the map center
            }
        };

        // Make the gRPC unary call
        grpcClient.Position(root.posReq, unaryCallbacks.finished, unaryCallbacks.errorOccurred, grpcCallOptions);
    }

If the client does not exist already, instantiate it using port which is set to initialPort, then sets the callbacks.

For the streamed calls:

    function requestStreamPosition(deviceId: string): void {
        if (!root.grpcClient) {
            root.grpcClient = createGrpcClient(`http://localhost:${port}`);
        }

        // Define the callbacks for the stream
        var streamCallbacks = {
            // Callback for handling each position response
            positionReceived: function(response) {
                receivedPosition.latitude = response.latitude;
                receivedPosition.longitude = response.longitude;
                map.center = receivedPosition; // Update the map center
            },

            // Callback for handling stream errors
            errorOccurred: function(error) {
                root.responseText += "Stream error: " + error.message + "\n";
            },

            // Callback for handling stream completion
            finished: function() {
                root.responseText += "Stream finished.\n";
            }
        };

        // Make the gRPC stream call
        grpcClient.StreamPosition(root.posReq, streamCallbacks.positionReceived, streamCallbacks.errorOccurred, streamCallbacks.finished, grpcCallOptions);
    }

Connect those functions to UI actions, buttons…

Pitfalls

Some notes:

Conclusion

That’s it, we have a way to work with the UI part in QML and deal with gRPC with our own language for anything else. Of course doing everything via gRPC is less natural, but the truth is most of the UI interaction can be handled with js in QML, complex computation via gRPC. It could be a viable solution for many projects.

Image of the demo app

You can find a fully working test project over here.