この記事は、PythonでデスクトップGUIを作るための公式バインディング PySide6(Qt for Python) を、まったくの初心者でも理解できるように段階的に解説します。
インストールから基本ウィジェットの使い方、レイアウト、イベント処理(シグナルとスロット)、簡単な実践アプリ、さらに配布時の注意点やパフォーマンスのヒントまで網羅します。
記事の後半には演習問題(実践課題)とその解答例を用意しているので、学んだ知識をすぐに試すことができます。
<参考>
PySide6はQt公式のPythonバインディングで、pipでインストール可能です。(PyPI)
公式ドキュメントでAPI詳細やチュートリアルも充実しています。(doc.qt.io)
PySide6とは?
PySide6は、Qt(C++で書かれた成熟したGUIフレームワーク)をPythonから使えるようにした公式バインディングです。
クロスプラットフォーム(Windows/Mac/Linux)で同じコードが動き、豊富なウィジェットやスタイル、描画機能を持ちます。
開発環境の準備
仮想環境の作成
プロジェクトごとに仮想環境(venv, pipenv, poetryなど)を作成するのを強くおすすめします。
python -m venv venv
# Windows
venv\Scripts\activate
# macOS / Linux
source venv/bin/activate
PySide6 のインストール
通常は pip でインストールします。
pip install PySide6
PySide6 は PyPI 上で配布されており、このコマンドで基本的に必要なモジュールが入ります(Essentials と Addons のエイリアス処理が行われることがあります)。
※ Qt Designer などのツールは別途インストール/利用が必要になることがあります(開発環境に応じて)。
最初のアプリ(Hello World)
まずは最小構成のウィンドウを作ってみましょう。
重要なのは QApplication(アプリ全体の管理)と QWidget(ウィンドウやウィジェットの基底)です。
# hello.py
import sys
from PySide6.QtWidgets import QApplication, QLabel, QWidget, QVBoxLayout
app = QApplication(sys.argv)
window = QWidget()
window.setWindowTitle("Hello PySide6")
layout = QVBoxLayout(window)
label = QLabel("Hello, PySide6!")
layout.addWidget(label)
window.show()
sys.exit(app.exec())
このファイルを実行するとシンプルなウィンドウが開きます。
app.exec() がイベントループを開始し、ウィンドウの操作やシグナル処理を可能にします。
主要なウィジェット(使い方と例)
以下に主要ウィジェットの使い方を簡潔に紹介します。各ウィジェットは PySide6.QtWidgets モジュールに含まれます。
- QPushButton:クリック可能なボタン
- QLabel:テキストや画像を表示
- QLineEdit:1行入力欄(テキストボックス)
- QTextEdit:複数行のテキスト入力/表示
- QCheckBox:チェックボックス
- QRadioButton:ラジオボタン
- QComboBox:ドロップダウン
- QListWidget / QListView:リスト表示
- QTableWidget / QTableView:表形式データ
簡単な例:ボタンを押してラベルを書き換える
from PySide6.QtWidgets import QApplication, QWidget, QVBoxLayout, QPushButton, QLabel
import sys
app = QApplication(sys.argv)
w = QWidget()
lay = QVBoxLayout(w)
label = QLabel("まだ押されていません")
btn = QPushButton("押してね")
def on_click():
label.setText("ボタンが押されました!")
btn.clicked.connect(on_click)
lay.addWidget(label)
lay.addWidget(btn)
w.show()
sys.exit(app.exec())
レイアウトの基本
ウィジェットの配置にはレイアウトマネージャを使います。
直接座標で配置するよりも、レイアウトを使う方がウィンドウのサイズ変更やプラットフォーム差に強いです。
- QVBoxLayout:縦に並べる
- QHBoxLayout:横に並べる
- QGridLayout:格子状に配置
- QFormLayout:ラベルと入力欄の組み合わせに便利
レイアウトは入れ子にでき、複雑な画面を柔軟に作れます。
イベント処理:シグナルとスロット
Qt系のイベント処理は「シグナル(signal)とスロット(slot)」で行います。
ウィジェットはイベント(例:クリック、テキスト変更)をシグナルとして発し、それに関数(スロット)を接続します。
button.clicked.connect(handler_function)
lineedit.textChanged.connect(on_text_changed)
スロットには関数だけでなく、メソッドや lambda も使えます。引数の数や型に注意して接続してください。
ダイアログ、メニュー、ツールバー
QMainWindow を使うとメニューやツールバーを持つアプリケーションが作りやすくなります。
QDialog は、ユーザに入力を促す小さなウィンドウを作るのに便利です。
例:メニューの作り方
from PySide6.QtWidgets import QMainWindow, QAction
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
file_menu = self.menuBar().addMenu("&File")
exit_action = QAction("Exit", self)
exit_action.triggered.connect(self.close)
file_menu.addAction(exit_action)
リソース管理(画像・スタイル)
画像やアイコンは QPixmap / QIcon を使って表示します。
複数のリソースを扱うときは Qt のリソースシステム(.qrc)を使ってバイナリに埋め込むこともできます。
スタイルは CSS 風の setStyleSheet() で見た目を変えられます。ただし過度のカスタムは保守性を下げるので注意。
マルチスレッドとUIの安全な更新
GUIはメインスレッド(UIスレッド)で動きます。
長時間かかる処理をメインスレッドで動かすとUIが固まるため、QThread や Python の concurrent.futures を使ってバックグラウンドで処理を行い、完了時にシグナルでメインスレッドに結果を渡します。
QThread は、 Qt(=PySide6)で「別スレッド」を管理するためのクラスです。QThread 自体はスレッドを管理し、run() を実行することでそのスレッド上で処理が動きます。大量計算や I/O のような「時間のかかる処理」をメイン(UI)スレッドとは別に動かすために使います。
Qt の GUI 操作(ウィジェットの作成・更新など)は必ずメインスレッドで行うこと。
別スレッドから直接 QLabel.setText() 等を呼ぶと不定挙動やクラッシュの原因になります。
スレッド間のやり取りは Signal(シグナル)を使ってメインスレッドに通知し、そこで UI を更新しましょう。
直接別スレッドからUIを変更しないこと!
QThread を使う代表パターン(2つ)
現場でよく使われるパターンは主に 2 種類です。どちらかを覚えればまずは十分です。
パターン A — QThread を継承して run() を実装する方法
手軽に書けるが、QThread オブジェクト自身は元のスレッド(作成したスレッド)に所属する性質などに注意が必要。単純なケース向け。
# A) QThread を継承する簡単な例(学習用)
# qthread_example_a.py
import sys, time
from PySide6.QtWidgets import QApplication, QWidget, QVBoxLayout, QPushButton, QLabel
from PySide6.QtCore import QThread, Signal
class WorkerThread(QThread):
# メインスレッドに送るためのシグナル
progress = Signal(int)
finished = Signal()
def run(self):
# run() は別スレッドで実行される
for i in range(1, 6):
time.sleep(1) # 重い処理の代わり
self.progress.emit(i) # シグナルで進捗を通知
self.finished.emit()
class MainWindow(QWidget):
def __init__(self):
super().__init__()
self.setWindowTitle("QThread 継承の例")
self.label = QLabel("未開始")
self.btn = QPushButton("開始")
layout = QVBoxLayout(self)
layout.addWidget(self.label)
layout.addWidget(self.btn)
self.btn.clicked.connect(self.start_thread)
def start_thread(self):
self.btn.setEnabled(False)
self.thread = WorkerThread() # 参照を保持すること(GC防止)
self.thread.progress.connect(self.on_progress)
self.thread.finished.connect(self.on_finished)
self.thread.start() # <- run() を別スレッドで呼ぶ
def on_progress(self, i):
# シグナルはメインスレッド側のスロットで安全に UI 更新ができる
self.label.setText(f"進捗: {i}")
def on_finished(self):
self.label.setText("完了")
self.btn.setEnabled(True)
self.thread = None
if __name__ == "__main__":
app = QApplication(sys.argv)
w = MainWindow()
w.show()
sys.exit(app.exec())
start() を使う。run() を直接呼んではいけません(直接呼ぶと別スレッドになりません)。
パターン B(推奨)— 長時間処理を行う QObject(Worker)を作り、moveToThread() で QThread に移動して使う方法
処理ロジックを QObject に切り出してシグナルでやり取りするため、設計が明瞭で安全性が高い。多くのプロや公式ドキュメントで推奨される。
# B) 推奨パターン:Worker(QObject) + moveToThread(実務向け)
# qthread_example_b.py
import sys, time
from PySide6.QtCore import QObject, Signal, Slot, QThread
from PySide6.QtWidgets import QApplication, QWidget, QVBoxLayout, QPushButton, QLabel
class Worker(QObject):
progress = Signal(int)
finished = Signal()
def __init__(self):
super().__init__()
self._running = True
@Slot()
def do_work(self):
# このメソッドは Worker が移動したスレッド上で動く
for i in range(1, 6):
if not self._running:
break
time.sleep(1)
self.progress.emit(i)
self.finished.emit()
@Slot()
def stop(self):
self._running = False
class MainWindow(QWidget):
def __init__(self):
super().__init__()
self.setWindowTitle("moveToThread の例(推奨)")
self.label = QLabel("未開始")
self.btn_start = QPushButton("開始")
self.btn_stop = QPushButton("停止")
self.btn_stop.setEnabled(False)
layout = QVBoxLayout(self)
layout.addWidget(self.label)
layout.addWidget(self.btn_start)
layout.addWidget(self.btn_stop)
self.btn_start.clicked.connect(self.start)
self.btn_stop.clicked.connect(self.stop)
def start(self):
self.btn_start.setEnabled(False)
self.btn_stop.setEnabled(True)
self.thread = QThread()
self.worker = Worker()
self.worker.moveToThread(self.thread)
# 接続
self.thread.started.connect(self.worker.do_work)
self.worker.progress.connect(self.on_progress)
self.worker.finished.connect(self.on_finished)
self.worker.finished.connect(self.thread.quit) # 終了したらスレッドのイベントループを抜ける
self.thread.finished.connect(self.thread.deleteLater)
self.worker.finished.connect(self.worker.deleteLater)
self.thread.start()
def stop(self):
self.btn_stop.setEnabled(False)
# Worker の stop() を呼んで安全に停止を指示
self.worker.stop()
def on_progress(self, i):
self.label.setText(f"進捗: {i}")
def on_finished(self):
self.label.setText("完了")
self.btn_start.setEnabled(True)
self.btn_stop.setEnabled(False)
# thread.quit() は worker.finished で呼ばれるので追加で呼ぶ必要はない
if __name__ == "__main__":
app = QApplication(sys.argv)
w = MainWindow()
w.show()
sys.exit(app.exec())
- 処理ロジック(Worker)が独立しているためテストしやすい。
- QObject のシグナル/スロット設計に自然に沿う。
公式ドキュメントや多くの実務者がこの方法を推奨しています。
中断・終了・クリーンアップのポイント
- スレッド終了:thread.quit() → thread.wait(timeout)(待つ)で確実に終了させる。quit() はスレッドのイベントループを抜けさせる。thread.terminate() は強制終了で危険なので避ける。
- オブジェクト破棄:deleteLater() を使って Qt のイベントループに安全に任せる。
- 参照保持:self.thread = thread のように参照を保持しないと即ガベージコレクトされて動作が不安定になる。
- UI 更新は 必ず シグナルで行う(UI 非スレッド側で行う)。
よくあるミスとデバッグのコツ
- run() を直接呼んでしまう(→ メインスレッドで同期実行され GUI が固まる)。
- スレッドオブジェクトをローカル変数にして参照が消える(GCで止まる)。→ self.thread に保持。
- 別スレッドから直接 UI を操作してクラッシュ。→ シグナル経由で更新。
- 「スレッドは終わったけどオブジェクトが残る」→ deleteLater() を使う。
- ロングループ内でイベント処理をしないためにアプリ全体が重くなる → ループは Sleep で区切るか適切に分割する。トラブル時はまずシグナルが届いているか、スレッドが起動しているか(isRunning())をログ出力して確認しましょう。
代替手段
- QThreadPool + QRunnable:大量の短いタスクを並列で処理するなら QThreadPool が管理してくれて便利です(スレッドの再利用など)。小さなバックグラウンド作業には使いやすいです。
- Python の threading モジュールを併用することも可能だが、Qt のシグナル/スロットと混ぜるなら QThread 系が扱いやすい。
モデル/ビュー(データ駆動の表示)
大量のデータや可変データを扱うときは QListView / QTableView と QAbstractListModel / QAbstractTableModel の組み合わせ(モデル/ビュー設計)を使うと効率的で柔軟です。
これによりデータと表示の分離、編集/ソート/フィルタの実装が容易になります。
(詳細な実装例はチュートリアルが多数あります)(Python GUIs)
デバッグとテスト、プロファイリング
print() による簡易デバッグはもちろん有用ですが、Qtアプリでは QLoggingCategory なども利用できます。
ユニットテストは pytest-qt のようなツールを使うことで、GUIイベントのテストが書きやすくなります。
パフォーマンスのボトルネックは cProfile などで測定し、必要ならアルゴリズムの見直しや C++ 拡張(Cython、PyBind等)を検討します。
パッケージングと配布(配布ツール)
PySide6を使ったアプリを配布する場合、バイナリ化・パッケージングが必要です。
PyInstallerや pyside6-deploy のようなツールがあります。
pyside6-deploy は PySide6 に付属または同梱されているデプロイ用ツールで、スタンドアロン実行ファイル作成を助けます。環境やライセンスに注意して使いましょう。(Qt Forum)
ベストプラクティスとよくある落とし穴
- UIコードとビジネスロジックを分離する(MVC/MVVM や Presenter を採用)。
- 長時間処理は必ずバックグラウンドで行う(QThread等)。
- リソース(画像、フォント)を正しく管理して、配布時に参照切れにならないようにする。
- Qt のシグナル接続は参照サイクルに注意(必要なら切断する)。
- クロスプラットフォームの差異(フォント、ウィンドウ装飾)を確認する。
実践編:簡単なアプリ例 — TODOリスト(概念)
ここでは「TODOリスト」アプリ(追加・削除・保存ができる簡易アプリ)の簡単な骨組みを紹介します。詳細なコードは演習問題で扱います。
- QMainWindow をベース
- QListWidget にタスクを並べる
- QLineEdit と QPushButton でタスク追加
- ファイル(JSON)に保存・読み込み
演習問題と解答例
以下は学んだ内容を確認するための演習問題(3問)と解答例です。まずは自分で考えてから解答例を確認してください。
演習1:最小GUIアプリを作る(基礎)
- QApplication と QWidget を使ってウィンドウを作る。
- 縦方向の QVBoxLayout を使い、上に QLabel(初期テキスト:”未実行”)、下に QPushButton(ラベル:”開始”)を配置する。
- ボタンを押すとラベルのテキストが “実行中…” に変わり、2秒後に “完了” に変わるようにする(UIが固まらないようにすること)。
- 直接 time.sleep(2) を使うとUIが固まるため、QTimer.singleShot を使うのが簡単です。
- 別スレッドで処理させたい場合は QThread を使う方法もありますが、ここでは簡易的に QTimer を使います。
解答例(参考コード)
# exercise1.py
import sys
from PySide6.QtWidgets import QApplication, QWidget, QVBoxLayout, QPushButton, QLabel
from PySide6.QtCore import QTimer
app = QApplication(sys.argv)
w = QWidget()
w.setWindowTitle("演習1 - 最小GUI")
layout = QVBoxLayout(w)
label = QLabel("未実行")
button = QPushButton("開始")
layout.addWidget(label)
layout.addWidget(button)
def on_start():
label.setText("実行中...")
# 2000ms = 2秒後に完了テキストに変更
QTimer.singleShot(2000, lambda: label.setText("完了"))
button.clicked.connect(on_start)
w.show()
sys.exit(app.exec())
演習2:簡易TODOアプリ(ファイル保存付き)
- QMainWindow を使った簡易TODOアプリを作る。
- 入力欄(QLineEdit)と「追加」ボタンで QListWidget にタスクを追加できること。
- QListWidget の項目を選んで「削除」ボタンで削除できること。
- メニューに「保存」「読み込み」を用意し、JSONファイルにタスクを保存/読み込みできること。
- QListWidget の addItem()、takeItem() を使う。
- ファイルは簡単のため tasks.json をアプリと同じフォルダに保存する。
解答例(参考コード)
# exercise2.py
import sys, json
from PySide6.QtWidgets import (QApplication, QMainWindow, QWidget, QListWidget,
QLineEdit, QPushButton, QHBoxLayout, QVBoxLayout,
QAction, QFileDialog, QMessageBox)
from PySide6.QtCore import Qt
class TodoMainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("TODO演習")
central = QWidget()
self.setCentralWidget(central)
self.list_widget = QListWidget()
self.input = QLineEdit()
add_btn = QPushButton("追加")
del_btn = QPushButton("削除")
h = QHBoxLayout()
h.addWidget(self.input)
h.addWidget(add_btn)
v = QVBoxLayout(central)
v.addLayout(h)
v.addWidget(self.list_widget)
v.addWidget(del_btn)
add_btn.clicked.connect(self.add_task)
del_btn.clicked.connect(self.delete_task)
menubar = self.menuBar()
file_menu = menubar.addMenu("ファイル")
save_action = QAction("保存", self)
load_action = QAction("読み込み", self)
file_menu.addAction(save_action)
file_menu.addAction(load_action)
save_action.triggered.connect(self.save_tasks)
load_action.triggered.connect(self.load_tasks)
def add_task(self):
text = self.input.text().strip()
if text:
self.list_widget.addItem(text)
self.input.clear()
def delete_task(self):
for item in list(self.list_widget.selectedItems()):
row = self.list_widget.row(item)
self.list_widget.takeItem(row)
def save_tasks(self):
tasks = [self.list_widget.item(i).text() for i in range(self.list_widget.count())]
path, _ = QFileDialog.getSaveFileName(self, "保存", filter="JSON Files (*.json)")
if path:
try:
with open(path, "w", encoding="utf-8") as f:
json.dump(tasks, f, ensure_ascii=False, indent=2)
except Exception as e:
QMessageBox.warning(self, "エラー", f"保存に失敗しました: {e}")
def load_tasks(self):
path, _ = QFileDialog.getOpenFileName(self, "読み込み", filter="JSON Files (*.json)")
if path:
try:
with open(path, "r", encoding="utf-8") as f:
tasks = json.load(f)
self.list_widget.clear()
for t in tasks:
self.list_widget.addItem(t)
except Exception as e:
QMessageBox.warning(self, "エラー", f"読み込みに失敗しました: {e}")
if __name__ == "__main__":
app = QApplication(sys.argv)
w = TodoMainWindow()
w.show()
sys.exit(app.exec())
演習3:長時間処理の非同期化(スレッド)
- 長時間かかる処理(例えば大きなリストをソートしたりダミーで時間のかかる関数)を別スレッドで実行し、終了時にUIに「完了」と表示する。
- スレッドは QThread を使い、メインスレッドで安全にUIを更新する。
- QThread を直接継承してワーカーを作るか、QObject をワーカーにして moveToThread() を使う方法がある。ここでは簡潔に QThread を示します。
- スレッドから直接 UI を触らない。終了シグナルを発してメインスレッド側で更新する。
解答例(参考コード)
# exercise3.py
import sys
import time
from PySide6.QtWidgets import QApplication, QWidget, QVBoxLayout, QPushButton, QLabel
from PySide6.QtCore import QThread, Signal
class Worker(QThread):
finished_signal = Signal(str)
def run(self):
# 長時間処理の代わりに sleep を使う例
time.sleep(3)
# 処理完了を通知
self.finished_signal.emit("バックグラウンド処理が完了しました")
class MainWindow(QWidget):
def __init__(self):
super().__init__()
self.setWindowTitle("演習3 - スレッド")
layout = QVBoxLayout(self)
self.label = QLabel("未実行")
self.btn = QPushButton("処理開始")
layout.addWidget(self.label)
layout.addWidget(self.btn)
self.btn.clicked.connect(self.start_work)
def start_work(self):
self.label.setText("処理中...")
self.btn.setEnabled(False)
self.worker = Worker()
self.worker.finished_signal.connect(self.on_finished)
self.worker.start()
def on_finished(self, message):
self.label.setText(message)
self.btn.setEnabled(True)
self.worker = None
if __name__ == "__main__":
app = QApplication(sys.argv)
w = MainWindow()
w.show()
sys.exit(app.exec())
まとめ
PySide6は学べば学ぶほど奥が深いフレームワークですが、基本を押さえれば短時間で実用的なデスクトップアプリが作れます。
まずは小さなプロジェクトを完成させ、徐々に機能を足していく開発スタイルがおすすめです。