【Qt】シグナル&スロットでMVVM的な動作をさせる実験

プログラミング

どうもです、タドスケです。

転職してから仕事でPySide6(以降、『Qt』と記載します)を使っています。

Qt for Python

Qtの公式ドキュメントはあるのですが、詳しい情報が足りないため、いざ現場で使う際に迷うことが多くあります。

そこでプライベートでも小さなサンプルを作って色々と試してみることにしました。




ビューとモデルの分離

こちらの記事でも書いている通り、業務レベルで GUI アプリを作る際にはビュー(見た目)モデル(ふるまい)を分離させるのが鉄則となります。

ビュー なしでも動くようにモデルを作り、そこにビューを被せるようなイメージです。

モデルはビューのことを知るべきではありません。

しかし、モデルの中身が変更された場合にはビューに反映させる必要があります。

これを実現するにはどうすれば良いでしょうか?

Qt のシグナル&スロット

方法は色々ありますが、Qt ではシグナル&スロットの仕組みを使うのが一般的なようです。

Signals and Slots - Qt for Python

使い方は、まず QObject 派生クラスが持つシグナルに関数を接続(connect) します。

シグナルに接続された関数をスロットと呼びます。

スロット関数は特別なものではなく、シグナル側で指定している引数を持っている関数なら何でも呼べます。

クラスB 側で通知が必要なイベントが起きる(「emit:発火」すると言います)と、接続したスロット関数が呼ばれます。

シグナルはボタンのクリックのように自動で呼ばれるものと、クラスB 内から手動で呼び出す(emit 関数を呼ぶ)ものがあります。

一つのシグナルに複数のスロットを接続することもできます。

シグナルが発火すると、どちらの関数も呼ばれます。

ビューとモデルをシグナル&スロットで接続する

この仕組みをそのままビュークラスとモデルクラスに当てはめて考えます。

モデルの「変更されたシグナル」にビューの関数を接続して、モデルに変更があったらビュー側の関数が呼ばれるようにします。

あとはビュー側の関数内に「モデルの状態に応じて表示を更新する処理」を書けばOKです。

モデル→ビューへ状態を渡す方法

モデル→ビューへ状態を受け渡す方法には2つのやり方が考えられます。

シグナルの引数で渡す

シグナルの引数に、変更されたモデルの値を入れておきます。

スロット側では引数の値だけを見ればよいので、ビューがモデルのことを全く知らなくても処理できます。

こちらの方がより結合度が低いので理想と言えそうですが、変更されうる状態の数だけシグナル&スロット関数を用意しないといけないので、実装がめんどくさくなるかもしれません。

ビュー生成時にモデルを渡す

上で書いた通り、モデルはビューのことを知るべきではありませんが、ビューがモデルのことを知っているのは問題ありません。

ビューはモデルのためのものなので、モデル無しでビューだけあっても仕方ないからです。

そこで、ビューの生成時にモデルを渡してしまいます。

class Model(QObject):
    """モデルクラス."""

    # 変更されたシグナル
    changed = Signal()
    
    def __init__(self):
        self.value = ''

    def set_value(value):
        self.value = value
        self.changed.emit()

class View(QWidget):
    """ビュークラス.
"""


    def __init__(self, model: Model):
        super().__init__()

        self._model = model
        self._model.changed.connect(self._on_changed)

    def _on_changed(self):
        print(self._model.value)

シグナル&スロット関数の接続は、モデルを渡した際に行います。

モデル側の set_value 関数を呼ぶと、内部で value の値を変更した後に changed シグナルが発火され、ビュー側のスロット関数 _on_changed が呼ばれます。

このサンプルではビュー側で print 関数を使用して値を表示していますが、実際はこれを QLabel や QLineEdit などに置き換えることになるでしょう。

ビューモデルクラスの検討

シンプルなプログラムならこれでも十分そうですが、今回は学習が目的なので、もうひと手間加えてみます。

それは、モデルの値をビュー用に加工するクラス:ビューモデルの存在です。

たとえば通貨(日本円)を画面に表示する場合を考えてみましょう。

モデル側では数値型(例:123456)として保存されていますが、表示する際には3桁ごとにカンマを入れたり、先頭に「¥」をつける処理が必要になります。

この時の数値→表示用文字列への変換を行うのがビューモデルクラスです。

変換もビュー側でやってしまっても良いのですが、ビューモデルクラスを間に挟むことで、ビュー側に書く処理を最小限にできます。

ビュー側の処理が少なくなることで、テストがしやすくなるというメリットがあります。

このようなビューモデルを利用した設計パターンを MVVM (Model-ViewModel-View)と呼びます。

実装例

モデル→ビューモデル、ビューモデル→ビュー間のやり取りについてもシグナル&スロットの仕組みを利用します。

以下のようなコードになりました。

"""シグナル&スロットを使用して MVVM 的な動作をさせる実験.

Model → ViewModel → View の通信はシグナルで間接的に行われます。
Model は ViewModel のことを一切知らず、ViewModel は View のことを一切知りません。
Model のシグナルを受け取れるのであれば、ViewModel や View を全く別のものに置き換えることもできます。
"""

from PySide6.QtCore import QObject, Signal
from PySide6.QtWidgets import QWidget, QApplication, QLineEdit, QVBoxLayout


class Model(QObject):
    """表示に依存しないデータ/ロジッククラス.

    :param parent: 親オブジェクト
    """

    # 名前が変更されたシグナル
    name_changed = Signal()
    # 個数が変更されたシグナル
    number_changed = Signal()

    def __init__(self, parent: QObject = None) -> None:
        super().__init__(parent)

        self._name = ''
        self._number = 0

    @property
    def name(self) -> str:
        """名前."""

        return self._name

    def change_name(self, name: str) -> None:
        """名前を変更します.

        :param name: 名前
        """

        # ロジックに関わる処理は Model 側で行う
        if not name:
            return

        self._name = name
        self.name_changed.emit()

    @property
    def number(self) -> int:
        """個数."""

        return self._number

    def add_number(self, number: int) -> None:
        """個数を加算します.

        :param number: 加算する数
        """

        # ロジックに関わる処理は Model 側で行う
        if number < 0:
            return

        self._number += number
        self.number_changed.emit()


class ViewModel(QObject):
    """Model の内容を表示用に加工するクラス.

    :param model: Model オブジェクト
    :param parent: 親オブジェクト
    """

    # 名前が変更されたシグナル
    name_changed = Signal()
    # 個数が変更されたシグナル
    number_changed = Signal()

    def __init__(self, model: Model, parent: QObject = None) -> None:
        super().__init__(parent)

        self._model = model
        self._model.name_changed.connect(self._on_name_changed)
        self._model.number_changed.connect(self._on_number_changed)

        self._name = ''
        self._number = ''  # LineEdit に表示するので str 型になる

    @property
    def name(self) -> str:
        """名前."""

        return self._name

    @property
    def number(self) -> str:
        """個数."""

        return self._number

    def _on_name_changed(self) -> None:
        """名前が変更された."""

        # 表示用に加工して保持
        self._name = f'**{self._model.name}**'

        self.name_changed.emit()

    def _on_number_changed(self) -> None:
        """個数が変更された."""

        # 表示用に加工して保持
        self._number = f'{self._model.number} 個'

        self.number_changed.emit()


class View(QWidget):
    """表示クラス.

    :param viewmodel: ViewModel オブジェクト
    :param parent: 親オブジェクト
    """

    def __init__(self, viewmodel: ViewModel, parent: QObject = None):
        super().__init__(parent)

        self._viewmodel = viewmodel
        self._viewmodel.name_changed.connect(self._on_viewmodel_changed)
        self._viewmodel.number_changed.connect(self._on_viewmodel_changed)

        self._name = QLineEdit('')
        self._number = QLineEdit('')

        layout = QVBoxLayout()
        layout.addWidget(self._name)
        layout.addWidget(self._number)
        self.setLayout(layout)

    def _on_viewmodel_changed(self) -> None:
        """ViewModel が変更された."""

        # ViewModel の内容をそのまま表示する
        self._name.setText(self._viewmodel.name)
        self._number.setText(self._viewmodel.number)


if __name__ == '__main__':
    app = QApplication()

    model = Model(app)
    viewmodel = ViewModel(model)
    view = View(viewmodel)

    # Model を変更すると、View に反映される
    model.change_name('りんご')
    model.add_number(5)

    view.show()
    app.exec()

実行すると以下のようになります。

どこまでやる?

今回はシグナル&スロットを利用して Qt 上でMVVMパターンっぽい実装を検証してみました。

ただ実際にやってみた感想としては「通信処理書くのめんどくさい」です。

コードが大規模になってくるほどメリットも大きくなると思いますが、個人レベルでサクッと作るアプリの場合は、ビューモデルを省略してビュー側に加工処理を書いてしまっても問題ないのではないかと思いました。

以上、検証おしまい!

コメント

  1. […] 【Qt】シグナル&スロットでMVVM的な動作をさせる実験どうもです、タドスケ… […]

タイトルとURLをコピーしました