【Qt】QAbstractItemModel を使用してモデル/ビューを自動で連動させる実験

プログラミング

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

こちらの記事で、シグナル&スロットを利用したモデル/ビューの分離について書きました。

今回は他のやり方として ItemModel を利用した方法も検証してみたので、記事にまとめたいと思います。




ItemModel とは

ItemModel とは、Qt の Model/View Programming で説明されている QAbstractItemModel を継承したクラスを指します。

Model/View Programming - Qt for Python

っていきなり言われても「なにそれ?」って感じですよね。

↑ のページの説明は非常に情報量が多く、読むのが大変なので、必要最小限の部分だけを要約した図を作ってみました。

一度モデルをビューに関連付けた後は、モデル側を更新するだけで良くなります。

ItemModel に対応しているビューであれば、コンボボックスだろうがリストボックスだろうが気にする必要はありません。

ItemModel を利用した処理の流れ

今回は ItemModel を利用したコンボボックス(QComboBox) を作ってみたいと思います。

こんな感じの「item1」「item2」だけが選択できる、シンプルなものです。

実装の流れは以下のようになります。

  1. QAbstractItemModel を継承したモデルクラスを用意する
  2. 必要な関数をオーバーライドする
  3. ItemModel に対応している Widget クラスを用意する
  4. Widget にモデルクラスのオブジェクトを渡す
  5. モデルを変更する
  6. Widget 側に自動で反映される

QAbstractItemModel を継承したモデルクラスを用意する

QAbstractItemModel は抽象クラスなので、そのままでは使えません。継承したクラスが必要です。

QStandardItemModel などの最初から用意されているサブクラスを使うこともできますが、機能がたくさんあり過ぎるので、今回の用途で使うには余計なものが多すぎます。

QAbstractItemModel を直接継承しても実装できますが、オーバーライドしないといけない項目が増えるため、実装が難しくなります。

1次元のリスト表示用に用意された QAbstractListModel クラスを継承して作るのが簡単です。

class MyModel(QAbstractListModel):
    """独自定義の ListModel クラス."""

    def __init__(self):
        super().__init__()

        self._items: list[str] = []

    def add(self, text: str) -> None:
        """アイテムを追加します."""

        self._items.append(text)

内部に文字列側のリストを持っており、ここに「item1」「item2」を add したら、コンボボックスに反映されるようにします。

必要な関数をオーバーライドする

モデルクラスを関連付けると、ビュー側から必要なタイミングでモデル側の関数が自動で呼ばれるようになります。

今回の用途でオーバーライドが必要な関数は以下です。

  • data
  • rowCount

コードにすると以下のようになります。

    def data(self, index, role) -> tp.Any:
        """(override) index, role に対応するデータを取得します."""

        if role == Qt.DisplayRole:
            return self._items[index.row()]

        return None

    def rowCount(self, parent=QModelIndex()) -> int:
        """(override) 行数を得ます."""

        return len(self._items)

data(index, role)

「index, role に対応するデータはどれですか?」という問い合わせのために呼ばれる関数です。

index はデータ位置で、row(行) と column(列) を持っていますが、今回のような1次元リストの場合は row だけを使えばよいです。

row = リストのインデックス に対応付けています。

role は要求されるデータの種類を表しています。

Qt.DisplayRole は表示用のデータで、コンボボックスの場合は選択項目のテキストが対応します。

他にも様々な role があり、画像を指定して項目にアイコンを表示することもできます。

rowCount()

「行数はいくつですか?」という問い合わせのために呼ばれる関数です。

今回は行数=リストの長さなので、len 関数の結果を返しています。

これでモデルクラス側の対応は完了です。

ItemModel に対応している Widget クラスを用意する

次にビュークラスを実装します。

ItemModel は何にでも使えるわけではなく、QComboBox, QListView などの「複数のデータを並べる」系のクラスにのみ使えます。

QLabel, QPushButton などのウィジェットには対応していません。(わざわざ対応するほどでもないですが)

今回はコンボボックスクラスをそのまま使います。

combobox = QComboBox()

Widget にモデルクラスのオブジェクトを渡す

用意したコンボボックスにモデルクラスのオブジェクトを渡します。

QComboBox setModel() 関数を使います。

model = MyModel()
combobox.setModel(model)

これでコンボボックスと自作のモデルクラスを関連付けることができました。

モデルを変更する

モデル側にデータを追加してみます。

model.add('item1')
model.add('item2')

Widget 側に自動で反映される

モデルにデータを追加すると、コンボボックスの項目として自動で反映されます。

QComboBox に関して行ったのは、setModel 関数の呼び出しだけです。

課題は統一感?

自作の ItemModel と コンボボックスを使って、モデルの操作がビューに自動で反映されるようにしました。

今回は QAbstractListModel を使用した最小限の例を紹介しましたが、QStandardItemModel などを使えばもっと細かい制御ができます。

ただ、使ってみた僕の感想としては、「便利だけど、独特な決まりが多く使える範囲も限定的なので、使いにくい」です。

オーバーライドする関数については、「何をオーバーライドすれば良いのか」がわかりにくいですし、role の意味を調べたりするのも面倒です。

ビュー側のどのタイミングでどの関数が呼ばれるのかも、パッと見ではわからないので、デバッグも面倒そうです。

大規模プロジェクトで使うのであれば、メンバーのスキルがそれなりに高くないと保守が大変になるかもしれません。

また、ItemModel が対応していない QLabel などは直接触らざるをえないので、一部分だけ ItemModel を使うことで、かえって読みづらいコードになってしまいそうです。

個人的にはシグナル&スロットで統一したほうが細かい融通が利きやすく、処理を追いやすくなるので、長期的にはメリットが大きいと思いました。

おまけ:操作を敢えて制限してみる

ItemModel を使った場合のとっつきにくさを解消するためのアイデアとして、敢えて操作を制限する というのを提案してみたいと思います。

たとえば QComboBox の生成時。

combobox = QComboBox()

model = MyModel()
combobox.setModel(model)

QComboBox を生成してから setModel を呼んでいますが、モデルをセットしなくてもコンボボックスは使えてしまいます。

複数人で共同作業を行う場合、人によってコンボボックスの使い方にバラつきが出てしまうかもしれません。

そこでコンボボックスをラップした独自コンボボックスクラスを用意し、「うちのチームでは必ずこっちを使ってね」というルールを定めます。

class MyComboBox(QWidget):
    """MyModel の使用を強制する独自コンボボックス.

    コンボボックスを継承ではなく内包することで、View を直接操作する手段を制限しています。
    """

    def __init__(self, model: MyModel, parent: QWidget = None) -> None:
        super().__init__(parent=parent)
        self._combobox = QComboBox()
        self._combobox.setModel(model)

        layout = QVBoxLayout(self)
        layout.addWidget(self._combobox)
        layout.setContentsMargins(0, 0, 0, 0)
        self.setLayout(layout)

    def select(self, index: int) -> None:
        """指定したインデックスのアイテムを選択します."""

        self._combobox.setCurrentIndex(index)

コンボボックスを生成するには必ずモデルを渡す必要があり、内部で使用しているコンボボックスは private なため、外部から直接コンボボックスを触ることはできません。

選択項目の変更など、ごく限られた操作のみを公開し、それ以外は全てモデル経由で操作しないといけないようになっています。
(いっそ「選択中のインデックス」もモデル側に持たせてしまっても良いかもしれません。)

このコンボボックスを使用する限り、ビューとモデルの分離を強制することができます。

初期の実装の手間はありますが、意図しない使い方でトラブルが起きるのを防げるかも…?

サンプルコード

今回の検証で使用した場合のコードを GitHub にアップしました。

pyside6/modelview.py at master · tadosuke/pyside6
Qt(pyside6)のサンプル. Contribute to tadosuke/pyside6 development by creating an account on GitHub.

より詳しく見てみたい方はどうぞ。

以上、検証おしまい!

コメント

  1. […] […]

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