バグっていたので修正しました → QQuickImageProviderを使うとプログラム終了時に不正終了する問題
背景
最近、さる事情でQt(https://ja.wikipedia.org/wiki/Qt)を使ったGUIアプリをよく書くようになったが、日本語で書かれた入門向けの解説やサンプルコードがまだまだ少ない(特にQt5.x)ので、お遊び程度だけど簡単なサンプルを公開しようかと思った。(お正月休みでちょっと時間があったので書いてみたともいう)
題材
マンデルブロー集合を描画して、GUI操作で描画範囲を調整できたりする簡単なアプリを作ってみる。
(フラクタルは昔から好きで暇があればいろんな言語で書いて遊んでいた)
マンデルブロー集合に関する情報はこの辺
フラクタル一般についてはこの辺
環境
- Windows10デスクトップアプリ
- Qt5.12.4 LGPL版+Qt Creator4.11.0
- Visual Studio 2017 Community 64bit
事前準備
Visual Studioのインストール
Qt本体をインストールする前にコンパイラをインストールしておくと、Qtが自動的にコンパイラを検出して設定を行ってくれるので楽。
このあたりから適当に持ってきてインストールする。無料のCommunity版でOK
Qtのインストール
ここからQtをインストールする。商用版もあるがLGPLのオープンソース版でOK。
2020年1月現在、最新LTSのQt5.12.6をインストールする。今回の例では先ほどインストールしたVisual Studioのコンパイラを使用するが、MinGWを使ってもOK
使用したいQtのバージョン、コンパイラにチェックを入れてインストールするが、面倒ならQt5.12.6以下をすべてチェックしてしまってもいいかもしれない(ディスクは結構食うけど)
以降、Qtの開発には公式IDEのQt Creatorを使用するので、Developer and Designer Tools以下のQt Creator 4.11.0にもチェックを入れておく。
スタートメニューからQt Creatorを起動し、Projects→New→Qt Quick Application - Emptyを選択してコンパイル→実行ができるか試してみる。
Build Systemは何も考えずqmakeを選択。
Kitsはコンパイル設定なのでVisual Studioのコンパイラを使う設定 Qt 5.12.4(msvc2017_64) にチェックを入れて先に進む。(VSのバージョンが違う場合、MinGWを使う場合は適宜読み替える)
そうするとIDEの画面上に以下のようなQML(Qt Modeling Language)のコードが生成される。
これはこのまま実行可能で、空のウインドウを表示するだけだがたったこれだけのコードでWindowsのGUIアプリケーションが作れるのでなかなかお手軽である。
これはQMLというQtのGUIフロントエンドを記述するための専用言語で、基本的にはJavaScriptの文法を流用した宣言型言語になっている。
import QtQuick 2.12
import QtQuick.Window 2.12
Window {
visible: true
width: 640
height: 480
title: qsTr("Hello World")
}
このような空のウインドウが描画されれば、Qtのインストールは正常にできている。
アプリの仕様
以下のような画面構成で、マンデルブロー集合を計算して描画する。
左側ペイン
- 計算結果を描画する。右側ペインへの入力の結果、再描画が必要であれば自動的に再描画する。
- マウスドラッグで指定範囲を拡大表示可能
右側ペイン
- 複素平面の実軸、虚軸の範囲をそれぞれ指定可能。
- 初期値は実軸、虚軸ともに-2.0~2.0
- ZoomIn/ZoomOutボタンを押すとそれぞれ描画範囲を1/4倍、4倍に変更する
- Resetボタンを押すと描画範囲を初期値に戻す
設計
画面設計をQMLで、バックエンドはC++で記述する。
描画に必要なパラメータはすべてC++側で持ち、QML側では単にC++が持っているパラメータを参照してGUI部品の描画のみを行う。
マンデルブロー集合の描画はC++側でビットマップの生成まで行い、QMLのImageProviderというモジュールを利用してビットマップをQMLとして表示する
GUI→バックエンドのインターフェース
- ビットマップの取得
- 実軸/虚軸それぞれの描画範囲を指定
- 描画範囲を初期値に戻す
- ウインドウ中のローカル座標から、対応する複素数を取得
- ズームイン/ズームアウト
バックエンド→GUIのインターフェース
- ビットマップの変更通知
- 描画範囲の変更通知
コード解説
QtによるGUIアプリの構造
昔のQtではQWidgetというクラス群を用いて、C++のみでGUIとバックエンドを記述していたが、Qt4.7.1からはQtQuickというGUIツールキットを用いて簡単にGUIを記述することができるようになった。
QtQuickではQMLというJavaScript風の宣言型言語を用いて、直感的にGUIを定義することができる。(前述)
GUIフロントエンド(QML)部分
今回はGUI部分はQMLを利用して定義する。
先だって設計した画面をそのままQMLに落とすと、以下のような構造になる。
Window
{
visible: true
width: 1280
height: 960
title: qsTr("Hello Fractal")
Image
{
// マンデルブロー集合が描画される描画エリア
id: drawnImage
height: 960
width: 960
anchors.top: parent.top
anchors.left: parent.left
}
Item
{
// 右側ペイン。各種入力用のボタン類を配置する
id: inputPain
anchors.top: parent.top
anchors.right: parent.right
width: parent.width - drawnImage.width
height: parent.height
ColumnLayout
{
// 縦配置
anchors.top: parent.top
width: parent.width
spacing: 10
Text
{
// 座標設定エリアのタイトル文字
Layout.topMargin: 10
Layout.alignment: Qt.AlignCenter
text: qsTr("Coordinates")
}
RowLayout
{
// 横配置
Layout.alignment: Qt.AlignRight
Layout.rightMargin: 10
// 実軸、最小
Text { text: qsTr("Real min") }
// テキスト入力フィールド
TextField {id: realMin }
}
RowLayout
{
// 横配置
Layout.alignment: Qt.AlignRight
Layout.rightMargin: 10
// 実軸、最大
Text { text: qsTr("Real max") }
// テキスト入力フィールド
TextField {id: realMin }
}
RowLayout
{
// 横配置
Layout.alignment: Qt.AlignRight
Layout.rightMargin: 10
// 虚軸、最小
Text { text: qsTr("Imaginary min") }
// テキスト入力フィールド
TextField {id: realMin }
}
RowLayout
{
// 横配置
Layout.alignment: Qt.AlignRight
Layout.rightMargin: 10
// 虚軸、最大
Text { text: qsTr("Imaginary max") }
// テキスト入力フィールド
TextField {id: realMin }
}
Button
{
// 描画範囲リセット用ボタン
Layout.alignment: Qt.AlignCenter
text: qsTr("Reset")
}
Text
{
// ズーム操作エリアのタイトル文字
Layout.topMargin: 10
Layout.alignment: Qt.AlignCenter
text: qsTr("Zooming")
}
RowLayout
{
// 横配置
Layout.alignment: Qt.AlignCenter
Button
{
// ズームインボタン
text: qsTr("Zoom In")
}
Button
{
// ズームアウトボタン
text: qsTr("Zoom Out")
}
}
}
Text
{
// 現在のマウスカーソル位置をウインドウ下部に常時表示する
anchors.bottom: parent.bottom
id: positionIndicator
text: qsTr("position: ")
}
}
これをベースに、各GUI部品に機能を肉付けしていくことにする。
idについて
各QML要素には、id:
というプロパティがある。これは他の要素から参照するためのユニークな識別子として、小文字で始まる文字列を指定することができる。他から参照されない場合はid:
を指定しなくてもよい。また、スコープはファイル。
GUIアイテムの配置
QMLの要素(Rectangle
やらImage
やらText
やら)はすべて、Item
を継承しているので同じやり方で配置の指定が可能。anchors:
プロパティを用いることで他の要素との相対的な位置を指定することができる。詳しくは公式ドキュメントを参照。
アンカーとして使用する他の要素は、前述id:
を使ったり特殊なIDであるparent
(親要素を表す)を使って特定する。
レイアウト
凝ろうとするといくらでも凝ることができるが、とりあえず横配置RowLayout
と縦配置ColumnLayout
を使えば大体の配置は可能。詳しくはこのあたり
画像描画方法(QQuickImageProvider)
静的な画像を単純に表示するには、QMLのImageのsource:
プロパティにファイルの場所を指定してあげればよいのだが、今回の例ではGUIの操作に応じて描画するべき画像(マンデルブロー集合)が変化するため、C++側で生成したビットマップを動的に表示する必要がある。
そこで今回の実装ではQtのQQuickImageProviderクラスを継承してFractalDrawer
クラスを作成し、動的に変化するビットマップの表示を行うことにする。実装は後述。
バグっていたので修正しました → QQuickImageProviderを使うとプログラム終了時に不正終了する問題
main.cpp内で、作成したFractalDrawer
をImageProviderとして登録する。
auto *fractalDrawer = new FractalDrawer(sizeSettings.getCanvasWidth(), sizeSettings.getCanvasHeight());
QQmlApplicationEngine engine;
// ImageProviderを登録する
engine.addImageProvider(QLatin1String("fractalDrawer"), fractalDrawer);
QML側では、登録したFractalDrawer
のIDをImageのsource:
プロパティに指定してあげればよい。
Image
{
id: drawnImage
height: 960
width: 960
anchors.top: parent.top
anchors.left: parent.left
source: "image://fractalDrawer/hoge"
}
先ほどfractalDrawer
というIDでImageProviderを登録したため、QMLではsource: "image://fractalDrawer/hoge"
というURL形式で画像ソースを指定する。
ここでhoge
は画像のIDで、ImageProviderはIDに応じて異なる画像を提供することができる。今回の仕様では画像はC++側の持つデータによって動的に変化するので、IDによる画像の切り替えは行わない。言語仕様上IDを何か与えなければいけないので、とりあえずhoge
を入れておく。
画像再読み込みのトリック
描画範囲の変更に応じてビットマップの内容が変化するが、QQuickImageProvider
には明示的に画像の再読み込みを行う機能がない。QMLの仕様上、source:
の値を変化させないと画像の再読み込みが行われないので、ちょっとしたトリックを使う必要がある。
前述のように今回の実装ではIDの文字列は使用せず、C++側で無視しているのでQMLではランダム生成した適当な文字列をIDとして与えてしまうようにする。
drawnImage.source = "image://fractalDrawer/" + Math.random()
これで、source:
に異なるURLが指定されるたびにC++側で更新された画像が読み込まれ、画像の明示的な再読み込みを疑似的に実現することができる。
ボタン
押したときのハンドラは後で実装するので、とりあえずテキストラベルだけ定義する。
Button
{
Layout.alignment: Qt.AlignCenter
text: qsTr("Reset")
}
テキスト入力フィールド
ここもテキスト入力終了時のハンドラは後で実装する。
TextField
{
id: imaginaryMax
}
マウスドラッグ
表示されているマンデルブロー集合上でマウスをドラッグして範囲指定すると、指定した範囲を拡大表示できるようにする。
指定後の再描画処理はC++側で行うので、QML側ではマウスドラッグ中、指定している範囲がわかるようにドラッグ枠を表示する処理のみを実装しておく。
まず、マウスドラッグを開始した際に表示されるドラッグ枠をGUI上に定義する。
// drag frame
Rectangle
{
id: dragFrame
visible: false
x: 100; y: 100
width: 1; height: 1
color: "#00000000"
border.color: "white"
border.width: 1
}
初期位置はどうでもいいので適当な値を指定しておくが、visible:
プロパティをfalse
にして初期状態では枠が表示されないようにする。
次に、ドラッグを有効にする範囲をMouseArea
を使って定義する。今回はマンデルブロー集合を表示するImage
要素とぴったり重ねて定義する。
property point dragStart: Qt.point(0, 0)
/* ... */
Image
{
/* ... */
MouseArea
{
anchors.fill: parent
hoverEnabled: true
onPressed:
{
dragStart.x = mouse.x
dragStart.y = mouse.y
dragFrame.x = mouse.x
dragFrame.y = mouse.y
dragFrame.width = 1
dragFrame.height = 1
dragFrame.visible = true
}
onReleased:
{
dragFrame.visible = false
}
onPositionChanged:
{
if(pressed)
{
dragFrame.x = Math.min(dragStart.x, mouse.x)
dragFrame.y = Math.min(dragStart.y, mouse.y)
dragFrame.width = Math.abs(dragStart.x - mouse.x)
dragFrame.height = Math.abs(dragStart.y - mouse.y)
}
}
}
}
まず、マウスドラッグの開始点を保持するためのプロパティ、dragStart
を定義しておく。
(プロパティはファイルスコープなのでMouseArea
の外で定義している)
GUI右下に現在のマウス位置を表示する機能のため、マウスが押されていないときもマウス位置を取得したいのでhoverEnabled:
プロパティをtrue
にしておく。
MouseArea
はマウスの各種イベントをハンドリングできるが、今回はonPressed:
onReleased:
onPositionChanged:
のイベントハンドラを実装する。
まずonPressed:
が発行されたら、現在のマウス位置をmouse
プロパティから取得し、dragStart
とdragFrame
にコピーしたうえでdragFrame.visible:
をtrue
にする。これで先ほど定義したドラッグ枠がマウスの位置に表示される。
次にマウスが移動されたら、onPositionChanged:
が発行されるので随時マウス位置を取得してdragFrame
の位置とサイズを調整する。
onReleased:
が発行されたらdragFrame.visible:
をfalse
にしてドラッグ枠を再び非表示にしておく。これでマウスドラッグの枠表示が完成した。
onReleased:
時の描画範囲変更と再描画処理、マウス位置の表示機能は追って実装する。
バックエンド(C++)部分
バックエンドは単なるC++の実装なので、Qt独自部分のみ紹介
ImageProviderの継承
最新のリファクタリングで多重継承は廃止したのでこの記述はソースコードと一致していないが、なんらかの理由があって多重継承を使う場合はハマるポイントではあるので残しておくことにします
QQuickImageProvider
を継承してFractalDrawer
を実装する。このクラスはQMLから呼び出されるインターフェイス関数も実装するのでQObject
も継承する必要があるが、QQuickImageProvider
はQObject
の派生クラスではないので多重継承を使う。mocと呼ばれるQtのプリプロセッサの仕様上、多重継承する場合にはQObject
を先に記述する必要がある。ハマりどころなので注意。
また、QObject
を継承したクラスはクラス定義の冒頭でQ_OBJECT
というマクロを記述しておく必要がある(セミコロン不要)。おまじない。
// QObject must be inherit at first!!
class FractalDrawer : public QObject, public QQuickImageProvider
{
Q_OBJECT
public:
FractalDrawer();
~FractalDrawer();
requestPixmap()関数のオーバーライド
作成したFractalDrawer
クラスでは、requestPixmap()
関数をオーバーライドする。これはQQuickImageProvider
クラスで定義されている関数で、QMLのImageから画像を要求された際に呼び出される関数。
QPixmap requestPixmap(const QString &id, QSize *size, const QSize &requestedSize) Q_DECL_OVERRIDE;
前述のようにQMLからは画像のIDも引数として送られてくるが、今回は無視してバックエンドで保持しているマンデルブロー集合描画クラスからビット列を取得してきて、QtのQPixmap形式に変換してQML側に返すように実装する。
QPixmap FractalDrawer::requestPixmap(const QString &id, QSize *size, const QSize &requestedSize)
{
// 画像サイズがハードコードで若干かっこ悪いが今は良しとする。
Q_UNUSED(id);
Q_UNUSED(requestedSize);
*size = QSize(960, 960);
m_calculator->calc(m_data);
QImage drawnImage(m_data, 960, 960, QImage::Format_ARGB32);
return QPixmap::fromImage(drawnImage);
}
m_data
はunsigned char*
型のビット列(32bit ARGB)で、バックエンドのCalcMandelbrot
クラスで指定された範囲のマンデルブロー集合を描画している。(詳しい計算内容は教科書通りなので省略)
ビット列をいったんQtのQImage
型に変換した後、QPixmap
型に変換してQMLに返す。
各クラスのドキュメントページは以下
QQuickImageProvider
QPixmap
QImage
クラスの登録
FractalDrawer
クラスはQMLから直接呼び出すので、main.cpp内でQtエンジンに登録しておく。QMLからはここで登録した名前でアクセス可能になる。
QObject
を継承しているクラスでないと登録できない。
QQmlApplicationEngine engine;
FractalDrawer fractalDrawer;
engine.rootContext()->setContextProperty("fractalDrawer", &fractalDrawer);
プロパティ(データバインディング)の定義
QMLから参照したいデータはQ_PROPERTY
というマクロを記述して定義する(セミコロン不要)。
Q_PROPERTY(qreal minX READ getMinX WRITE setMinX NOTIFY reloadRange)
例えば上記のように記述すると、qreal型(Qtの型で、実際はdoubleと同じ)のminXというプロパティがQMLから参照可能になる。
QML上では
TextField
{
id: realMin
text: fractalDrawer.minX
onEditingFinished: { fractalDrawer.minX = parseFloat(text) }
}
のように記述すると、C++が保持するデータをGUI上のプロパティminX
としてバインディングすることができる。
QML上でminX
の値を呼び出すときには上記マクロ中のREAD
で定義された関数getMinX()
が呼び出され、同様にminX
に何か値を代入するとWRITE
で定義された関数setMinX()
が呼び出される。このようにsetter/getter関数を噛ませることで、C++側で値の正当性のチェックが可能になる。
C++側でプロパティの値が変更されたときは、NOTIFY
に定義された特殊な関数reloadRange()
を呼び出すことで、QML側に値の変更が通知され、GUI上の表示が更新される。
上記の関数は以下のように定義しておく。
void setMinX(qreal value) { changeRange(value, m_minY, m_maxX, m_maxY); }
void setMinY(qreal value) { changeRange(m_minX, value, m_maxX, m_maxY); }
void setMaxX(qreal value) { changeRange(m_minX, m_minY, value, m_maxY); }
void setMaxY(qreal value) { changeRange(m_minX, m_minY, m_maxX, value); }
qreal getMinX() { return m_minX; }
qreal getMinY() { return m_minY; }
qreal getMaxX() { return m_maxX; }
qreal getMaxY() { return m_maxY; }
signals:
void redrawNeeded();
void reloadRange();
NOTIFY
で呼び出すのはシグナルと呼ばれる特殊な関数で、Qt独自のsignals:
というセクションに記述しておく。単に呼び出すだけでQMLに通知が送られるが、お作法としてemit文という構文で呼び出すことになっている。
void FractalDrawer::changeRange(qreal minX, qreal minY, qreal maxX, qreal maxY)
{
if(minX < maxX && minY < maxY)
{
m_minX = minX;
m_minY = minY;
m_maxX = maxX;
m_maxY = maxY;
updateImageAndNotify();
}
emit reloadRange();
}
emitは最終的にはただのマクロで、空白に置換されるのでシグナル関数を直接呼び出しても結果は同じだが、シグナルであることを明示するためにemitを付けるのがQtのお約束になっている。
ここでは、描画範囲を表すminX, minY, maxX, maxYが更新されたことを通知するreloadRange()
と画像自体が更新されて再描画が必要なことを通知するredrawNeeded()
の2つのシグナルを定義している。
シグナルを表す関数の関数名はlower camelにしておく(理由は後述)
QMLから呼び出し可能(INVOKABLE)な関数の定義
メンバ関数宣言の冒頭にQ_INVOKABLE
というマクロを記述すると、QMLから呼び出し可能になる。
Q_INVOKABLE void zoomOut() { changeRange(m_minX*2, m_minY*2, m_maxX*2, m_maxY*2); }
Q_INVOKABLE void zoomIn() { changeRange(m_minX/2, m_minY/2, m_maxX/2, m_maxY/2); }
Q_INVOKABLE void resetRange() { changeRange(-2.0, -2.0, 2.0, 2.0); }
Q_INVOKABLE void changeRange(qreal minX, qreal minY, qreal maxX, qreal maxY);
Q_INVOKABLE qreal getRe(int value) { return (m_minX + (value * (m_maxX - m_minX) / 960)); }
Q_INVOKABLE qreal getIm(int value) { return (m_minY + (value * (m_maxY - m_minY) / 960)); }
QML→C++の呼び出し
C++側でプロパティとして公開した変数は、QMLから簡単に参照できる。
NOTIFY
で定義したシグナルが発行されると自動的に表示が更新される。
TextField
{
id: realMin
// プロパティ
text: fractalDrawer.minX
}
QMLで配置したボタンやテキストフィールドから、C++側の関数を簡単に呼び出せる。
Button
がクリックされた場合の動作はonClicked:
TextField
の編集が終了した(Enterキーが押された)場合はonEditingFinished:
に記述すればよい。
// TextFieldの編集が終了したらfractalDrawerのsetMaxY()関数を呼び出す
TextField
{
id: imaginaryMax
text: fractalDrawer.maxY
// C++側でsetMaxY()関数が呼び出される。(値の正当性チェックが行われる)
onEditingFinished: { fractalDrawer.maxY = parseFloat(text) }
}
/* ... */
// ResetボタンがクリックされたらfractalDrawerのresetRange()関数を呼び出す
Button
{
Layout.alignment: Qt.AlignCenter
text: qsTr("Reset")
// resetRange()関数内でプロパティの値が更新されてreloadRange()シグナルが発行されるので、GUI上の表示は自動的に更新される
onClicked: fractalDrawer.resetRange()
}
同じく、QML側でドラッグを検知し、ドラッグが終了したら描画範囲を変更する。
onReleased:
{
// ドラッグ枠を消す
dragFrame.visible = false
// C++の関数呼び出し。ドラッグの位置に対応して描画範囲を変更する
fractalDrawer.changeRange(Math.min(mouseRe, fractalDrawer.getRe(dragStart.x)),
Math.min(mouseIm, fractalDrawer.getIm(dragStart.y)),
Math.max(mouseRe, fractalDrawer.getRe(dragStart.x)),
Math.max(mouseIm, fractalDrawer.getIm(dragStart.y)))
}
C++→QMLの通知
C++からのシグナルを受けてQML側で動作を定義する。方法はいくつかあるがもっとも簡単なConnections
を使う方法で実装する。
Connections
{
target: fractalDrawer
onRedrawNeeded:
{
// redraw
drawnImage.source = "image://fractalDrawer/" + Math.random()
}
}
target:
プロパティで指定したターゲットが発行したシグナルをキャッチして実行されるハンドラを定義する。
若干わかりにくいが、lower camelのシグナル名をupper camelにして先頭にonを付けたものがハンドラ名になる。
redrawNeeded
→onRedrawNeeded:
ここでは、C++側から再描画が必要というシグナルが飛んできたら、前述のトリックを使ってImageProviderから再読み込みを行っている。
実行結果
こんな感じ。試していないがLinuxやMacでもほぼそのまま動くはず。
ソース
https://bitbucket.org/y-uehara/qtmandelbrot/src/20200128/
QQuickImageProviderのバグ修正、ウインドウサイズなどのハードコーディングの削除、RaspberryPi上での動作に対応したバージョン(GitHubに移動)
https://github.com/y-uehara/QtMandelbrot/releases/tag/20200530
今後の予定
- QMLの座標定義の関係で複素平面の正負が逆なので修正する(虚軸プラス方向が下になっている)
- マンデルブロー集合の発散判定の閾値が固定値なので、拡大した際の表示がおかしいので修正する
- マウスドラッグによるスクロールを実装
- さらに解像度を上げて、高速化に挑む