12
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

Qtを使った簡単なGUIアプリ(1) マンデルブロー集合を描いてみる

Last updated at Posted at 2020-01-28

バグっていたので修正しましたQQuickImageProviderを使うとプログラム終了時に不正終了する問題

背景

最近、さる事情でQthttps://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の文法を流用した宣言型言語になっている。

main.qml
import QtQuick 2.12
import QtQuick.Window 2.12

Window {
    visible: true
    width: 640
    height: 480
    title: qsTr("Hello World")
}

実行結果
qt-1.jpg

このような空のウインドウが描画されれば、Qtのインストールは正常にできている。

アプリの仕様

以下のような画面構成で、マンデルブロー集合を計算して描画する。

image.png

左側ペイン

  • 計算結果を描画する。右側ペインへの入力の結果、再描画が必要であれば自動的に再描画する。
  • マウスドラッグで指定範囲を拡大表示可能

右側ペイン

  • 複素平面の実軸、虚軸の範囲をそれぞれ指定可能。
  • 初期値は実軸、虚軸ともに-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として登録する。

main.cpp
    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プロパティから取得し、dragStartdragFrameにコピーしたうえでdragFrame.visible:trueにする。これで先ほど定義したドラッグ枠がマウスの位置に表示される。
次にマウスが移動されたら、onPositionChanged:が発行されるので随時マウス位置を取得してdragFrameの位置とサイズを調整する。
onReleased:が発行されたらdragFrame.visible:falseにしてドラッグ枠を再び非表示にしておく。これでマウスドラッグの枠表示が完成した。
onReleased:時の描画範囲変更と再描画処理、マウス位置の表示機能は追って実装する。

バックエンド(C++)部分

バックエンドは単なるC++の実装なので、Qt独自部分のみ紹介

ImageProviderの継承

最新のリファクタリングで多重継承は廃止したのでこの記述はソースコードと一致していないが、なんらかの理由があって多重継承を使う場合はハマるポイントではあるので残しておくことにします

QQuickImageProviderを継承してFractalDrawerを実装する。このクラスはQMLから呼び出されるインターフェイス関数も実装するのでQObjectも継承する必要があるが、QQuickImageProviderQObjectの派生クラスではないので多重継承を使う。mocと呼ばれるQtのプリプロセッサの仕様上、多重継承する場合にはQObject先に記述する必要がある。ハマりどころなので注意。
また、QObjectを継承したクラスはクラス定義の冒頭でQ_OBJECTというマクロを記述しておく必要がある(セミコロン不要)。おまじない。

FractalDrawer.h
// QObject must be inherit at first!!
class FractalDrawer : public QObject, public QQuickImageProvider
{
    Q_OBJECT

public:
    FractalDrawer();
    ~FractalDrawer();

requestPixmap()関数のオーバーライド

作成したFractalDrawerクラスでは、requestPixmap()関数をオーバーライドする。これはQQuickImageProviderクラスで定義されている関数で、QMLのImageから画像を要求された際に呼び出される関数。

FractalDrawer.h
QPixmap requestPixmap(const QString &id, QSize *size, const QSize &requestedSize) Q_DECL_OVERRIDE;

前述のようにQMLからは画像のIDも引数として送られてくるが、今回は無視してバックエンドで保持しているマンデルブロー集合描画クラスからビット列を取得してきて、QtのQPixmap形式に変換してQML側に返すように実装する。

FractalDrawer.cpp
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_dataunsigned char*型のビット列(32bit ARGB)で、バックエンドのCalcMandelbrotクラスで指定された範囲のマンデルブロー集合を描画している。(詳しい計算内容は教科書通りなので省略)

ビット列をいったんQtのQImage型に変換した後、QPixmap型に変換してQMLに返す。

各クラスのドキュメントページは以下
QQuickImageProvider
QPixmap
QImage

クラスの登録

FractalDrawerクラスはQMLから直接呼び出すので、main.cpp内でQtエンジンに登録しておく。QMLからはここで登録した名前でアクセス可能になる。
QObjectを継承しているクラスでないと登録できない。

main.cpp
QQmlApplicationEngine engine;

FractalDrawer fractalDrawer;
engine.rootContext()->setContextProperty("fractalDrawer", &fractalDrawer);

プロパティ(データバインディング)の定義

QMLから参照したいデータはQ_PROPERTYというマクロを記述して定義する(セミコロン不要)。

FractalDrawer.h
   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上の表示が更新される。
上記の関数は以下のように定義しておく。

FractalDrawer.h
    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文という構文で呼び出すことになっている。

FractalDrawer.cpp
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から呼び出し可能になる。

FractalDrawer.h
    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で定義したシグナルが発行されると自動的に表示が更新される。

main.qml
TextField
{
    id: realMin
    // プロパティ
    text: fractalDrawer.minX
}

QMLで配置したボタンやテキストフィールドから、C++側の関数を簡単に呼び出せる。
Buttonがクリックされた場合の動作はonClicked: TextFieldの編集が終了した(Enterキーが押された)場合はonEditingFinished:に記述すればよい。

main.qml
// 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側でドラッグを検知し、ドラッグが終了したら描画範囲を変更する。

main.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を使う方法で実装する。

main.qml
Connections
{
    target: fractalDrawer
    onRedrawNeeded:
    {
        // redraw
        drawnImage.source = "image://fractalDrawer/" + Math.random()
    }
}

target:プロパティで指定したターゲットが発行したシグナルをキャッチして実行されるハンドラを定義する。
若干わかりにくいが、lower camelのシグナル名をupper camelにして先頭にonを付けたものがハンドラ名になる。

redrawNeededonRedrawNeeded:

ここでは、C++側から再描画が必要というシグナルが飛んできたら、前述のトリックを使ってImageProviderから再読み込みを行っている。

実行結果

こんな感じ。試していないがLinuxやMacでもほぼそのまま動くはず。

image2.png

ソース

https://bitbucket.org/y-uehara/qtmandelbrot/src/20200128/

QQuickImageProviderのバグ修正、ウインドウサイズなどのハードコーディングの削除、RaspberryPi上での動作に対応したバージョン(GitHubに移動)
https://github.com/y-uehara/QtMandelbrot/releases/tag/20200530

今後の予定

  • QMLの座標定義の関係で複素平面の正負が逆なので修正する(虚軸プラス方向が下になっている)
  • マンデルブロー集合の発散判定の閾値が固定値なので、拡大した際の表示がおかしいので修正する
  • マウスドラッグによるスクロールを実装
  • さらに解像度を上げて、高速化に挑む
12
9
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
12
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?