PySide6 実践講座 #1 プロジェクト設計 — 習慣トラッカーを作りながら学ぶアプリ構造
PySide6 でデスクトップアプリを作る入門シリーズで、ウィジェット、シグナル、モデル/ビュー、パッケージングという部品を身につけました。ところが、部品を知っていることとアプリを完成させることの間には谷があります。ファイルをどう分けるのか、データはどこに置くのか、「動くけれどコードがぐちゃぐちゃ」をどう避けるのか。入門の例題は教えてくれません。この実践シリーズはその谷を渡ります。アプリひとつを 8 回かけて、設計から配布できる状態まで完成させます。
何を作るのか — ローカル習慣トラッカー「Daily」 #
作るのは習慣トラッカーです。毎日実践する習慣(運動、読書、水を飲む)を登録し、今日やったかをチェックし、草むらスタイルのヒートマップで継続を確認するローカルアプリです。この題材を選んだのは学習密度のためです。
- CRUD + 永続化: 習慣とチェック記録を SQLite に保存します(第 2 回)。
- モデル/ビューの実戦: カスタムモデルとデリゲートで一覧画面を作ります(第 3 回)。
- カスタム描画: ヒートマップには既製ウィジェットがないので QPainter で自作します(第 4 回)。
- デスクトップらしさ: トレイ常駐とリマインダー通知(第 5 回)、設定とテーマ(第 6 回)。
- 品質と配布: pytest-qt テスト(第 7 回)、アイコン・バージョン・CI までの仕上げ(第 8 回)。
機能要件は最初に画面単位で書き出しておくのがよいです。このアプリの画面は 3 つ。メイン画面(今日の習慣一覧 + チェック)、統計画面(習慣ごとのヒートマップと達成率)、設定画面(テーマ、リマインダー時刻)。画面リストがそのまま作業リストになり、「今回はどこまで」の境界になります。
構造 — UI、コア、データの 3 層 #
実践で最初に崩れるのが構造です。ボタンのハンドラーの中に SQL が入り始めると、テストも修正も不可能なひと塊になります。このシリーズは最初から 3 層に分けます。
daily/
├── pyproject.toml
└── src/daily/
├── __main__.py # エントリーポイント: python -m daily
├── app.py # QApplication の生成と組み立て
├── ui/ # 画面: ウィジェット、ウィンドウ、ビュー用モデル
│ └── main_window.py
├── core/ # ルール: 習慣・連続記録の計算など Qt と無関係なロジック
│ └── habits.py
└── data/ # 保存: SQLite アクセス(第 2 回で実装)
└── repository.py層のルールは 2 行に要約できます。依存の方向は ui → core → data の一方向、そして core では PySide6 をインポートしない。 core が Qt を知らないと良いことが連鎖します。「連続 14 日達成」のようなルールを GUI なしの普通の pytest で検証でき(第 7 回)、あとで CLI や Web に移すときも core はそのまま持っていけます。デスクトップアプリが壊れていく典型経路が「UI にロジックがこびりつくこと」なので、このルールひとつがシリーズ全体の背骨です。
プロジェクトのセットアップ #
セットアップは uv 基準です。環境管理に不慣れなら Python パッケージングシリーズを先にどうぞ。
uv init daily --package
cd daily
uv add pyside6
uv add --dev pytest pytest-qt--package オプションで src レイアウトのパッケージ型プロジェクトを作りました。単一スクリプトではなくパッケージで始める理由は第 8 回で分かります。PyInstaller で固めるときも、テストからインポートするときも、パッケージ構造のほうが問題が減ります。
メインウィンドウの骨組み #
今回のコードの目標は画面切り替えの構造までです。3 つの画面を QStackedWidget に入れ、サイドボタンで切り替える、この先 7 回が乗っかる骨組みです。
# src/daily/ui/main_window.py
from PySide6.QtWidgets import (
QHBoxLayout, QLabel, QMainWindow, QPushButton,
QStackedWidget, QVBoxLayout, QWidget,
)
class MainWindow(QMainWindow):
def __init__(self) -> None:
super().__init__()
self.setWindowTitle("Daily")
self.resize(480, 640)
# 画面の枠: 今はプレースホルダー、次回以降で実際の画面に差し替え
self.pages = QStackedWidget()
self.pages.addWidget(QLabel("今日")) # index 0: 第 3 回で実装
self.pages.addWidget(QLabel("統計")) # index 1: 第 4 回で実装
self.pages.addWidget(QLabel("設定")) # index 2: 第 6 回で実装
nav = QVBoxLayout()
for i, name in enumerate(["今日", "統計", "設定"]):
btn = QPushButton(name)
btn.clicked.connect(lambda _=False, idx=i: self.pages.setCurrentIndex(idx))
nav.addWidget(btn)
nav.addStretch()
root = QWidget()
layout = QHBoxLayout(root)
layout.addLayout(nav)
layout.addWidget(self.pages, stretch=1)
self.setCentralWidget(root)# src/daily/app.py
import sys
from PySide6.QtWidgets import QApplication
from daily.ui.main_window import MainWindow
def main() -> int:
app = QApplication(sys.argv)
app.setApplicationName("daily")
app.setOrganizationName("daily-app") # 第 6 回の QSettings がこの値を使います
window = MainWindow()
window.show()
return app.exec()# src/daily/__main__.py
import sys
from daily.app import main
sys.exit(main())uv run python -m daily で実行すると、左側にナビゲーション、右側に 3 つの空画面のあるウィンドウが立ち上がります。ラムダで idx=i と値を固定しているのは、入門 #3 で扱ったクロージャーの罠の実戦適用です。setOrganizationName のような些細に見える行も後の回(QSettings)への伏線なので、今のうちに入れておきます。
このシリーズの地図 #
| 回 | 作るもの | 核心技術 |
|---|---|---|
| 1(この記事) | 設計と骨組み | 3 層構造、QStackedWidget |
| 2 | データ層 | SQLite、リポジトリ、マイグレーション |
| 3 | 今日画面 | カスタムモデル、デリゲート |
| 4 | 統計画面 | QPainter ヒートマップ |
| 5 | トレイ常駐 | QSystemTrayIcon、通知、単一インスタンス |
| 6 | 設定 | QSettings、テーマ、バックアップ |
| 7 | テスト | pytest-qt、層別テスト戦略 |
| 8 | 配布の仕上げ | アイコン・バージョン、署名の概要、GitHub Actions |
まとめ #
- このシリーズは習慣トラッカーアプリひとつを 8 回かけて、設計から配布可能な状態まで完成させます。画面リスト(今日・統計・設定)が作業の境界です。
- 構造は ui → core → data の一方向 3 層で、core は PySide6 をインポートしません。このルールがテスト可能性と保守性の根です。
- プロジェクトは uv の src レイアウトのパッケージとして始めます。パッケージングとテストでこの選択が効いてきます。
- メインウィンドウは QStackedWidget ベースの画面切り替えの骨組みとして作りました。次回はこのアプリの心臓、SQLite のデータ層を作ります。