PyQt5を触り始めたとき、signal/slotが少し直感的ではありませんでした。
button.clicked.connect(...) のような組み込みシグナルはなんとなく使えるのですが、自分で pyqtSignal を定義し始めると、急に分かりにくくなります。
-
connect()は何をしているのか -
emit()は何をしているのか -
emit()に値を渡せるのか -
boolやstrだけでなく、自作オブジェクトも渡せるのか - 普通に関数を呼ぶのと何が違うのか
この記事では、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_selected が emit() されたら、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_selectedsignalを置く -
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を使うと何が嬉しいのか
今回の例では、UserListWidget は UserDetailWidget の存在を知りません。
一覧側はただ、
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寄りです。
UserListWidget や UserDetailWidget は画面表示を担当するので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 系のクラスを継承している必要があります。
今回は QListWidget が QObject を継承しているので、そのまま使えます。
もう1つ注意したいのは、オブジェクトを渡した場合、基本的には同じオブジェクトへの参照が渡るという点です。
def set_user(self, user):
user.name = "Changed"
のようにslot側で変更すると、元のオブジェクトにも影響します。
「コピーが飛んでくる」わけではないので、そこは意識しておくとよいです。
まとめ
PyQt5のsignal/slotは、最初は少し独特に見えます。
ただ、次のように考えると理解しやすくなります。
-
pyqtSignalは通知の種類を定義する -
connect()は通知が来たときに呼ぶ関数を登録する -
emit()は通知を発火する -
emit()には値を渡せる -
objectを使えば自作オブジェクトも渡せる - signalを使うと、通知する側と受け取る側をゆるくつなげられる
- MVCっぽく、データ・表示・接続部分の責務を分けやすくなる
signal/slotは、単なるボタンクリック用の仕組みではなく、アプリ内で「状態が変わったこと」を伝えるための仕組みとして使えます。