PyQt

【PyQt】Model-Viewを使ったアプリケーション開発

【PyQt】Model-Viewを使ったアプリケーション開発

PythonのGUIツールキットであるPyQtで、Model-Viewを使ったアプリケーション開発をする方法について解説します。

Model-View開発の概要

GUIアプリケーションを作るときには、利用者に見せる画面部分やデータ処理をしている部分等があります。プログラミングの際には、これらの処理をどのように作るかといったプログラム構造を検討する必要があります。

GUI開発において昔からある開発パターンとしてModel-ViewControllerMVCパターンという一つの開発パターンがあります。これは、データを実行するModelと利用者が使う画面のView、そして制御を行うControllerに分けて開発するパターンの事です。

PyQtでは、このModel-View-Controllerパターンをベースにした開発ができるような仕組みが提供されていますが、ViewとControllerが1つのコンポーネントにまとめられている点がMVCパターンと少し異なります。そのため、Model-Viewという表現をしています。

Model部分では、アプリケーションが扱うデータの取得・保存・操作のための制御ロジックを含みます。View部分は、ユーザーにデータを表示したり入力・操作したりするためのインタフェースを提供しています。これらをうまく分離することで相互の依存性が低く、再利用等しやすいアプリケーション開発ができます。

本記事では、Model-Viewクラスを用いたPyQtでの画面開発方法について基本的な使い方を紹介していきます。

Model-Viewの実装方法

QObjectやQWidgetを継承したModel-Viewの実装

Model-Viewの考え方をPyQtで実装する方法として、QObjetやQWidgetを継承したModelクラスとViewクラスを作成して実装する方法があります。以下のサンプルプログラムを使って内容を説明していきます。

実装例

import sys
from pathlib import Path

from PyQt6 import QtCore as qtc
from PyQt6 import QtGui as qtg
from PyQt6 import QtWidgets as qtw


class Model(qtc.QObject):
    """Modelクラス"""

    # エラー用シグナル定義
    error = qtc.pyqtSignal(str)

    def save(self, filename, content):
        """ファイル保存

        Args:
            filename: 保存対象ファイルパス
            content: 保存コンテンツ
        """
        error = ""
        file_path = Path(filename)
        if not filename:
            error = "ファイル名が空です。"
        elif file_path.exists():
            error = f"ファイルが存在しています。{filename}"
        else:
            try:
                with open(filename, "w", encoding="utf-8") as f:
                    f.write(content)
            except Exception as ex:
                error = f"ファイルを書き込めませんでした。: {ex}"

        if error:
            self.error.emit(error)


class View(qtw.QWidget):
    """Viewクラス"""

    # 実行シグナル定義
    submitted = qtc.pyqtSignal(str, str)

    def __init__(self):
        """Viewコンストラクタ"""
        super().__init__()

        # 画面要素の定義
        self.filename = qtw.QLineEdit()
        self.content = qtw.QTextEdit()
        self.save_button = qtw.QPushButton("保存", clicked=self.submit)

        # 画面レイアウトと要素の定義
        layout = qtw.QVBoxLayout()
        layout.addWidget(self.filename)
        layout.addWidget(self.content)
        layout.addWidget(self.save_button)
        # レイアウトの設定
        self.setLayout(layout)

    def submit(self):
        """保存実行"""
        self.submitted.emit(self.filename.text(), self.content.toPlainText())

    def show_error_message(self, error):
        """エラーダイアログ表示

        Args:
            error: エラーメッセージ
        """
        qtw.QMessageBox.critical(None, "エラー", error)


class MainWindow(qtw.QMainWindow):
    """メインウィンドウ"""

    def __init__(self):
        """メインウィンドウコンストラクタ"""
        super().__init__()

        # 画面タイトルの設定
        self.setWindowTitle("Model-Viewサンプル")
        # 画面サイズの設定
        self.resize(640, 360)

        # Viewをインスタンス化し、CentralWidgetへ設定
        self.view = View()
        self.setCentralWidget(self.view)

        # Modelをインスタンス化
        self.model = Model()

        # ModelとViewのsignal-slot接続
        self.view.submitted.connect(self.model.save)
        self.model.error.connect(self.view.show_error_message)

        # 画面表示
        self.show()


def main():
    """メイン関数"""
    app = qtw.QApplication(sys.argv)
    mv = MainWindow()
    sys.exit(app.exec())


if __name__ == "__main__":
    main()

【実行結果】

初期表示は以下のようになります。

Model-View サンプル

例えば、一番上のQLineEdit欄にファイルパスを入力し、次のQTextEditに文字列を入力した上で「保存」をクリックすると入力した文字列を指定したファイルパスに保存します。以下はプログラム実行フォルダにtest.txtというファイルを保存する場合です。

Model-View サンプル 入力例

QlineEdit欄が空で保存ボタンを押すと以下左図のようなエラーが、既にあるファイルを指定すると以下右図のようなエラーとなります。

Model-View サンプル エラー
Model-View サンプル エラー

一度しか保存できないプログラムなのであまり有用なツールではありませんが、誤って既存のファイルを上書きしてしまわないようにという点と今回はModel-Viewの説明のためのプログラムということでご理解ください。

詳細説明

以降で各部分ごとに詳細を説明していきます。まずは、プログラムの全体構造としては以下の図のようになっていますので、この図を見ながら読んでもらうと分かりやすいかと思います。

Model-Viewサンプル 全体構造

【Modelクラス】

class Model(qtc.QObject):
    """Modelクラス"""

    # エラー用シグナル定義
    error = qtc.pyqtSignal(str)

    def save(self, filename, content):
        """ファイル保存

        Args:
            filename: 保存対象ファイルパス
            content: 保存コンテンツ
        """
        error = ""
        file_path = Path(filename)
        if not filename:
            error = "ファイル名が空です。"
        elif file_path.exists():
            error = f"ファイルが存在しています。{filename}"
        else:
            try:
                with open(filename, "w", encoding="utf-8") as f:
                    f.write(content)
            except Exception as ex:
                error = f"ファイルを書き込めませんでした。: {ex}"

        if error:
            self.error.emit(error)

モデルクラスは、qtc.QObjectというクラスを継承して実装しています。また、モデル側でエラーが発生した場合のエラーシグナルを以下のように定義しています。

    # エラー用シグナル定義
    error = qtc.pyqtSignal(str)

saveメソッドについては、ファイルパス(filename)と内容(content)を受け取って、ファイルを書き込むシンプルなプログラムです。ただ、ファイル名が空の場合やファイルが既に存在している例では上記の実行例でも見たようにエラーとなるようにしています。

エラーが発生した場合は、errorに文字列を設定して以下の部分でerrorシグナルを発生させるようにしています。

        if error:
            self.error.emit(error)

これが上記概要図でsaveメソッドからshow_error_messageに向かっている↑矢印です。(接続は後述するMainWindow内で行います)

このようにデータの扱いに関する実装はModelクラス内に定義します。

【Viewクラス】

class View(qtw.QWidget):
    """Viewクラス"""

    # 実行シグナル定義
    submitted = qtc.pyqtSignal(str, str)

    def __init__(self):
        """Viewコンストラクタ"""
        super().__init__()

        # 画面要素の定義
        self.filename = qtw.QLineEdit()
        self.content = qtw.QTextEdit()
        self.save_button = qtw.QPushButton("保存", clicked=self.submit)

        # 画面レイアウトと要素の定義
        layout = qtw.QVBoxLayout()
        layout.addWidget(self.filename)
        layout.addWidget(self.content)
        layout.addWidget(self.save_button)
        # レイアウトの設定
        self.setLayout(layout)

    def submit(self):
        """保存実行"""
        self.submitted.emit(self.filename.text(), self.content.toPlainText())

    def show_error_message(self, error):
        """エラーダイアログ表示

        Args:
            error: エラーメッセージ
        """
        qtw.QMessageBox.critical(None, "エラー", error)

次に、画面表示のViewクラスについてです。こちらは画面要素のためqtw.QWidgetを継承して実装しています。画面では、各要素を用意してレイアウト配置しています。

特徴的なのはsubmittedシグナルとsubmitメソッド定義の部分です。

...(途中省略)...
    # 実行シグナル定義
    submitted = qtc.pyqtSignal(str, str)

...(途中省略)...

    def submit(self):
        """保存実行"""
        self.submitted.emit(self.filename.text(), self.content.toPlainText())

このメソッド内ではsubmittedシグナルは文字列を2つ送るように定義しています。

画面側で保存ボタンが押されると「self.save_button = qtw.QPushButton(“保存”, clicked=self.submit)」で定義しているようにsubmitメソッドが呼ばれ、submitメソッド内部ではファイルパス「self.filename.text()」と内容「self.content.toPlainText()」の文字列を含んだシグナルを発生させます。これが上記概要図でsubmitメソッドからsaveメソッドに向かっている↓矢印です。(接続は後述するMainWindow内で行います)

また、以下はエラー文字列を受け取ってエラーダイアログを表示するためのメソッドで後ほど先ほど説明したerrorシグナルとこのメソッドを接続します。

    def show_error_message(self, error):
        """エラーダイアログ表示

        Args:
            error: エラーメッセージ
        """
        qtw.QMessageBox.critical(None, "エラー", error)

このように画面表示に関する実装はViewクラス内に定義します。

【MainWindowクラス】

class MainWindow(qtw.QMainWindow):
    """メインウィンドウ"""

    def __init__(self):
        """メインウィンドウコンストラクタ"""
        super().__init__()

        # 画面タイトルの設定
        self.setWindowTitle("Model-Viewサンプル")
        # 画面サイズの設定
        self.resize(640, 360)

        # Viewをインスタンス化し、CentralWidgetへ設定
        self.view = View()
        self.setCentralWidget(self.view)

        # Modelをインスタンス化
        self.model = Model()

        # ModelとViewのsignal-slot接続
        self.view.submitted.connect(self.model.save)
        self.model.error.connect(self.view.show_error_message)

        # 画面表示
        self.show()

上記のようにModelクラスとViewクラスを用意しておくとMainWindowクラスの実装は簡単です。以下のようにViewとモデルをインスタンス化します。Viewの方は中心ウィジェットとしてsetCentralWidgetでセットしています。

        # Viewをインスタンス化し、CentralWidgetへ設定
        self.view = View()
        self.setCentralWidget(self.view)

        # Modelをインスタンス化
        self.model = Model()

あとは以下の部分で上記で説明したシグナルとスロットの接続をconnectで記載します。

        # ModelとViewのsignal-slot接続
        self.view.submitted.connect(self.model.save)
        self.model.error.connect(self.view.show_error_message)

このようにすることでModel-Viewの考え方に基づく実装が可能です。

PyQtで用意されているModel-Viewクラスの利用

上記では、QObjectやQWidgetを継承してModel-Viewの考え方を実装する例を見てきました。ただし、PyQtでは必ずしも上記のように自分で実装する必要はなく、すぐに使えるモデルやビューのクラスが実装されているものがあります。

以降ではPyQtで用意されているModel-Viewクラスの概要説明と簡単な使用例を紹介します。

PyQtで用意されているModel-Viewのクラス概要

リスト表示のためのQListWidgetを例に使いつつ、PyQtのModel-Viewクラスの概要について説明していきます。

QListWidgetはそのものを使用することができるわけですが、実際はQListViewというビューと、QStringListModelというモデルの組み合わせになっていて以下のようにもモデルとビューを分けて表現することができます。

model = qtc.QStringListModel(data)
listview = qtw.QListView()
listview.setModel(model)

上記プログラムで以下の手順になっています。

  1. モデルのQStringListModelをdataというリストを使って生成する
  2. ビューのQListViewを生成する
  3. ビューのsetModel()メソッドでモデルのデータを設定する

このように既に用意されているクラスでModel-Viewの実装を実現できるようなものがPyQtには他にも色々と用意されています。

なお、必ずしもQStringListModel、QListViewのようにモデルとビューが分離されて用意されているわけではありません。例えば、QComboBoxは同じようなモデル、ビューのクラスはありません。しかし、QComboBoxにはsetModelメソッドがあるので以下のようにQStringListModelをセットすることもできます。

model = qtc.QStringListModel(data)
combobox_view = qtw.QComboBox()
combobox_view.setModel(model)

この考え方を用いるとリストとコンボボックスのデータ共有のようなことができますが、以降で例として紹介します。

また、QTableWidgetというテーブルのウィジェットやQTreeWidgetというウィジェットは、それぞれビューとしてQTableViewとQTreeViewといったビュークラスを持っていますが、モデルとしてすぐに使える便利なモデルは用意されていません。そのかわりにQAbstructTableModelやQAbstructTreeModelといった抽象クラスが用意されていますので、自分でモデルを実装することが可能です。抽象クラスについてイメージがわかない方は「ポリモーフィズムと抽象クラス、抽象メソッド」等も参考にしてもらえればと思います。

QAbstructTableModelやQAbstructTreeModelの使い方はまた別途整理してみようかなと思っています。

使用例:リストとコンボボックスのデータ共有

PyQtで既に用意されているModel-Viewクラスの使用例として、リストとコンボボックスのデータ共有をする例を見てみようと思います。

実装例
import sys

from PyQt6 import QtCore as qtc
from PyQt6 import QtGui as qtg
from PyQt6 import QtWidgets as qtw


class MainWindow(qtw.QWidget):
    """メインウィンドウ"""

    def __init__(self):
        super().__init__()
        # 画面タイトルの設定
        self.setWindowTitle("Qt Model-View")

        # ===== モデルを使った実装
        # データの定義
        list_data = ["item01", "item02", "item03", "item04", "item05"]

        # Modelを定義し、データを設定
        self.model = qtc.QStringListModel(list_data)

        # Viewの定義
        # リスト
        self.list_view = qtw.QListView()
        self.list_view.setModel(self.model)
        # コンボボックス
        self.combobox_view = qtw.QComboBox()
        self.combobox_view.setModel(self.model)

        # レイアウトを用意し部品を設定
        layout = qtw.QHBoxLayout()
        layout.addWidget(self.list_view)
        layout.addWidget(self.combobox_view)
        self.setLayout(layout)

        # 画面表示
        self.show()


def main():
    """メイン関数"""
    app = qtw.QApplication(sys.argv)
    mv = MainWindow()
    sys.exit(app.exec())


if __name__ == "__main__":
    main()

上記プログラムでは、初期表示では以下左図のようになります。右図はコンボボックスを開いた時の表示です。

Model-View QListView QStringListModel QComboBox
Model-View QListView QStringListModel QComboBox

左側のリストは編集可能なのでダブルクリック又はF2を押すと編集ができます。編集して確定させると右側のコンボボックスの内容も変更されます。

Model-View QListView QStringListModel QComboBox
詳細説明

では、プログラム詳細を部分ごとに説明していきます。

        # データの定義
        list_data = ["item01", "item02", "item03", "item04", "item05"]

        # Modelを定義し、データを設定
        self.model = qtc.QStringListModel(list_data)

上記部分ではリストで表示するデータのモデルを定義し、データを設定しています。モデルはQStringListModelを使用し、引数にリストデータを渡しています。

        # Viewの定義
        # リスト
        self.list_view = qtw.QListView()
        self.list_view.setModel(self.model)
        # コンボボックス
        self.combobox_view = qtw.QComboBox()
        self.combobox_view.setModel(self.model)

モデルが用意できたので次はビューを用意してモデルを設定します。リストについてはQListViewを、コンボボックスはQComboBoxを使います。上記で説明した通りQComboBoxはViewとしてのクラスはなく、ビューとモデルが一つになっているようなクラスです。

モデルを設定する場合には、setModelメソッドを使います。ここでポイントなのは、QListViewとQComboBoxが同じQStringListModelのインスタンスをセットしていることです。これによりリストとコンボボックスがデータを共有することになり、リスト側の変更がコンボボックス側に反映されるわけです。

もし、Model-Viewの考え方を使用しない場合は、リストとコンボボックスそれぞれでデータを持つことになります。その場合でもシグナルとスロットを使用すれば変更の共有はできますが非常に面倒です。こういった点でModel-Viewの考え方は大事であることが分かるかと思います。

まとめ

PythonのGUIツールキットであるPyQtで、Model-Viewを使ったアプリケーション開発をする方法について解説しました。

QObjetやQWidgetを継承したModelクラスとViewクラスを作成して実装する方法ともともとPyQtで定義されているモデル、ビューを使った方法を例としてQStringListModelを使って説明しました。

Model-View実装の考え方はアプリケーション開発では以前からある一つの基本的な構成であり、PyQtではそれを支える各種枠組みが提供されています。うまく活用してGUIアプリケーション開発をしてもらえるとよいかなと思います。