こんにちは。田原です。

前回、QtはQt WidgetsとQt Quickの2つの方法でGUI開発でき当講座ではQt Quickを用いることを説明しました。
Qt QuickではGUIをQML(Qt Meta-Object Language)というドキュメント記述言語で記述します。つまりC++とQML間でデータのやり取りが必要になります。そのための重複した複数の方法があり、どれを使いのが良いか悩ましいと思います。そこで、私なりに整理し使い勝手の良さそうな方法にまとめてみました。

1.目標

  1. C++側のGUI制御部をクラス(Windowクラス)にまとめ、かつ、できるだけ他の部分から独立させます
    経験的に、GUI部はロジックから適度に分離(注1)した方がメンテナンスしやすいプログラムになります。しかし、多くのQMLサンプル・ソースはQQmlApplicationEngine(QMLを解釈し処理するエンジン)をmain.cppで生成しているので、全てのウィンドウがmain.cppに依存してしまいます。これは避けたいです。
  2. GUI部はQMLで記述しC++側から制御します
    Qt QuickのGUI部をC++で書くことができると思いますが、鬼のように苦労しそうです。しかしQMLで記述すると比較的容易にGUIを記述できます。(デザイナーを使えるとよりお手軽なのですが、QMLデザイナーはQMLの強力さに十分には対応出来ておらず現実的ではありません。)
    QMLは実は非常に強力で、V8というJavaScriptエンジン上のJavaScriptを使えるため、C++を使わないでもそれなりに高度なアプリを開発できます。しかし、我々はC++のGUI開発ツールとしてQMLを使いますので、C++側からQMLで記述したGUIを制御できる必要があります。
  3. C++側のWindowクラスからQML側のGUIオブジェクトを容易に制御したいです
    C++側とQML側の通信方法としてQtは様々な方法を用意しています。あまりにも多いのでどれを使えばよいのか結構悩みます。
    そこで、その中でもプログラムの開発の手間が少ないと思われる方法を選んでみました。

(注1)
むやみに密結合するとGUIの小変更に伴いロジック側も修正することになりメンテナンスしづらくなります。また手間を無視して可能な限り分離してしまうとインタフェースが複雑になりメンテナンスしづらくなります。なので「適度」に疎結合することが重要です。そのバランスは意外に難しいのですが。

2.それぞれの目標を実現する方法についての解説

2-1. 独立性の高いGUI制御用のC++側Windowクラス

Qt Widgetsの場合、 main.cpp から独立して各ウィンドウを設計できます。
しかし、Qt QuickはQQmlApplicationEngineを必要とします。ほとんどのQMLサンプルはこれをmain.cppで確保しているのですが、それらと同じ構造で実装するとGUI制御用のクラスがmain.cppに依存してしまいます。

それが本当に嫌だったのですが、実は簡単に解決することが分かりました。QQmlApplicationEngineのインスタンスを各ウィンドウ毎に確保するだけでした。いや~、てっきりこれはできないと思いこんでいました。
もちろん、その分のメモリやスレッドを多く消費する可能性はあります。調べていないので分かりませんが、許容できる程度であることを期待しています。プロジェクトによってはそれが許容できない場合もあるかもしれません。その際は QQmlApplicationEngine をシングルトン等で確保することで対処できるだろうと思います。
しかし、複雑な構造のサンプルにするのは如何なものかと思いますので、ウィンドウ毎に QQmlApplicationEngine を割り当てる構造にしました。

2-2. GUI部はQMLで記述しC++側から制御する

これはざっくり2つの課題があります。
– QMLオブジェクトの生成と破棄をC++側からどうやって行うのか?
– QMLオブジェクトをC++側からどうやって制御するのか?

2-2-1. QMLオブジェクトの生成と破棄をC++側から行う方法

最初はQt Widgetsのuiののように、C++側でGUIをコンストラクト/デストラクトできると思っていたのですが、Qt Quickでは残念ならができません。
QML側のオブジェクトは「プロトタイプ・ベース」のオブジェクトですのでクラスとインスタンス間の明確な差がなく、コンストラクト/デストラクトという考え方自体ありません。これに対してC++側は「クラス・ベース」ですので、クラスをコンストラクトしてインスタンスを生成し、不要になればデストラクトします。(前回の解説を参照下さい。)
この相違をどう処理するのか? 結構悩みました。

QML側のGUIオブジェクトはQQmlApplicationEngineにQMLでGUIを記述した .qml ファイルをロードした時点で生成されます。
ということは、2-1でQQmlApplicationEngineをC++側のWindowクラスに属させることにしたので、このWindowクラスのコンストラクタで .qmlファイルをロードしておけば、後はWindowクラスのデストラクタで自動的に破棄されます。なんだ実は簡単でした。

ところで、QMLオブジェクトをcreate/destoroyする方法が公式のドキュメント にちゃんと書いてありました。
従ってQQmlApplicationEngineインスタンスを1つに統合したい場合でも対処方法はありそうです。

2-2-2. QML側オブジェクトとC++側クラスとの関係について(ちょっと寄り道)

QML側のオブジェクトには、対応するC++側クラスが存在する場合があります。
例えば、QML側オブジェクトのルートの1つである QtObject QML Type に対応するC++側クラスは QObject です。

GUIの場合、QML側のGUIオブジェクトに Windows QML Type があります。それに対応するC++側のクラスは QQuickWindow Class です。他にも対応関係のあるGUIオブジェクトが多数ありますが、当初はWindowとQQuickWindowを把握しておけば事足りると思います。

なお、前回軽く触れたように QML Type は他の QML Type を拡張して定義できますが、そのような QML Type に対応するC++側のクラスは存在しません。
しかし、その拡張元となった QML Type が存在する筈です。それは公式のリファレンスのInherits:欄に記載されています。その中にC++側と対応している QML Type もある筈です。
例えば、ApplicationWindow QML Type の Inherits: には Window と帰されていますので、その拡張元は Window QML Type です。

対応するC++クラスについて
上記の Window QML Type のページには、「Instantiates: QQuickWindow」という記載があります。QMLのWindowオブジェクトに対応するC++側クラスはQQuickWindowという意味です。Instantiates(インスタンス化)として記述されているのは、恐らくQMLオブジェクトはC++クラスのインスタンスに相当するからだろうと思います。(QMLは「プロトタイプ・ベース」ですので。)
また、QQuickWindow Class のページには、「Instantiated By: Window」という記述があります。

さて、ApplicationWindow QML Type のページには、「Instantiates」の記載がありません。つまり、ApplicationWindowは他のQML Typeを拡張したQML Typeということと思います。

2-2-3. QML側オブジェクトをC++側からどうやって制御するのか?

さて、QML側オブジェクトに対応するC++側クラスが存在しますので、QML側オブジェクトへのポインタをC++側で獲得できればQML側オブジェクトをC++側から制御できます。
また、直接存在しない場合でも、QML側オブジェクトの拡張元オブジェクトのどれかは対応するC++クラスが存在する筈です。そこまで遡れば制御できます。

そのポインタの取得方法は簡単です。
QQmlApplicationEngine::rootObjects().first()が、QQmlApplicationEngineへロードしている .qmlファイルのルート・オブジェクトへのポインタを QObject* 型で返却します。これを当該QMLオブジェクトに対応する C++側のクラスへのポインタへ dynamic_castして確保すれば良いです。

例えば、以下のような .qmlファイルを用意します。このままならタイトルに「Hello World」と表示されます。

main.qml
01
02
03
04
05
06
07
08
09
import QtQuick 2.12
import QtQuick.Window 2.12
 
Window {
    visible: true
    width: 640
    height: 480
    title: qsTr("Hello World")
}

次のようなmain.cppでQQmlApplicationEngine にロードし、QQuickWindow*ポインタを獲得して、タイトルを書き換えてました。
これを走らせると、タイトルに「Hello QML!」と表示されます。

main.cpp
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
#include <QGuiApplication>
#include <QQmlApplicationEngine>
#include <QQuickWindow>
 
int main(int argc, char *argv[])
{
    QCoreApplication::setAttribute(Qt::AA_EnableHighDpiScaling);
 
    QGuiApplication app(argc, argv);
 
    QQmlApplicationEngine engine;
    const QUrl url("qrc:/main.qml");
    engine.load(url);
    QQuickWindow* aWindow=dynamic_cast<QQuickWindow*>(engine.rootObjects().first());
    aWindow->setTitle("Hello QML!");
 
    return app.exec();
}
作成方法
QtCreatorを起動して、「ファイル(F)」 → 「ファイル/プロジェクトの新規作成(N)」 で開くダイアログで 「アプリケーション」 → 「Qt Quick Application – Empty」を選択して作成したプロジェクトの main.cppを書き換えたものです。(エラー処理を削除して、14-15行を追加)

さて、制御方法は大別して2種類あります。
1. C++側クラスに用意されているメンバを用いて行う制御
上述の通りです。
2. QML側クラスで定義したプロパティ、シグナル、関数を用いて行う制御
こちらを次節で解説します。

2-3. C++側のWindowクラスからQML側のGUIオブジェクトをお手軽に制御

C++側をマスター、QML側をスレーブとして考えると、以下の1., 2.を実現できれば基本的な制御はできます。
1. C++からQML側の関数を呼び出す
2. QML側で発生したイベントをC++側へ通知する
3. また、関数やイベントを用意するのではなく、QML側のメンバ変数をC++側リード/ライトしたい時もある

以上の3つを比較的容易に行う方法をまとめました。

2-3-1. C++からQML側の関数を呼び出す

これは簡単です。QMLオブジェクト側に、JavaScript関数を定義し、それをC++側から対応するクラス・ポインタ経由でQMetaObject ::invokeMethod()を使って呼び出します。
(数に制限はありますが)引数を渡し、戻り値を受け取ることができます。相手はJavaScript関数ですので引数や戻り値の型は QVariant を使うとお手軽です。

後述のサンプルでは、MainWindow.cppの「MainWindow::button0Slot()関数」から、MainWindow.qmlの「setTextInput0()関数」を呼び出しています。

2-3-2. QML側で発生したイベントをC++側へ通知する

Qtはイベントを通知する仕組みとして Signal/Slot という便利な方法を用意しています。
それを利用するのが良いと思います。QML側オブジェクトでsignalを定義し、C++側でconnectして使います。(逆に、C++側からQML側へシグナルを送ることも可能ですが手間がかかります。invokeMethodを使う方をお勧めします。)

sinal/slotのconnectの説明を始めると長くなるので、後日の解説に譲りますが、connectの書き方はSIGNAL()/SLOT()マクロを使う方法と使わない方法の2種類があります。QMLのシグナルを受け取るにはSIGNAL()/SLOT()マクロを使う方のみ使用できますのでご留意下さい。

後述のサンプルでは、MainWindow.cppの「MainWindowのコンストラクタ」でconnectし、MainWindow.qmlで「button0Signalシグナル」を定義し、button0のonClickedイベントで呼び出しています。

2-3-3. QML側のメンバ変数をC++側からリード/ライトする

QML側オブジェクトの「メンバ変数」は「プロパティ」と呼ばれています。QMLオブジェクトは他のQMLオブジェクトを内包できますので、アクセスしたいプロパティを持っているQMLオブジェクトをfindしてからproperty()関数やsetProperty()関数でリード/ライトすることができます。
しかし、それは結構面倒です。
既にC++側でポインタを獲得しているQML側オブジェクトに、該当オブジェクトのプロパティを別名プロパティとして定義するとお手軽にアクセスできます。

後述のサンプルでは、MainWindow.qmlで「label0Text別名プロパティ」を定義し、MainWindow.cppの「MainWindowのコンストラクタ」の頭でsetProperty()関数で書き込んでいます。(読み出す時は、property()関数を用います。)

3.サンプル・ソース

サンプル・ソース(雛形的なもの)を作成しました。
QtCreatorを起動して、「ファイル(F)」 → 「ファイル/プロジェクトの新規作成(N)」 で開くダイアログで 「アプリケーション」 → 「Qt Quick Application – Empty」を選択して作成したプロジェクト修正しました。

3-1. main.cpp

見ての通りWindow(MainWindow)側はmain.cppに依存していません。

#include <QGuiApplication>
#include "MainWindow.h"
int main(int argc, char *argv[])
{
QCoreApplication::setAttribute(Qt::AA_EnableHighDpiScaling);
try
{
QGuiApplication app(argc, argv);
MainWindow aMainWindow;
aMainWindow.show();
return app.exec();
}
catch (MyException& e)
{
qDebug() << "MyException :" << e.what();
}
return EXIT_FAILURE;
}
view raw main.cpp hosted with ❤ by GitHub

サンプルなのでエラー処理は不要とは思いましたが、僅かなコードなのでMyExceptionを導入してみました。QExceptionから派生しています。手抜きですがWindowBase.hにて定義しています。

3-2. WindowBase.h, WindowBase.cpp

定形処理を行うための基底クラスです。
QQmlApplicationEngine(mQmlEngine)を確保、.qmlファイル(iQmlFile)をロード、QML側のルート・オブジェクトへのポインタ(mQmlWindow)をC++側へ取り込んでいます。
mQmlWindowは、様々なメンバ関数を持っていますので、それらを呼び出すための中継関数を定義すると使いやすくなると思います。サンプルでは、show()とhide()のみ定義しています。

#ifndef WINDOW_BASE_H
#define WINDOW_BASE_H
#include <QObject>
#include <QQmlApplicationEngine>
#include <QQuickWindow>
#include <QException>
// ***************************************************************************
// QML用ウインドウ・ベース
// ***************************************************************************
//----------------------------------------------------------------------------
// エラー通知用
//----------------------------------------------------------------------------
class MyException : public QException
{
public:
MyException(QString const& iReason) : mReason(iReason.toUtf8()) { }
void raise() const override { throw *this; }
MyException *clone() const override { return new MyException(*this); }
char const* what() const noexcept override { return mReason.data(); }
private:
QByteArray mReason;
};
//----------------------------------------------------------------------------
// 本体
//----------------------------------------------------------------------------
class WindowBase : public QObject
{
Q_OBJECT
Q_DISABLE_COPY(WindowBase)
public:
explicit WindowBase(QString const iQmlFile);
~WindowBase();
// 中継
void show() { mQmlWindow->show(); }
void hide() { mQmlWindow->hide(); }
protected:
QQuickWindow* mQmlWindow;
signals:
private slots:
private:
QQmlApplicationEngine mQmlEngine;
// デバッグ用
void enumeration(QObject const* iObject, int iLevel=0);
};
#endif // WINDOW_BASE_H

#include <QCoreApplication>
#include <QDebug>
#include "WindowBase.h"
// ***************************************************************************
// コンストラクタ
// ***************************************************************************
WindowBase::WindowBase(QString const iQmlFile) :
QObject(nullptr),
mQmlWindow(nullptr)
{
// QMLのロード
const QUrl url(iQmlFile);
connect(&mQmlEngine, &QQmlApplicationEngine::objectCreated, this,
[url](QObject *obj, const QUrl &objUrl)
{
if (!obj && url == objUrl)
{
throw MyException("Failed to load qml : " + url.toString());
}
}, Qt::QueuedConnection);
mQmlEngine.load(url);
for (QObject const* obj : mQmlEngine.rootObjects())
{
enumeration(obj);
}
// Windowポインタ獲得
mQmlWindow = dynamic_cast<QQuickWindow*>(mQmlEngine.rootObjects().first());
if (!mQmlWindow)
{
throw MyException("Not found QQuickWindow : " + url.toString());
}
}
// ***************************************************************************
// デストラクタ
// ***************************************************************************
WindowBase::~WindowBase()
{
}
// ***************************************************************************
// デバッグ用
// ***************************************************************************
void WindowBase::enumeration(QObject const* iObject, int iLevel)
{
QMetaObject const* aMetaObject = iObject->metaObject();
qDebug() << QByteArray(iLevel*4, ' ').data() << aMetaObject->className() << " :" << iObject->objectName();
QObjectList const aObjList = iObject->children();
if (aObjList.size() == 0)
return;
for (QObject const* obj : aObjList)
{
enumeration(obj, iLevel+1);
}
}

WindowBase::enumeration()はデバッグ用の関数です。QMLファイルの構成を表示します。QMetaObject::className()はC++側のクラス名が返ってくるようです。QObject::objectName()は、QMLオブジェクトで定義している objectName の値が返ってきます。 id の値ではありません。

3-3. MainWindow.qml, MainWindow.h, MainWindow.cpp

これらが実際にQMLでウィンドウを定義し、C++が制御する部分のサンプル・ソースです。
2-3.の解説、および、WindowBase::enumeration()の結果とMainWindow.qmlと見比べてみて下さい。

import QtQuick 2.15
import QtQuick.Window 2.15
import QtQuick.Controls 2.15
import QtQuick.Layouts 1.15
ApplicationWindow
{
objectName: "root"
visible: false
width: 640
height: 480
title: qsTr("Hello World")
// C++ I/F
property alias label0Text: label0.text
signal button0Signal(string iMessage);
function setTextInput0(iText)
{
console.log("setTextInput0():", textInput0.text, " -> ", iText)
textInput0.text=iText
return "returnString";
}
// コンテンツ
ColumnLayout
{
Text
{
id: label0
objectName: "label0"
text: qsTr("デフォルト")
font
{
pixelSize: 20
bold: true
}
}
Button
{
id: button0
objectName: "button0"
text: "Add"
font
{
pixelSize: 20
bold: true
}
onClicked:
{
console.log("button0Signal(): ", textInput0.text)
button0Signal(textInput0.text)
}
}
TextInput
{
id: textInput0
objectName: "textInput0"
text: "Text"
font
{
pixelSize: 20
bold: true
}
}
}
}

#ifndef MAIN_WINDOW_H
#define MAIN_WINDOW_H
#include "WindowBase.h"
class MainWindow : public WindowBase
{
Q_OBJECT
Q_DISABLE_COPY(MainWindow)
public:
explicit MainWindow();
~MainWindow();
signals:
private slots:
void button0Slot(QString const& iMessage);
private:
};
#endif // MAIN_WINDOW_H

#include <QCoreApplication>
#include "MainWindow.h"
// ***************************************************************************
// コンストラクタ
// ***************************************************************************
MainWindow::MainWindow() :
WindowBase("qrc:/MainWindow.qml")
{
mQmlWindow->setProperty("label0Text", u8"C++から設定した文字列");
connect(mQmlWindow, SIGNAL(button0Signal(QString)),
this, SLOT(button0Slot(QString)));
}
// ***************************************************************************
// デストラクタ
// ***************************************************************************
MainWindow::~MainWindow()
{
}
// ***************************************************************************
// button0の処理
// ***************************************************************************
void MainWindow::button0Slot(QString const& iMessage)
{
qDebug() << "button0Slot():" << iMessage;
QVariant returnValue;
bool ret = QMetaObject::invokeMethod
(
mQmlWindow, "setTextInput0",
Qt::DirectConnection,
Q_RETURN_ARG(QVariant, returnValue),
Q_ARG(QVariant, iMessage+"@")
);
qDebug() << " " << ret << returnValue;
}

3-4. その他

cpp-qml-if.proはQtが提供するmakefile生成ツール qmake 用の設定ファイルです。CMakeのCMakeLists.txtに該当します。(qmakeはCMakeに比べると使い勝手はいまいちな印象ですが、CMakeよりはQtとの相性が良いと思います。)
qml.qrcはリソース・ファイルです。ビルドされたバイナリ・ファイル内に.qmlファイルをリソースとして組み込んでいます。

その他のファイル
QT += quick
CONFIG += c++11
# The following define makes your compiler emit warnings if you use
# any Qt feature that has been marked deprecated (the exact warnings
# depend on your compiler). Refer to the documentation for the
# deprecated API to know how to port your code away from it.
DEFINES += QT_DEPRECATED_WARNINGS
# You can also make your code fail to compile if it uses deprecated APIs.
# In order to do so, uncomment the following line.
# You can also select to disable deprecated APIs only up to a certain version of Qt.
#DEFINES += QT_DISABLE_DEPRECATED_BEFORE=0x060000 # disables all the APIs deprecated before Qt 6.0.0
SOURCES += \
WindowBase.cpp \
main.cpp \
MainWindow.cpp
HEADERS += \
MainWindow.h \
WindowBase.h
RESOURCES += qml.qrc
# Additional import path used to resolve QML modules in Qt Creator's code model
QML_IMPORT_PATH =
# Additional import path used to resolve QML modules just for Qt Quick Designer
QML_DESIGNER_IMPORT_PATH =
# Default rules for deployment.
qnx: target.path = /tmp/$${TARGET}/bin
else: unix:!android: target.path = /opt/$${TARGET}/bin
!isEmpty(target.path): INSTALLS += target

<RCC>
<qresource prefix="/">
<file>MainWindow.qml</file>
</qresource>
</RCC>
view raw qml.qrc hosted with ❤ by GitHub

当サンプルは boost software license で配布します
©2020 Theoride Technology (http://theolizer.com/) All Rights Reserved.
Boost Software License - Version 1.0 - August 17th, 2003
Permission is hereby granted, free of charge, to any person or organization
obtaining a copy of the software and accompanying documentation covered by
this license (the "Software") to use, reproduce, display, distribute,
execute, and transmit the Software, and to prepare derivative works of the
Software, and to permit third-parties to whom the Software is furnished to
do so, all subject to the following:
The copyright notices in the Software and this entire statement, including
the above license grant, this restriction and the following disclaimer,
must be included in all copies of the Software, in whole or in part, and
all derivative works of the Software, unless such copies or derivative
works are solely in the form of machine-executable object code generated by
a source language processor.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE, TITLE AND NON-INFRINGEMENT. IN NO EVENT
SHALL THE COPYRIGHT HOLDERS OR ANYONE DISTRIBUTING THE SOFTWARE BE LIABLE
FOR ANY DAMAGES OR OTHER LIABILITY, WHETHER IN CONTRACT, TORT OR OTHERWISE,
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.

ざっくり意訳
あなたの責任で自由に使って下さい。ソース・コードを配布する時は下記の著作権表示を含めて下さい。バイナリで配布する時は著作権表示を含めなくて良いです。
©2020 Theoride Technology (http://theolizer.com/) All Rights Reserved.
(ざっくり意訳と英語版原本が矛盾する場合は、英語版原本を優先します。)

4.最後に

QMLはC++とは別の世界(QMLエンジン上)で動いています。そこは主にJavaScriptの世界です。それとC++間のインタフェースをする必要があるため、Qt Widgetsに比べるとどうにもC++との親和性は低いです。そこに複数の重複したインタフェース方法が実装されているため、かなり悩ましいことが分かりました。
Qtを密に使い始めて2年目にしてやっと見えてきましたので、これなら使い易いだろう方式をサンプル・ソースとしてまとめてみましたので、よかったら参考にしてみて下さい。

今回は、当初思っていたより大作になってしまい、最後は駆け足になってしまいました。分かりにくい部分や何か勘違いしていそうな部分がありましたら、コメント頂けたらと幸いです。

それでは今日はこの辺で。お疲れさまでした。