QtGrpc チャット

チャットルームであらゆる種類のメッセージを共有するためのチャットアプリケーションです。

Chat の例では、QtGrpc クライアント API の高度な使い方を示します。サーバはユーザが登録と認証を行い、ChatRoomに参加できるようにします。一度参加すると、ユーザーはChatRoomでテキストメッセージ、画像、ユーザーのアクティビティ、または他の参加者全員とディスクから他のファイルなど、様々なメッセージタイプを共有することができます。

この例で説明する主なトピックは次のとおりです:

Protobufスキーマ

Protobufスキーマは、チャットアプリケーションで使用されるメッセージとサービスの構造を定義します。スキーマは2つのファイルに分かれています:

syntax = "proto3";

package chat;

import "chatmessages.proto";

service QtGrpcChat {
    // Register a user with \a Credentials.
    rpc Register(Credentials) returns (None);
    // Join as a registered user and exchange \a ChatMessage(s)
    rpc ChatRoom(stream ChatMessage) returns (stream ChatMessage) {}
}

qtgrpcchat.proto ファイルは、2 つの RPC メソッドを提供する QtGrpcChat サービスを指定します:

  • Register:提供されるCredentials にユーザーを登録する。サーバーは、データベースからプレーンテキストでユーザーを保存し、検証します。
  • ChatRoom:接続されているすべてのクライアント間でChatMessage(s)を交換するための双方向ストリームを確立する。サーバーはすべての受信メッセージを他の接続されたクライアントにブロードキャストする。
syntax = "proto3";

package chat;

import "QtCore/QtCore.proto";

message ChatMessage {
    string username = 1;
    int64 timestamp = 2;
    oneof content {
        TextMessage text = 3;
        FileMessage file = 4;
        UserStatus user_status = 5;
    }
}

chatmessages.proto ファイルはChatMessage を定義している。これはタグ付き和集合 (和集合型としても知られる)である。これは、ChatRoom ストリーミング RPC を介して送信可能なすべての個別メッセージを表します。すべてのChatMessage には、送信者を識別するためにusernametimestamp を含める必要があります。

QtProtobufQtCoreTypes モジュールの型を有効にするために、QtCore/QtCore.proto インポートを含め、QtCore 固有の型と Protobuf に相当する型との間のシームレスな変換を可能にしています。

message FileMessage {
    enum Type {
        UNKNOWN = 0;
        IMAGE = 1;
        AUDIO = 2;
        VIDEO = 3;
        TEXT = 4;
    }
    Type type = 1;
    string name = 2;
    bytes content = 3;
    uint64 size = 4;

    message Continuation {
        uint64 index = 1;
        uint64 count = 2;
        QtCore.QUuid uuid = 3;
    }
    optional Continuation continuation = 5;
}

FileMessage は、 sum型でサポートされているメッセージ型の1つです。これは、任意のローカルファイルをメッセージにラップすることができます。オプションの フィールドは、大きなファイル転送をチャンク単位で処理することで、信頼性の高い配信を保証します。ChatMessage Continuation

注: ProtobufスキーマとアプリケーションコードでProtobufQtCoreTypes モジュールを使用する詳細については、以下を参照してください。 Qt Core usage.

サーバー

注: ここで説明するサーバーアプリケーションは gRPCライブラリを使用します。

サーバー・アプリケーションでは、非同期のgRPC コールバック API を使用しています。これにより、手動で完了キューを管理する複雑さを伴うことなく、非同期APIのパフォーマンス上の利点を享受することができる。

class QtGrpcChatService final : public chat::QtGrpcChat::CallbackService

生成されたQtGrpcChat サービスのCallbackService をサブクラス化したQtGrpcChatService クラスを宣言します。

    grpc::ServerBidiReactor<chat::ChatMessage, chat::ChatMessage> *
    ChatRoom(grpc::CallbackServerContext *context) override
    {
        return new ChatRoomReactor(this, context);
    }

    grpc::ServerUnaryReactor *Register(grpc::CallbackServerContext *context,
                                       const chat::Credentials *request,
                                       chat::None * /*response*/) override

サービスによって提供される2つのメソッド(gRPC )の機能を実装するために、仮想関数をオーバーライドします:

  • Register メソッドはユーザーを検証し、プレーンテキストデータベースに格納する。
  • ChatRoom メソッドは、メタデータで提供された認証情報をデータベースと照合する。成功すると、通信用の双方向ストリームを確立する。
    // Broadcast \a message to all connected clients. Optionally \a skip a client
    void broadcast(const std::shared_ptr<chat::ChatMessage> &message, const ChatRoomReactor *skip)
    {
        for (auto *client : activeClients()) {
            assert(client);
            if (skip && client == skip)
                continue;
            client->startSharedWrite(message);
        }
    }

サービス実装は、ChatRoom メソッドを通じて接続または切断したすべてのアクティブなクライアントを追跡する。これにより、接続されているすべてのクライアントとメッセージを共有するbroadcast 機能が有効になる。ストレージとオーバーヘッドを削減するために、ChatMessageshared_ptr でラップされている。

    // Share \a response. It will be kept alive until the last write operation finishes.
    void startSharedWrite(std::shared_ptr<chat::ChatMessage> response)
    {
        std::scoped_lock lock(m_writeMtx);
        if (m_response) {
            m_responseQueue.emplace(std::move(response));
        } else {
            m_response = std::move(response);
            StartWrite(m_response.get());
        }
    }

startSharedWrite ChatRoomReactorリアクター(つまりクライアント)が現在書き込み中の場合、メッセージはキューにバッファされます。そうでない場合は、書き込み操作が開始されます。すべてのクライアント間で共有されるメッセージは一意である。response use_countすべてのクライアントがメッセージの書き込みを終了し、use_count が 0 になると、そのリソースは解放される。

    // Distribute the incoming message to all other clients.
    m_service->broadcast(m_request, this);
    m_request = std::make_shared<chat::ChatMessage>(); // detach
    StartRead(m_request.get());

このスニペットはChatRoomReactor::OnReadDone 仮想メソッドの一部である。このメソッドが呼ばれるたびに、クライアントから新しいメッセージが受信される。メッセージは、送信者をスキップして、他のすべてのクライアントにブロードキャストされる。

    std::scoped_lock lock(m_writeMtx);

    if (!m_responseQueue.empty()) {
        m_response = std::move(m_responseQueue.front());
        m_responseQueue.pop();
        StartWrite(m_response.get());
        return;
    }

    m_response.reset();

このスニペットはChatRoomReactor::OnWriteDone 仮想メソッドの一部である。このメソッドが呼ばれるたびに、メッセージがクライアントに書き込まれます。キューにバッファされたメッセージがあれば、次のメッセージが書き込まれる。そうでない場合、m_response がリセットされ、書き込み操作が進行中でないことを知らせる。ロックは、broadcast メソッドとの競合から保護するために使用されます。

クライアント

クライアントアプリケーションは、提供されたProtobufスキーマを使用してサーバーと通信します。これは、ユーザーを登録し、ChatRoom gRPC メソッドの長寿命の双方向ストリームを処理するためのフロントエンドとバックエンドの両方の機能を提供します。これにより、ChatMessageの可視化と通信が可能になります。

セットアップ
add_library(qtgrpc_chat_client_proto STATIC)
qt_add_protobuf(qtgrpc_chat_client_proto
    QML
    QML_URI QtGrpcChat.Proto
    PROTO_FILES
        ../proto/chatmessages.proto
    PROTO_INCLUDES
        $<TARGET_PROPERTY:Qt6::ProtobufQtCoreTypes,QT_PROTO_INCLUDES>
)

qt_add_grpc(qtgrpc_chat_client_proto CLIENT
    PROTO_FILES
        ../proto/qtgrpcchat.proto
    PROTO_INCLUDES
        $<TARGET_PROPERTY:Qt6::ProtobufQtCoreTypes,QT_PROTO_INCLUDES>
)

まず、Protobufスキーマからソースファイルを生成する。qtgrpcchat.proto ファイルにはmessage 定義が含まれていないため、qtgrpcgen の生成のみが必要です。また、"QtCore/QtCore.proto" のインポートが有効であることを保証するために、ProtobufQtCoreTypes モジュールのPROTO_INCLUDES も提供します。

target_link_libraries(qtgrpc_chat_client_proto
    PUBLIC
        Qt6::Protobuf
        Qt6::ProtobufQtCoreTypes
        Qt6::Grpc
)

独立したqtgrpc_chat_client_proto ターゲットが、ProtobufQtCoreTypes モジュールを含む依存関係に対して公にリンクされていることを確認します。アプリケーション・ターゲットは、このライブラリに対してリンクされる。

バックエンドロジック

アプリケーションのバックエンドは、4つの重要な要素を中心に構築されている:

  • ChatEngine:アプリケーションのロジックを管理するQML向けのシングルトン。
  • ClientWorker:gRPC クライアント機能を非同期に提供するワーカーオブジェクト。
  • ChatMessageModel:ChatMessageの処理と保存のためのカスタムQAbstractListModel
  • UserStatusModel:ユーザーの活動を管理するためのカスタムQAbstractListModel
    explicit ChatEngine(QObject *parent = nullptr);
    ~ChatEngine() override;

    // Register operations
    Q_INVOKABLE void registerUser(const chat::Credentials &credentials);
    // ChatRoom operations
    Q_INVOKABLE void login(const chat::Credentials &credentials);
    Q_INVOKABLE void logout();
    Q_INVOKABLE void sendText(const QString &message);
    Q_INVOKABLE void sendFile(const QUrl &url);
    Q_INVOKABLE void sendFiles(const QList<QUrl> &urls);
    Q_INVOKABLE bool sendFilesFromClipboard();

上のスニペットは、QMLから呼び出されてサーバーとやりとりするQ_INVOKABLE 機能の一部を示しています。

    explicit ClientWorker(QObject *parent = nullptr);
    ~ClientWorker() override;

public Q_SLOTS:
    void registerUser(const chat::Credentials &credentials);
    void login(const chat::Credentials &credentials);
    void logout();
    void sendFile(const QUrl &url);
    void sendFiles(const QList<QUrl> &urls);
    void sendMessage(const chat::ChatMessage &message);

ClientWorker が提供するスロットはChatEngine が公開するAPIを多少ミラーしています。ClientWorker は専用のスレッドで動作し、大きなファイルの送受信などの高価な処理をバックグラウンドで処理します。

m_clientWorker->moveToThread(&m_clientThread);
m_clientThread.start();
connect(&m_clientThread, &QThread::finished, m_clientWorker, &QObject::deleteLater);
connect(m_clientWorker, &ClientWorker::registerFinished, this, &ChatEngine::registerFinished);
connect(m_clientWorker, &ClientWorker::chatError, this, &ChatEngine::chatError);
...

ChatEngine のコンストラクタでは、ClientWorker を専用のワーカースレッドに割り当て、QML 側で利用できるようにシグナルの処理と転送を続けます。

void ChatEngine::registerUser(const chat::Credentials &credentials)
{
    QMetaObject::invokeMethod(m_clientWorker, &ClientWorker::registerUser, credentials);
}
...
void ClientWorker::registerUser(const chat::Credentials &credentials)
{
    if (credentials.name().isEmpty() || credentials.password().isEmpty()) {
        emit chatError(tr("Invalid credentials for registration"));
        return;
    }

    if ((!m_client || m_hostUriDirty) && !initializeClient()) {
        emit chatError(tr("Failed registration: unabled to initialize client"));
        return;
    }

    auto reply = m_client->Register(credentials, QGrpcCallOptions{}.setDeadlineTimeout(5s));
    const auto *replyPtr = reply.get();
    connect(
        replyPtr, &QGrpcCallReply::finished, this,
        [this, reply = std::move(reply)](const QGrpcStatus &status) {
            emit registerFinished(status);
        },
        Qt::SingleShotConnection);
}

これは、ChatEngineClientWorker とどのようにやりとりしてユーザーを登録するかを示しています。ClientWorker は専用のスレッドで実行されるので、invokeMethod を使ってメンバ関数を安全に呼び出すことが重要です。

ClientWorker では、クライアントが初期化されていないか、またはホストURIが変更され ていないかをチェックする。いずれかの条件が満たされた場合、initializeClient を呼び出す。 は新しいQGrpcHttp2Channel を作成する。これは高価な操作であるため、発生を最小限に抑える。

Register RPCを処理するために、setDeadlineTimeout オプションを使用して、サーバーの非アクティブをガードする。一般に、単項 RPC には期限を設定することを推奨する。

void ClientWorker::login(const chat::Credentials &credentials)
{
    if (credentials.name().isEmpty() || credentials.password().isEmpty()) {
        emit chatError(tr("Invalid credentials for login"));
        return;
    }
    ...
    QGrpcCallOptions opts;
    opts.setMetadata({
        { "user-name",     credentials.name().toUtf8()     },
        { "user-password", credentials.password().toUtf8() },
    });
    connectStream(opts);
}

ChatRoom にログインする場合、setMetadata オプションを使用して、認証のためにサーバから要求されるユーザ資格情報を提供する。実際の呼び出しと接続設定は、connectStream メソッドで処理される。

void ClientWorker::connectStream(const QGrpcCallOptions &opts)
{
    ...
    m_chatStream = m_client->ChatRoom(*initialMessage, opts);
    ...
    connect(m_chatStream.get(), &QGrpcBidiStream::finished, this,
            [this, opts](const QGrpcStatus &status) {
                if (m_chatState == ChatState::Connected) {
                    // If we're connected retry again in 250 ms, no matter the error.
                    QTimer::singleShot(250, [this, opts]() { connectStream(opts); });
                } else {
                    setState(ChatState::Disconnected);
                    m_chatResponse = {};
                    m_userCredentials = {};
                    m_chatStream.reset();
                    emit chatStreamFinished(status);
                }
            });
    ...

接続中にストリームが突然終了した場合に備えて、基本的な再接続ロジックを実装しています。これは、単に最初の呼び出しからQGrpcCallOptions を使ってconnectStream を再度呼び出すことで行われる。これにより、必要な接続がすべて更新される。

注: AndroidのDoze/App-Standbyモードは、FileDialogを使用したり、別のアプリに切り替えたりすることなどでトリガーできます。このモードではネットワークアクセスがシャットダウンされ、アクティブなQTcpSocket 接続がすべて閉じられ、ストリームがfinished になります。この問題は、再接続ロジックで対処します。

    コネクト(m_chatStream.get(), &::messageReceived, this)QGrpcBidiStream::messageReceived, this, [this]{ ...switch(m_chatResponse.contentField()) {casechat::ChatMessage::ContentFields::UninitializedField:            qDebug("Received uninitialized message");
           return;casechat::ChatMessage::ContentFields::Text:if(m_chatResponse.text().content().isEmpty())return;break;casechat::ChatMessage::ContentFields::File:// 任意のファイルメッセージをダウンロードし、ダウンロードしたURLを // コンテンツに 保存 し、モデルがそこから参照できるようにする。
            m_chatResponse.file() .setContent(saveFileRequest(m_chatResponse.file()).toString().toUtf8());break; ...chatStreamMessageReceived(m_chatResponse); }); setState(Backend::ChatState::Connecting); }

メッセージが受信されると、ClientWorkerFileMessage の内容を保存するなどの前処理を行い、ChatEngine はモデルだけに集中すればよいようにします。ContentFields enumを使用して、ChatMessage sum型のoneof content フィールドを安全にチェックします。

void ChatEngine::sendText(const QString &message)
{
    if (message.trimmed().isEmpty())
        return;

    if (auto request = m_clientWorker->createMessage()) {
        chat::TextMessage tmsg;
        tmsg.setContent(message.toUtf8());
        request->setText(std::move(tmsg));
        QMetaObject::invokeMethod(m_clientWorker, &ClientWorker::sendMessage, *request);
        m_chatMessageModel->appendMessage(*request);
    }
}
...
void ClientWorker::sendMessage(const chat::ChatMessage &message)
{
    if (!m_chatStream || m_chatState != ChatState::Connected) {
        emit chatError(tr("Unable to send message"));
        return;
    }
    m_chatStream->writeMessage(message);
}

メッセージを送信するとき、ChatEngine は適切にフォーマットされたリクエストを作成します。例えば、sendText メソッドはQString を受け取り、createMessage 関数を使用して、usernametimestamp フィールドが設定された有効なメッセージを生成します。その後、クライアントがメッセージを送信するために呼び出され、そのコピーがChatMessageModel にエンキューされます。

QMLフロントエンド
import QtGrpc
import QtGrpcChat
import QtGrpcChat.Proto

QMLのコードでは、以下のインポートが使われています:

  • QtGrpc:StatusCode のようなQtGrpc QML の機能を提供します。
  • QtGrpcChat:ChatEngine シングルトンのようなコンポーネントを含むアプリケーションモジュール。
  • QtGrpcChat.Proto:生成されたprotobuf型へのQMLアクセスを提供します。
    Connections {
        target: ChatEngine
        function onChatStreamFinished(status) {
            root.handleStatus(status)
            loginView.clear()
        }
        function onChatStateChanged() {
            if (ChatEngine.chatState === Backend.ChatState.Connected && mainView.depth === 1)
                mainView.push("ChatView.qml")
            else if (ChatEngine.chatState === Backend.ChatState.Disconnected && mainView.depth > 1)
                mainView.pop()
        }
        function onRegisterFinished(status) {
            root.handleStatus(status)
        }
        function onChatError(message) {
            statusDisplay.text = message
            statusDisplay.color = "yellow"
            statusDisplay.restart()
        }
    }

Main.qml では、ChatEngine が発するコアシグナルを処理します。これらのシグナルのほとんどはグローバルに処理され、アプリケーションのどの状態でも可視化されます。

Rectangle {
    id: root

    property credentials creds
    ...
    ColumnLayout {
        id: credentialsItem
        ...
        RowLayout {
            id: buttonLayout
            ...
            Button {
                id: loginButton
                ...
                enabled: nameField.text && passwordField.text
                text: qsTr("Login")
                onPressed: {
                    root.creds.name = nameField.text
                    root.creds.password = passwordField.text
                    ChatEngine.login(root.creds)
                }
            }

protobufスキーマから生成されたメッセージタイプは、QML_VALUE_TYPE(メッセージ定義のキャメルケースバージョン)であるため、QMLでアクセス可能です。LoginView.qmlcredentials の値型プロパティを使ってloginChatEngine で開始します。

        ListView {
            id: chatMessageView
            ...
            component DelegateBase: Item {
                id: base

                required property chatMessage display
                default property alias data: chatLayout.data
                ...
            }
            ...
            // We use the DelegateChooser and the 'whatThis' role to determine
            // the correct delegate for any ChatMessage
            delegate: DelegateChooser {
                role: "whatsThis"
                ...
                DelegateChoice {
                    roleValue: "text"
                    delegate: DelegateBase {
                        id: dbt
                        TextDelegate {
                            Layout.fillWidth: true
                            Layout.maximumWidth: root.maxMessageBoxWidth
                            Layout.preferredHeight: implicitHeight
                            Layout.bottomMargin: root.margin
                            Layout.leftMargin: root.margin
                            Layout.rightMargin: root.margin

                            message: dbt.display.text
                            selectionColor: dbt.lightColor
                            selectedTextColor: dbt.darkColor
                        }
                    }
                }

ChatView.qml では、ListView のメッセージをChatRoom に表示します。ChatMessage の和型を条件付きで扱う必要があるため、少し複雑になります。

これを処理するために、DelegateChooserを使用します。DelegateChooserは、メッセージのタイプに基づいて適切なデリゲートを選択することができます。モデルのデフォルトのwhatThis ロールを使用し、各ChatMessage インスタンスのメッセージタイプを提供します。DelegateBase コンポーネントは、モデルのdisplay ロールにアクセスし、chatMessage データをレンダリングできるようにします。

TextEdit {
    id: root

    required property textMessage message

    text: message.content
    color: "#f3f3f3"
    font.pointSize: 14
    wrapMode: TextEdit.Wrap
    readOnly: true
    selectByMouse: true
}

以下は、TextMessage 型を視覚化するコンポーネントの1つです。これは、テキストを視覚化するためにprotobufモジュールのtextMessage value型を使用しています。

                TextArea.flickable: TextArea {
                    id: inputField

                    function sendTextMessage() : void {
                        if (text === "")
                            return
                        ChatEngine.sendText(text)
                        text = ""
                    }
                    ...
                    Keys.onPressed: (event) => {
                        if (event.key === Qt.Key_Return && event.modifiers & Qt.ControlModifier) {
                            sendTextMessage()
                            event.accepted = true
                        } else if (event.key === Qt.Key_V && event.modifiers & Qt.ControlModifier) {
                            if (ChatEngine.sendFilesFromClipboard())
                                event.accepted = true
                        }
                    }

チャットクライアントは、以下のようなメッセージ送信における様々なアクセスポイントを提供します:

  • アプリケーションにドロップされたファイルの受信。
  • <Ctrl + V>でQClipboardに保存されているものを送信します。
  • <Ctrl + Enter> でQClipboardからメッセージを送信します。inputField
  • の送信ボタンをクリックする。inputField
  • FileDialogでファイルを選択する

SSL

サーバーとクライアント間の通信を保護するために、SSL/TLS暗号化が使用されます。これには最低でも以下のものが必要です:

  • 秘密鍵:サーバーの秘密鍵が含まれ、安全な接続を確立するために使用される。秘密鍵は秘密にしておかなければならず、決して共有してはならない。
  • 証明書:サーバーの公開証明書が含まれ、サーバーの身元を確認するためにクライアントと共有される。通常、認証局(CA)によって署名されるが、テスト目的で自己署名することもできる。
  • オプションのルートCA証明書:カスタム認証局(CA)を使用してサーバー証明書に署名する場合、ルートCA証明書は、サーバーの証明書チェーンを検証するためにクライアント側で必要となります。カスタムCAのルート証明書は、パブリックCAのようにクライアントのトラストストアにプリインストールされていないため、クライアントがサーバー証明書を信頼できることを保証します。

OpenSSLを使用してこれらのファイルを作成し、SSL/TLSを使用するようにgRPC

        grpc::SslServerCredentialsOptions sslOpts;
        sslOpts.pem_key_cert_pairs.emplace_back(grpc::SslServerCredentialsOptions::PemKeyCertPair{
            LocalhostKey,
            LocalhostCert,
        });
        builder.AddListeningPort(QtGrpcChatService::httpsAddress(), grpc::SslServerCredentials(sslOpts));
        builder.AddListeningPort(QtGrpcChatService::httpAddress(), grpc::InsecureServerCredentials());

秘密鍵と証明書をgRPC サーバーに提供します。それを使って、SslServerCredentials を構築し、サーバー側でTLSを有効にします。安全な通信に加えて、暗号化されていないアクセスも許可します。

サーバーは以下のアドレスでリッスンする:

  • HTTPS :0.0.0.0:65002
  • HTTP :0.0.0.0:65003

サーバーは、0.0.0.0 にバインドして、すべてのネットワーク・インタフェースをリッスンし、同じネットワーク上のどのデバイスからでもアクセスできるようにします。

if(m_hostUri.scheme()== "https") {if(QSslSocket::supportsSsl()) { chatError(tr("デバイスはSSLをサポートしていません。 'http'スキームを使用してください。"))をemit;return false; } もし(!    QFilecrtFile(":/res/root.crt");if(!crtFile.open( ::ReadOnly)) { emit chatError(tr("デバイスはSSLをサポートしていません。QFile::ReadOnly)){の場合        qFatal("Unable to load root certificate");
       戻り値 false; }.  QSslConfigurationsslConfig;    QSslCertificatecrt(crtFile.readAll()); sslConfig.addCaCertificate(crt); sslConfig.setProtocol(QSslsslConfig.setProtocols(::TlsV1_2OrLater); sslConfig.setAllowedNextProtocols({"h2"});// HTTP/2を許可する // ホスト名検証を無効にして、任意のローカルIPからの接続を許可する QSslSocket::VerifyNone); opts.setSslConfiguration(sslConfig); }

CA を自己署名したので、クライアントはルート CA 証明書をロードします。この証明書は、QSslCertificate を作成するために使用される。HTTP/2を使用しているため、setAllowedNextProtocols"h2" プロトコルを提供することが重要である。

例の実行

  • qtgrpc_chat_server が実行され、正常にリスンしていることを確認する。
  • サーバーと同じマシン上にいる場合は、デフォルトのlocalhost アドレスで十分です。qtgrpc_chat_client を実行する場合、サーバーをホストしているデバイス以外のデバイスを使用している場合は、設定ダイアログでサーバーを実行しているホストの正しい IP アドレスを指定してください。
  • クライアントでGRPC_CHAT_USE_EMOJI_FONT CMakeオプションが有効になっていることを確認し、スムーズな絵文字エクスペリエンス🚀でビルドしてください。

からサンプルを実行するには Qt Creatorからサンプルを実行するには、Welcome モードを開き、Examples からサンプルを選択します。詳細については、Qt Creator:チュートリアルを参照してください:ビルドと実行

サンプル・プロジェクト @ code.qt.io

© 2025 The Qt Company Ltd. Documentation contributions included herein are the copyrights of their respective owners. The documentation provided herein is licensed under the terms of the GNU Free Documentation License version 1.3 as published by the Free Software Foundation. Qt and respective logos are trademarks of The Qt Company Ltd. in Finland and/or other countries worldwide. All other trademarks are property of their respective owners.