0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

PyQt5のsignal/slotが分かりにくかったので「状態変更の通知」として理解する

0
Posted at

PyQt5を触り始めたとき、signal/slotが少し直感的ではありませんでした。

button.clicked.connect(...) のような組み込みシグナルはなんとなく使えるのですが、自分で pyqtSignal を定義し始めると、急に分かりにくくなります。

  • connect() は何をしているのか
  • emit() は何をしているのか
  • emit() に値を渡せるのか
  • boolstr だけでなく、自作オブジェクトも渡せるのか
  • 普通に関数を呼ぶのと何が違うのか

この記事では、PyQt5のsignal/slotを 「状態が変わったことを、関心のある相手に通知する仕組み」 として整理します。

サンプルアプリ

今回は、ユーザー一覧をクリックすると、右側の詳細パネルが更新される小さなアプリを作ります。
ここでは「ユーザーがクリックされた」という操作を、「選択中のユーザーが変わった」という状態変更として扱います。

+-----------------+------------------+
| ユーザー一覧     | 詳細             |
|-----------------|------------------|
| Alice           | 名前: Alice      |
| Bob             | 年齢: 24         |
| Carol           | 権限: admin      |
+-----------------+------------------+

一覧側は「ユーザーが選ばれたよ」と通知するだけです。
詳細パネル側は、その通知を受け取って表示を更新します。

signalは「通知の口」

まず、ユーザー情報を表すクラスを用意します。

from dataclasses import dataclass

@dataclass
class User:
    name: str
    age: int
    role: str

次に、ユーザー一覧ウィジェットを作ります。

from PyQt5.QtCore import Qt, pyqtSignal
from PyQt5.QtWidgets import QListWidget, QListWidgetItem

class UserListWidget(QListWidget):
    user_selected = pyqtSignal(object)

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

        for user in users:
            item = QListWidgetItem(user.name)
            item.setData(Qt.UserRole, user)
            self.addItem(item)

        self.itemClicked.connect(self.on_item_clicked)

    def on_item_clicked(self, item):
        user = item.data(Qt.UserRole)
        self.user_selected.emit(user)

ここで重要なのはこの部分です。

user_selected = pyqtSignal(object)

これは「object を1つ渡せるsignal」を定義しています。

そして、ユーザーがクリックされたときに、

self.user_selected.emit(user)

として、選択された User オブジェクトを飛ばしています。

slotは「通知を受け取る関数」

次に、詳細パネルを作ります。

from PyQt5.QtWidgets import QWidget, QLabel, QVBoxLayout

class UserDetailWidget(QWidget):
    def __init__(self):
        super().__init__()

        self.name_label = QLabel("名前: -")
        self.age_label = QLabel("年齢: -")
        self.role_label = QLabel("権限: -")

        layout = QVBoxLayout()
        layout.addWidget(self.name_label)
        layout.addWidget(self.age_label)
        layout.addWidget(self.role_label)
        self.setLayout(layout)

    def set_user(self, user):
        self.name_label.setText(f"名前: {user.name}")
        self.age_label.setText(f"年齢: {user.age}")
        self.role_label.setText(f"権限: {user.role}")

set_user() は普通のメソッドです。
ただし、signalに接続することで「通知を受け取る関数」として使えます。
PyQtでは @pyqtSlot を付けてslotを定義する書き方もありますが、まずは普通のメソッドを connect() できる、と理解すれば十分です。

connect() は「あとで呼ぶ予約」

一覧と詳細パネルをつなぎます。

user_list.user_selected.connect(user_detail.set_user)

これは user_detail.set_user() を今すぐ呼んでいるわけではありません。

user_selectedemit() されたら、user_detail.set_user を呼んでね」と登録しています。

つまり、流れはこうです。

ユーザーをクリックする
  ↓
UserListWidget.on_item_clicked() が呼ばれる
  ↓
self.user_selected.emit(user)
  ↓
connectされていた user_detail.set_user(user) が呼ばれる
  ↓
詳細パネルが更新される

コードのディレクトリ構造

サンプルを少しだけ実アプリっぽく分けるなら、例えば次のような構成にできます。

pyqt-signal-sample/
├── main.py
├── models/
│   ├── __init__.py
│   └── user.py
└── views/
    ├── __init__.py
    ├── user_detail_widget.py
    └── user_list_widget.py

それぞれの役割は次のようなイメージです。

  • models/user.py: User データクラスを置く
  • views/user_list_widget.py: ユーザー一覧と user_selected signalを置く
  • views/user_detail_widget.py: 選択されたユーザーの詳細表示を置く
  • main.py: 画面を作り、signalとslotを connect() する

この記事では全体像を追いやすくするために、この構成を1ファイルにまとめたコードを載せます。

全体のコード

import sys
from dataclasses import dataclass

from PyQt5.QtCore import Qt, pyqtSignal
from PyQt5.QtWidgets import (
    QApplication,
    QWidget,
    QListWidget,
    QListWidgetItem,
    QLabel,
    QHBoxLayout,
    QVBoxLayout,
)


@dataclass
class User:
    name: str
    age: int
    role: str


class UserListWidget(QListWidget):
    user_selected = pyqtSignal(object)

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

        for user in users:
            item = QListWidgetItem(user.name)
            item.setData(Qt.UserRole, user)
            self.addItem(item)

        self.itemClicked.connect(self.on_item_clicked)

    def on_item_clicked(self, item):
        user = item.data(Qt.UserRole)
        self.user_selected.emit(user)


class UserDetailWidget(QWidget):
    def __init__(self):
        super().__init__()

        self.name_label = QLabel("名前: -")
        self.age_label = QLabel("年齢: -")
        self.role_label = QLabel("権限: -")

        layout = QVBoxLayout()
        layout.addWidget(self.name_label)
        layout.addWidget(self.age_label)
        layout.addWidget(self.role_label)
        layout.addStretch()
        self.setLayout(layout)

    def set_user(self, user):
        self.name_label.setText(f"名前: {user.name}")
        self.age_label.setText(f"年齢: {user.age}")
        self.role_label.setText(f"権限: {user.role}")


class MainWindow(QWidget):
    def __init__(self):
        super().__init__()

        users = [
            User("Alice", 30, "admin"),
            User("Bob", 24, "member"),
            User("Carol", 28, "guest"),
        ]

        user_list = UserListWidget(users)
        user_detail = UserDetailWidget()

        user_list.user_selected.connect(user_detail.set_user)

        layout = QHBoxLayout()
        layout.addWidget(user_list)
        layout.addWidget(user_detail)
        self.setLayout(layout)

        self.setWindowTitle("PyQt5 signal sample")


if __name__ == "__main__":
    app = QApplication(sys.argv)

    window = MainWindow()
    window.resize(500, 250)
    window.show()

    sys.exit(app.exec_())

emit() には値を渡せる

emit() は、ただ「何か起きた」と知らせるだけではありません。
値も一緒に渡せます。

user_selected = pyqtSignal(object)

と定義したsignalなら、

self.user_selected.emit(user)

のようにオブジェクトを渡せます。

受け取る側は、

def set_user(self, user):
    ...

のように、渡される値を引数で受け取ります。

object以外も渡せる

例えば、boolを渡すならこうです。

dark_mode_changed = pyqtSignal(bool)
self.dark_mode_changed.emit(True)

文字列を渡すならこうです。

message_changed = pyqtSignal(str)
self.message_changed.emit("Hello")

複数の値も渡せます。

name_changed = pyqtSignal(str, str)
self.name_changed.emit("old name", "new name")

自作クラスを渡したい場合は、今回のように object にしておくと分かりやすいです。

user_selected = pyqtSignal(object)

また、型を明示したい場合は、自作クラスを書くこともできます。

user_selected = pyqtSignal(User)

ただし、まずは object で理解するのが楽だと思います。

signalを使うと何が嬉しいのか

今回の例では、UserListWidgetUserDetailWidget の存在を知りません。

一覧側はただ、

self.user_selected.emit(user)

と通知しているだけです。

詳細パネル側も、一覧の内部実装を知りません。

user_list.user_selected.connect(user_detail.set_user)

この1行で、両者を外側からつないでいます。

つまり、signal/slotを使うと、

  • 通知する側
  • 通知を受け取る側
  • それらを接続する部分

を分けて書けます。

小さいサンプルだと少し大げさに見えますが、画面が増えたり、同じイベントを複数の場所で受け取りたくなったりすると、この分離が効いてきます。

MVCっぽく見ると理解しやすい

この考え方は、MVCやModel/Viewの考え方にも近いです。

MVCは Model - View - Controller の略で、ざっくり言うと次のように責務を分ける考え方です。

  • Model: データや状態を持つ
  • View: 画面表示を担当する
  • Controller: ユーザー操作を受け取り、ModelやViewをつなぐ

今回のサンプルでいうと、User はデータを持つのでModel寄りです。
UserListWidgetUserDetailWidget は画面表示を担当するのでView寄りです。
そして、

user_list.user_selected.connect(user_detail.set_user)

のように、どの通知をどの処理につなぐかを決める部分はController的な役割に近いです。

ただし、PyQtのアプリを厳密に「これはMVCです」と言い切ると少しややこしくなります。
QtにはQt独自のModel/Viewの仕組みもありますし、実際のアプリではMVC、MVP、MVVMっぽい考え方が混ざることもあります。

なので、この記事では厳密な設計パターンとしてではなく、「signal/slotを使うと、MVCっぽく責務を分けやすくなる」 くらいに捉えるのがちょうどよいと思います。

注意点

pyqtSignal は基本的にクラス変数として定義します。

class UserListWidget(QListWidget):
    user_selected = pyqtSignal(object)

また、signalを使うクラスは QObject 系のクラスを継承している必要があります。
今回は QListWidgetQObject を継承しているので、そのまま使えます。

もう1つ注意したいのは、オブジェクトを渡した場合、基本的には同じオブジェクトへの参照が渡るという点です。

def set_user(self, user):
    user.name = "Changed"

のようにslot側で変更すると、元のオブジェクトにも影響します。
「コピーが飛んでくる」わけではないので、そこは意識しておくとよいです。

まとめ

PyQt5のsignal/slotは、最初は少し独特に見えます。

ただ、次のように考えると理解しやすくなります。

  • pyqtSignal は通知の種類を定義する
  • connect() は通知が来たときに呼ぶ関数を登録する
  • emit() は通知を発火する
  • emit() には値を渡せる
  • object を使えば自作オブジェクトも渡せる
  • signalを使うと、通知する側と受け取る側をゆるくつなげられる
  • MVCっぽく、データ・表示・接続部分の責務を分けやすくなる

signal/slotは、単なるボタンクリック用の仕組みではなく、アプリ内で「状態が変わったこと」を伝えるための仕組みとして使えます。

0
1
0

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
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?