どうもです、タドスケです。
新しい職場で Qt(Pyside6)を使い始めて1年ちょっと。
様々な処理が簡単に書けていいなぁと思う反面、気を付けて実装しなかったために問題を起こしてしまったこともありました。
この記事では、僕が現場で実際にやらかした間違いを紹介します。
(僕がやらかすたびに記事が増えていきます)
Qt を学ばれる方にとって、参考になれば幸いです。
QPixmap の生成をメインスレッド外でやってしまう
画像を GUI 上に表示できる QPixmap。
画像の読み込みは時間がかかるため、非同期(メインスレッド外)で行われることが多いです。
そこで僕がやらかしたのは、メインスレッド外で呼ばれる関数の中で QPixmap の生成を行ってしまうパターンでした。
ダメな例
"""QPixmap の生成をメインスレッド外で行ってしまうサンプル:ダメな例."""
import concurrent.futures
from PySide6.QtGui import QPixmap
from PySide6.QtWidgets import QApplication, QLabel
_IMAGE_FILE = '../resource/face.png'
def _load_pixmap():
"""Pixmap を読み込む."""
return QPixmap(_IMAGE_FILE)
if __name__ == '__main__':
app = QApplication([])
# QPixmap をメインスレッド外で生成
executor = concurrent.futures.ThreadPoolExecutor()
future = executor.submit(_load_pixmap)
pixmap = future.result()
label = QLabel()
label.setPixmap(pixmap)
label.show()
app.exec()
Qt に限らず、GUI 要素はメインスレッドで操作するのがお決まりらしいです。
とはいえ、時間のかかる画像読み込みを非同期で行いたいのは変わりません。
そんなときは以下の方法で、必要最小限の操作のみをメインスレッド上で行えます。
- 非同期で QImage を読み込む
- 読み込んだ QImage を使って、メインスレッド側で QPixmap を生成する
QImage は画像をメモリ上で表現したものなので、メインスレッド外で扱っても大丈夫です。
この方法で実装した例が以下です。
良い例
"""QPixmap の生成をメインスレッド外で行ってしまうサンプル:よい例."""
import concurrent.futures
from PySide6.QtGui import QPixmap, QImage
from PySide6.QtWidgets import QApplication, QLabel
_IMAGE_FILE = '../resource/face.png'
def _load_image():
"""QImage を読み込む."""
return QImage(_IMAGE_FILE)
if __name__ == '__main__':
app = QApplication([])
# QImage をメインスレッド外で生成
executor = concurrent.futures.ThreadPoolExecutor()
future = executor.submit(_load_image)
image = future.result()
# メインスレッド上で QImage から QPixmap を生成
pixmap = QPixmap.fromImage(image)
label = QLabel()
label.setPixmap(pixmap)
label.show()
app.exec()
QObject の親を設定し忘れる
Qt 上で表示されるウィンドウやボタンなどは、QObject というクラスを継承しています。
QObject は、親子関係を持つオブジェクトツリー上で管理されおり、親が削除されると子も自動で削除されるようになっています。
オブジェクトの親子関係は、生成時の parent 引数や setParent 関数で設定できます。
ここでよくあるミスが、parent を設定し忘れてしまうことです。
親のいないオブジェクトは、親が削除された後も残り続けてしまいます。
ダメな例
"""オブジェクトに親を設定し忘れるサンプル:悪い例."""
from PySide6.QtWidgets import QApplication, QWidget, QPushButton
app = QApplication([])
window = QWidget()
# 親を指定せずにボタンを生成
button = QPushButton("Click Me!")
button.destroyed.connect(lambda _: print('destroyed'))
# window の削除予約
window.deleteLater()
window.show()
app.exec()
# destroyed は表示されない:Button はまだ残っている
これを防ぐには、オブジェクト生成時に parent 引数を指定します。
良い例
"""オブジェクトに親を設定し忘れるサンプル:良い例."""
from PySide6.QtWidgets import QApplication, QWidget, QPushButton
app = QApplication([])
window = QWidget()
# 親を指定してボタンを生成
button = QPushButton("Click Me!", parent=window)
button.destroyed.connect(lambda _: print('destroyed'))
# window(親) の削除予約
window.deleteLater()
window.show()
app.exec()
# destroyed が表示される:Button はもう残っていない
特別な理由がない限り、基本は親オブジェクトを設定しておいたほうが、問題は起こりにくいかと思います。
僕のいる現場では、独立して出てくるメッセージダイアログなども、メインウィンドウの子にしています。
こうしないと、「アプリを複数起動した際に、誰が親なのかがわかりにくくなってしまう」という問題が起きます。
GUI をプログラムから更新する際にシグナルがループしてしまう
Qt で独自の View-Model クラスを作っていて、View の変更を Model に、Model の変更を View に通知し合いたい場合。通知には QObject のシグナルを使うとします。
ここでよくあるミスが、スロット内で相手の set 関数を呼ぶ際にシグナルが発生し、それを受け取ってまた set を呼んで…と、シグナルの呼び出しが無限ループしてしまうことです。
無限ループって怖いですね。
ダメな例
"""GUI をプログラムから更新する際にシグナルがループしてしまうサンプル:ダメな例."""
import sys
from PySide6.QtCore import QObject, Signal
from PySide6.QtWidgets import QApplication, QVBoxLayout, QWidget, QLabel
class MyModel(QObject):
"""モデルクラス."""
value_changed = Signal()
def __init__(self, parent: QObject):
super().__init__(parent=parent)
self.value = 0
def set_value(self, value: int):
self.value = value
self.value_changed.emit()
class MyLabel(QLabel):
"""ラベルクラス."""
value_changed = Signal()
def __init__(self, parent: QWidget):
super().__init__('0', parent=parent)
def set_value(self, value: int):
self.setText(str(value))
self.value_changed.emit()
class MyWidget(QWidget):
def __init__(self):
super().__init__()
self.model = MyModel(parent=self)
self.model.value_changed.connect(self._on_model_changed)
self.label = MyLabel(parent=self)
self.label.value_changed.connect(self._on_view_changed)
layout = QVBoxLayout()
layout.addWidget(self.label)
self.setLayout(layout)
def _on_view_changed(self):
print('view_changed')
self.model.set_value(int(self.label.text()))
def _on_model_changed(self):
print('model_changed')
self.label.set_value(self.model.value)
if __name__ == "__main__":
app = QApplication(sys.argv)
widget = MyWidget()
widget.show()
widget.model.set_value(1)
sys.exit(app.exec())
これを防ぐには、QObject の blockSignals を使い、スロット内で set 関数を呼んでいる間だけ、シグナルをブロックするようにします。
blockSignals は使い終わったら False で解除する必要があります。
解除忘れを防ぐために、contextmanager を使うと良いでしょう。
良い例
"""GUI をプログラムから更新する際にシグナルがループしてしまうサンプル:よい例."""
import sys
from contextlib import contextmanager
from PySide6.QtCore import QObject, Signal
from PySide6.QtWidgets import QApplication, QVBoxLayout, QWidget, QLabel
class MyModel(QObject):
"""モデルクラス."""
value_changed = Signal()
def __init__(self, parent: QObject):
super().__init__(parent=parent)
self.value = 0
def set_value(self, value: int):
self.value = value
self.value_changed.emit()
class MyLabel(QLabel):
"""ラベルクラス."""
value_changed = Signal()
def __init__(self, parent: QWidget):
super().__init__('0', parent=parent)
def set_value(self, value: int):
self.setText(str(value))
self.value_changed.emit()
class MyWidget(QWidget):
def __init__(self):
super().__init__()
self.model = MyModel(parent=self)
self.model.value_changed.connect(self._on_model_changed)
self.label = MyLabel(parent=self)
self.label.value_changed.connect(self._on_view_changed)
layout = QVBoxLayout()
layout.addWidget(self.label)
self.setLayout(layout)
def _on_view_changed(self):
print('view_changed')
with block_signals(self.model): # model からのシグナルをブロック
self.model.set_value(int(self.label.text()))
def _on_model_changed(self):
print('model_changed')
with block_signals(self.label): # view からのシグナルをブロック
self.label.set_value(self.model.value)
@contextmanager
def block_signals(obj: QObject):
"""コンテキストの間だけ、obj のシグナルをブロックする."""
obj.blockSignals(True)
yield
obj.blockSignals(False)
if __name__ == "__main__":
app = QApplication(sys.argv)
widget = MyWidget()
widget.show()
widget.model.set_value(1)
widget.label.set_value(2)
sys.exit(app.exec())
ウィンドウコンテナに埋め込んでいる QWindow の座標を position で取ろうとしてしまう
QWidget.createWindowContainer を使って外部で生成した QWindow を埋め込んだウィンドウコンテナを作る場合。
例えばエディタを作っていて、ゲームウィンドウをエディタ内に埋め込む場合など。
ゲームウィンドウの位置を取ろうとした際に、QWindow.position() で取ろうとするとうまくいきません。
"""QWindow をウィンドウコンテナに埋め込んだ状態での座標取得."""
from PySide6.QtCore import QPoint
from PySide6.QtGui import QWindow
from PySide6.QtWidgets import QApplication, QWidget
def _print_point(point: QPoint):
print(f'({point.x()}, {point.y()})')
app = QApplication()
# QWindow を生成
window = QWindow()
window.setPosition(10, 20)
_print_point(window.position()) # (10, 20)
# ウィンドウを埋め込んだウィンドウコンテナ(QWidget)を生成
widget = QWidget().createWindowContainer(window)
widget.move(100, 200)
widget.show()
_print_point(widget.pos()) # (100, 200)
_print_point(window.position()) # (0, 0)
app.exec()
上のコードのような例で、僕は window.position() が(100, 200)を返してくれることを期待していましたが、実際にはそうなりませんでした。
ウィンドウコンテナに埋め込んだウィンドウの座標は、ウィンドウではなくウィンドウコンテナから取得する必要があります。
具体的には、widget.pos() を使います。
コメント