2025-2I プログラミング1 第25回 講義資料

2025年01月19日(月)5・6時限

1 連絡事項と講義概要

1.1 連絡

1.2 課題08

次のような不備が見られました。評価のフィードバック前に大至急修正のこと。

1.3 講義概要

今回の講義では PySide (PyQt) というライブラリを使用した「GUIアプリ」の開発について学んでいきます。Qt「キューティ」ではなく「キュート」 と読みます。

img

今回講義の「達成目標」は、次のようになります。

なお、PySide をある程度理解して利用するためには「オブジェクト指向プログラミング (特にクラス定義関連) の基礎的な理解と知識」が必要となります。オブジェクト指向プログラミング (OOP) については第23回講義で学習済みです。

2 GUI (Graphical User Interface)

GUI (=Graphical User Interface) とは、CLI (=Command Line Interface) あるいは CUI (=Character User Interface) と「対」になる言葉です。

CLIアプリは、コンソール画面での テキストベースのインタラクション を通じて人間とコンピュータが対話します。これに対して、GUIアプリでは、いわゆる「ウィンドウ」に配置した「ボタン」「テキストボックス」「チェックボックス」「ラジオボタン」「コンボボックス (プルダウンメニュー/ドロップダウンリスト)」などのグラフィカルな要素を通して人間とコンピュータが対話します。GUIでは、キーボードに加えて「マウス」や「タッチパネル」などの ポインティングデバイス がユーザー入力に使用されることが特徴となります。

人間にとって、特に「非技術者」や「初心者」にとっては GUI のほうが「直感的」かつ「分かりやすい」インターフェイスとなります。このため、PCやスマートフォンにおける一般向けアプリのほとんどは「GUIアプリ」として設計・実装されています。また、ATM や 無人レジ などの画面も GUI で構成されています。

インタラクションとは

インタラクション (Interaction) とは「Inter (相互に)」と「Action (活動・行為・作用)」をあわせた語で要素間の相互作用 (アクションに対応したリアクション) や交流を意味します。コンピュータの分野においては「ユーザーがコンピュータに命令を与え、コンピュータがそれに応答する」というプロセスがインタラクションに含まれます。例えば、ユーザーがマウスやキーボードを使って入力を与えることで、コンピュータはそれに応じたタスクを実行して結果を画面に出力しますが、これが「インタラクション」となります。

このようにインタラクションは、ユーザーとコンピュータの間で行われる双方向のコミュニケーションを意味し、コンピュータシステムやアプリの使用体験 (=ユーザーエクスペリエンス (UX: User Experience)) において非常に重要な要素となります。

最近では「Googleアシスタント」や「Siri」、「Amazon Alexa」などの VUI: Voice User Interface を採用するアプリも登場しています。

3 PythonにおけるGUIアプリ開発

Pythonで GUIアプリ (=いわゆる「ウィンドウ」を持ったアプリ) を開発するときは、何らかの「GUIライブラリ」を使用することが一般的となります。ライブラリを使わずにゼロからGUIアプリを開発することも技術的には可能ですが、単純なウィンドウにボタンをひとつを表示するだけでも大変な手間がかかるため、「GUIライブラリ」を使わないGUIアプリ開発は一般的とは言えません

GUIアプリを開発するためのPython用の「ライブラリ」あるいは「フレームワーク」としては、第22~24回講義で学んだPygameの他、TkinterPyQtPySideKivywxPythonPySimpleGUIなど様々なものが存在します。各ライブラリは、得意分野機能外観実行速度ライセンス の面で 一長一短 があり、開発しようとするアプリの目的や特性、規模に応じて適切なライブラリを選択することが求められます。

例えば、「Pygame」は2Dゲームの開発に特化したGUIライブラリであり、一般業務に使用するようなビジネス用GUIアプリの開発には適していません。

「ライブラリ」と「フレームワーク」の違い

ライブラリ」とは、特定の機能を提供するプログラムの集合を指します。プログラマは、これらのライブラリの機能を必要に応じて自分のコードに組み込み活用します。例えば、数学計算をサポートするライブラリ ( math.sinmath.degrees など) や、多次元配列の操作を容易にする NumPy のようなライブラリが存在します。ライブラリを利用する際、プログラムの「流れ」や「構造」は、あくまで プログラマ自身が設計 します。

一方、「フレームワーク」とは、アプリケーションの基本的な「流れ」や「構造」、「規則」などをプログラマに提供するものとなっています。プログラマは、フレームワークが定める「枠組み」のなかで、目的に応じたコードを記述します。例えば、Arduino 開発においては setuploop という決められた名前の関数にコードを記述することで開発をしますが、これはフレームワークに基づく開発といえます。

簡単に言えば、ライブラリは「自分のプログラムのなかで使用するツール(便利な道具)」であり、フレームワークは「コードを記述するための枠組み」となります。ライブラリは自分で選んで利用するものであり、フレームワークは自分が従うべきルールや構造を提供してくれるものと考えてください。

3.1 Tkinter

PythonにおけるGUIライブラリとしては Tkinter が良く知られています。

Tkinter は「ティーケイ・インター」あるいは「ティーキンター」と読みます。Tkinter は、Pythonの開発環境に標準で組み込まれているGUIライブラリであり、pip を使って追加インストールすることなく利用することができます。

標準ライブラリであることから、比較的利用者が多く、インターネット上にも解説情報が多く存在します。また、雑誌やネットの 初心者向けGUIアプリ開発の解説記事 では、たいてい Tkinter を取り上げたものになっています。ただ、Tkinter の残念な面としては、他のGUIライブラリと比較して 外観 (デザイン) がしょぼい機能が貧弱ということが挙げられます。

▼▼ Tkinter を使った GUIアプリの外観 ▼▼ (高DPIスケーリングとの相性が悪いため、環境によっては文字が滲む)

img

▼▼ PySide / PyQt を使った GUIアプリの外観 ▼▼ (美しいモダンスタイルのウィンドウ)

img

(プロンプト例)

Python の Tkinter は、OSの高DPIスケーリングとの相性が悪いと聞きました。「高DPIスケーリング」とは何ですか?

3.2 PyQt

PyQt(読み方はパイキュート) は、C++で実装されたクロスプラットフォーム対応のGUIツールキットである「Qt」のPythonバインディングとなります。これにより、高機能かつモダンでスタイリッシュなデザインのGUIアプリを開発することが可能となっています。ここで「Pythonバインディング」とは「Pythonのコードを使用してバックグラウンドでQtの機能を動作させる仕組み」を意味します。

PyQtを用いる場合、シンプルなGUIアプリ (例えば、単一のボタンを持つもの) の開発であっても、前々回の講義で学んだようなクラスの定義 が求められます。これに対して、Tkinterではクラス定義なしでGUIアプリの開発が可能であるため、PyQTは 初心者にとっては学習のハードルが高い と言えます。しかしながら、GUIアプリの開発では コードが複雑化・肥大化しがち であるため、現実的にはオブジェクト指向プログラミング(OOP)の採用が必須となります。従って「OOPが必要となるから」という理由で PyQt を避けるのは適切ではありません。

また、Tkinter と比較しての「PyQt の短所」としては、高機能であるが故に覚えるべき内容が多く、また、ウェブに情報が少ないということが挙げられます。また、PyQt のライセンスが、いわゆる「コピーレフト」にあたる GPL v3 であるということが「使いにくさ」となっています。

GPL v3 : GNU General Public License version 3.0

GPL v3 ライセンスのライブラリ (例えばPyQtが該当) を利用するアプリを開発し、有償・無償に関わらず配布する際には、以下のルールに従う必要があります。

ソースコードの公開 : 開発したアプリのソースコードは「GPL v3ライセンス」で公開する必要があります。つまり、開発したアプリのソースコードを全公開し、誰でも自由に使ったり改良したりできるようにすること が求められます。

ライセンス関連のドキュメントの同梱 : アプリを配布する際は「GPL v3ライセンスのコピー」と「ライセンスの条項に従っていることを示すドキュメント」を同梱する必要があります。

ライセンスの継承 : アプリにGPLライセンスのコードが含まれる場合、そのアプリ全体もGPLライセンスで提供される必要があります。これは「コピーレフト」と呼ばれる特性となります。

この他にも、様々なルールがあります。詳しくは「GPL v3 解説」などでウェブ検索してください。

3.3 PySide

PySide は PyQt と同様に「Qt」のPythonバインディングとなります。PySide は PyQt とほぼ同じように使用できるように設計されており、比較的単純なプログラムであれば、ライブラリの import 文を PyQt6 から PySide6 に変更するだけで、そのまま動作させることができます。一方で、PyQt との大きな違いは、PySide のライセンスが LGPL (Lesser GPL) となっている点にあります。

LGPLライセンスは、GPLライセンスに比べて、特に「ソースコードの公開」と「ライセンスの継承」に関して柔軟性があります。具体的には、LGPLライセンスのライブラリを使用しても、そのライブラリを改変しない限り、アプリのソースコードを公開する必要 はありません。また、LGPLライセンスのライブラリをリンクしているアプリは、そのアプリ自体に異なるライセンスを適用できます。詳しくは「LGPL 解説」などでウェブ検索してください。

この講義のなかでは PySide を使用していきます。

4 環境構築

PySideを利用したGUIアプリ開発のための環境構築をしていきます。

プロジェクトフォルダ Qt-Playground を作成して、VSCodeでオープンして 仮想環境の構築と有効化 をしてください。

python -m venv .venv
.venv/Scripts/Activate.ps1

仮想環境を有効化したら、念のために pip を最新版にアップグレードしてからPySide6をインストールしてください。

python -m pip install --upgrade pip
pip install PySide6

PySide6 は 「Qt6」に基づいており、「PyQt6」と互換性 があります。PySide および PyQt はどちらも頻繁にバージョンアップされるため、インターネットやChatGPTで問題解決を試みる際には「PySide」ではなく、バージョンを含んだ「PySide6」というキーワードを使って調べるようにしてください。また、多くの場合、「PyQt6」に関する記事はそのまま「PySide6」に適用可能なので、「PySide6」で情報が見つからないときは、「PyQt6」で情報を探してみてください。

5 画面の設計

GUIアプリでは、まずはじめに大まかな「ウィンドウ」の構成(=要素の配置・レイアウト)から設計していきます。ここでは、次に示すような「ボタン」「テキストボックス」「ステータスバー」という要素を持った画面を作成してきます。

なお、「ボタン」「テキストボックス」「ステータスバー」のような要素を総称して 「ウィジェット」あるいは「UIコンポーネント」、「コントロール」、「フォーム要素」、「UI要素」、「パーツ」のように言います。Qt (PySide/PyQt) では、主に「ウェジェット (Widget)」という呼び名が使用されます。

img

上記のような画面を持ったアプリは、次のプログラム (qt-01.py) で作成することができます。

先ほど構築したプロジェクトフォルダに qt-01.py というファイルを新規作成して、以下のプログラムを貼り付け、実行して動作を確認してください。ただし、現在の段階では、ボタンを押下してもアプリからの反応はありません (テキストボックスに文字を書き込むことなどは可能です、試してみてください)。

import sys
import PySide6.QtWidgets as Qw

# PySide6.QtWidgets.MainWindow を継承した MainWindow クラスの定義
class MainWindow(Qw.QMainWindow):
  
  # コンストラクタ(初期化)
  def __init__(self):

    # 親クラスのコンストラクタの呼び出し
    super().__init__() 

    # ウィンドウタイトル設定
    self.setWindowTitle('MainWindow') 

    # ウィンドウのサイズ(640x240)と位置(X=100,Y=50)の設定
    self.setGeometry(100, 50, 640, 240) 

    #「実行」ボタンの生成と設定
    self.btn_run = Qw.QPushButton('実行',self)
    self.btn_run.setGeometry(10,10,100,20)

    #「クリア」ボタンの生成と設定
    self.btn_clear = Qw.QPushButton('クリア',self)
    self.btn_clear.setGeometry(120,10,100,20)

    # テキストボックス
    self.tb_log = Qw.QTextEdit('',self)
    self.tb_log.setGeometry(10,40,620,170)
    self.tb_log.setPlaceholderText('(ここに実行ログを表示します)')

    # ステータスバー
    self.sb_status = Qw.QStatusBar()
    self.setStatusBar(self.sb_status)
    self.sb_status.setSizeGripEnabled(False)
    self.sb_status.showMessage('プログラムを起動しました。')

# 本体
if __name__ == '__main__':
  app = Qw.QApplication(sys.argv)
  main_window = MainWindow()
  main_window.show()
  sys.exit(app.exec())

第02行目では PySide6.QtWidgets をインポートとして、これを Qw という省略名 (別名エイリアス) で使用できるようにしています。もし、as Qw を付けずにインポートした場合、例えば、第40行目app = Qw.QApplication(sys.argv) は、app = PySide6.QtWidgets.QApplication(sys.argv) のように書き換える必要があります。その他、プログラム内の Qw. となっている部分を、すべて PySide6.QtWidgets. に置換する必要があります。

なお、講義資料としての可読性を優先して、ここでは Qw という省略名を使いますが、これは NumPy の np や、Pandas の pd のように一般的な省略命ではないので注意してください。特にウェブ検索したり、調べた情報を自分のプログラムに適用する際には、適切に読み替え をするようにしてください。

5.1 メイン処理

第39行目から第43行目がプログラムの本体となります。第39行目if __name__ == '__main__': については、第25回講義で解説しました。

# 本体
if __name__ == '__main__':
  app = Qw.QApplication(sys.argv)
  main_window = MainWindow()
  main_window.show()
  sys.exit(app.exec())

第40行目では「QApplicationオブジェクト」を作成して変数 app に格納しています。このオブジェクトは、すべてのQtアプリケーションに不可欠な基本オブジェクトであり、アプリケーション全体の「設定」や「管理」を担います。例えば、app.setDoubleClickIntervalapp.app.setApplicationVersion のような設定系のメソッドを持っています。また、引数に与えている sys.argv は「コマンドライン引数」を格納した特殊な変数であり、これを通じてコマンドラインから QApplication に対して各種設定や実行時オプションを与えることができるようになっています。

コマンドライン引数

コマンドライン引数とは、プログラムを実行する際に「コマンドラインを通じて与えるオプション」のことです。Qt では、いくつかの引数が予め定義されており、アプリケーションの動作や外観を設定するために使用すること ができます。

例えば、qt-01.py は、通常、コマンドラインから次のように実行することができます。

python qt-01.py

ここで、--style=fusion--style=windows のようにオプション (コマンドライン引数) を与えることで、アプリの外観を制御することができます (qt-01.py第40行目 で、この仕組みを実現しています)。

python qt-01.py --style=fusion

img

img

演習: コマンドライン引数 --style=fusion および --style=windows を指定して qt-01.py を実行せよ。

img

第41行目では、第05行目から第36行目にかけて定義された「MainWindowクラス」の オブジェクト (=インスタンス実体) を生成しています。これにより、第08行目 から定義されている コンストラクタ (__init__関数) が呼び出され、ウィンドウの初期化が行われます (ただし、この時点ではまだ画面にウィンドウは表示されません)。また、生成した MainWindowオブジェクト の参照を変数 main_window に格納し、以降、この変数 main_window を通じてウィンドウの操作ができるようにしています。

第42行目では、デスクトップ画面に実際にウィンドウを表示するための showメソッドを実行しています。showメソッドを呼び出すまでは、ウィンドウは内部的に作成されているものの、画面上には表示されません。ここで注目すべきは、MainWindowクラスの定義 (=第05行目第36行目) のなかに「showメソッド」が明示的に記述されていないにも関わらず、「showメソッド」が機能していること です。これは、第05行目のクラス定義の開始行で「class MainWindow:」ではなく「 class MainWindow(Qw.QMainWindow):」のように 継承 という仕組みを用いて MainWindow を定義しているためです。

オブジェクト指向プログラミングにおける「継承」とは

継承」とは、あるクラス(この場合は Qw.QMainWindow)が持つ特性、つまり「メソッド」や「プロパティ」を、別のクラス(この場合は自作クラス MainWindow)に 引き継ぐ ための仕組みです。

この「継承」という仕組みにより、Qw.QMainWindow に定義されている show メソッドや、その他の機能を MainWindow でも利用することが可能になります。つまり、MainWindow では QMainWindow の機能を拡張し、さらに独自の機能を追加することが可能になります。

このような関係にあるとき・・・

第43行目では、app.exec() により、GUIアプリとしてのイベントループ (メインループ) を開始しています。これにより、イベント処理 (=ユーザからのマウス操作やキーボード操作、OSからのイベントの受信など) が開始されます。また、ウィンドウのタイトルバーにある「 ボタン」が押下されるなどしてGUIの終了処理がされると app.exec() は終了コード (数値) を返し、それが sys.exit() に渡されます。sys.exit() は、この終了コードを OS に伝えます。

「終了コード」とは

終了コード(または終了ステータス)は、アプリが OS に対して返す数値で、プログラムの実行が成功したかどうか (正常終了したか) を OS に伝える役割をします。慣例的に、0 は「成功」を意味し、0 以外の数値は何らかの「異常終了 (エラー発生などによる終了) 」など表します。

例えば、Pythonプログラムであれば、プログラムが問題なく実行されて最後まで到達した場合、通常 sys.exit(0) が呼び出され、終了コード 0 が OS に返されます。一方で、プログラムのなかでエラーが発生した場合や、ユーザーによる強制終了が行われた場合など、異常な状況下では 0 以外の終了コードが返されることが一般的となっています。

演習1: 第42行目 (=showメソッドによるウィンドウの表示) をコメントアウトとするとウィンドウが表示されないことを確認せよ。確認後はVSCodeからプログラムを強制停止すること。また、コメントアウトを元に戻しておくこと。

演習2: 第43行目 (=イベントループの開始) をコメントアウトとするとウィンドウが一瞬だけ表示され、そのままプログラムが終了してしまうことを確認せよ。確認後はコメントアウトを元に戻しておくこと。

5.2 クラス定義

PySide6.QtWidgets.QMainWindow を継承する MainWindow クラスの定義について確認していきます。現段階において、このクラスには コンストラクタのみ が定義されています。

# PySide6.QtWidgets.QMainWindow を継承した MainWindow クラスの定義
class MainWindow(Qw.QMainWindow):
  
  # コンストラクタ(初期化)
  def __init__(self):

    # 親クラスのコンストラクタの呼び出し
    super().__init__() 

    # ウィンドウタイトル設定
    self.setWindowTitle('MainWindow') 

    # ウィンドウのサイズ(640x240)と位置(X=100,Y=50)の設定
    self.setGeometry(100, 50, 640, 240) 

    #「実行」ボタンの生成と設定
    self.btn_run = Qw.QPushButton('実行',self)
    self.btn_run.setGeometry(10,10,100,20)

    #「クリア」ボタンの生成と設定
    self.btn_clear = Qw.QPushButton('クリア',self)
    self.btn_clear.setGeometry(120,10,100,20)

    # テキストボックス
    self.tb_log = Qw.QTextEdit('',self)
    self.tb_log.setGeometry(10,40,620,170)
    self.tb_log.setPlaceholderText('(ここに実行ログを表示します)')

    # ステータスバー
    self.sb_status = Qw.QStatusBar()
    self.setStatusBar(self.sb_status)
    self.sb_status.setSizeGripEnabled(False)
    self.sb_status.showMessage('プログラムを起動しました。')

第05行目class MainWindow(Qw.QMainWindow): により、自作クラス MainWindowQw.QMainWindow継承 (Inheritance) するクラスを定義することを示しています。何も継承せずにゼロからクラスを定義するときは、class MainWindow: のようにクラス定義を書き出します。

第11行目super().__init__() では 親クラス (super class) のコンストラクタ を明示的に呼び出しています。基本的に、何らかのクラスを継承してクラスを定義した場合、そのクラスのコンストラクタのなかで「親クラスのコンストラクタを呼び出す処理」が必要になります。

第14行目 では、ウィンドウの「タイトル」を設定しています。また、第17行目 では、ウィンドウの「サイズ」と「デスクトップ画面上の初期位置 (ウィンドウ右上をあわせる座標)」を設定しています。


演習1: 第11行目をコメントアウトして、親クラスのコンストラクタを呼びださないと「プログラムが正常動作しないこと」を確認せよ。確認後は、コメントアウトを元に戻しておくこと。

演習2: ウィンドウタイトルに「日本語」が使用可能かどうかを確認せよ。

演習3: 第17行目setGeometry メソッドの引数を変更して、ウィンドウの「サイズ」と「位置」を変更せよ。また、位置に「負数」を設定可能かどうか確認せよ。さらに、引数に「実数」が使用可能かどうかを確認せよ。

5.2.1 ボタンの生成と配置

第20行目 では、Qw.QPushButton クラスから、いわゆる「ボタン」のオブジェクトを生成し、変数 (クラス属性) self.btn_run に格納しています。ここでは、後から btn_run を参照する必要があるために self.btn_run としています。もし、コンストラクタ以外で参照する必要がなければ、次のようにすることもできます。

btn_run = Qw.QPushButton('実行',self)
btn_run.setGeometry(10,10,100,20)

なお、QPushButton のコンストラクタの第2引数を省略すると (つまり、Qw.QPushButton('実行') のようにすると)、内部的にはボタンが生成されますが、ウィンドウには配置されません

第21行目 はボタンオブジェクトが持っている setGeometryメソッドで「ウィンドウ内の配置位置 (左上基準)」と「サイズ」を設定しています。この他、ボタンの外観や動作に関する設定やカスタマイズsetXxxxxx メソッドで行ないます。例えば、setEnabled メソッドでは、bool型を引数に与えてボタンの有効化と無効化 (グレーアウトされた状態) を設定することができます。

QPushButton についての詳細は、公式リファレンスを参照してください。

同様に、第24行目第25行目 で「クリア」ボタンの生成と設定をしています。

ウィジェット外観のカスタマイズ

Qt (PySide/PyQt) の各種ウィジェットは setStyleSheet メソッドを使用して CSS (スタイルシート) の要領で「外観をカスタマイズすること」ができます (CSSについては、第15回講義で簡単に解説しました)。

例えば、以下のコードは「実行」ボタンの文字を「太字」「赤色 #DE5065」にカスタマイズしています。

self.btn_run.setStyleSheet('QPushButton {color: #DE5065; font-weight: bold;}')
img

詳しくは「setStyleSheet pyside6」などでウェブ検索してください。


演習1:「クリア」ボタンの setGeometry メソッドのあとに self.btn_clear.setEnabled(False) を追記して、「クリア」ボタンが「グレーアウトすること」を確認せよ。

img

演習2: 次のように「設定」ボタンを追加せよ。ボタンの位置やサイズも見本と同じようにすること。

img

5.2.2 テキストボックスの生成と配置

第28行目 から 第30行目QTextEdit オブジェクト (いわゆる「テキストボックス」) を生成してウィンドウに配置しています。

第30行目setPlaceholderText では、テキストボックスに文字が未入力のときに表示されるプレースホルダ文字列 を設定しています。プレースホルダ文字とは、ウォーターマークとも呼ばれ 「あとから入力される文字の代わりに表示する文字」 で、ユーザーに対して「入力例」や「ヒント」を提示するために使用されます。プログラムを実行し、実際にテキストボックスに文字を入力すると、このプレースホルダ文字は消えます。

プレースホルダ文字ではなく、通常の文字列をセットする場合は、テキストボックスオブジェクト (QTextEditオブジェクト) の setPlainText メソッドを使用します。また、逆にテキストボックスから文字列を取得するためには toPlainTextメソッド を使用します。

なお、1行だけのテキスト (文字列)の入出力を扱う場合は、QTextEdit ではなく QLineEdit を使用します。


演習1: self.tb_log = Qw.QTextEdit('',self) の第1引数を 'あいうえお' に書き替えると、初期値が与えられることを確認せよ。確認後は元に戻しておくこと。

演習2: self.tb_log = Qw.QTextEdit('',self)self.tb_log = Qw.QLineEdit('',self) に書き換えると、テキストボックス内で「改行が不可になること」を確認せよ。確認後は元に戻しておくこと。

演習3: self.tb_log.setReadOnly(True) とするとテキストボックスが「読み取り専用になること」を確認せよ。

5.2.3 ステータスバーの生成と配置

第33行目 から 第36行目 では、ウィンドウ下部に配置する「ステータスバー」を作成しています。ステータスバーについては、ボタンやテキストボックスとは生成方法および配置方法が異なる点に注意してください。

演習1: 第35行目self.sb_status.setSizeGripEnabled(False) の引数を True に変更するとどのようになるか確認せよ。ヒント: ウィンドウの右下

5.3 レスポンシブデザイン

qt-1.py では、ウィジェットのサイズや配置が全て数値リテラルで固定的に設定されていました。この方法で画面レイアウトを作成すると「ウィジェットを追加や削除するとき」や「コンテナ (=ウィジェットの格納領域) であるウィンドウのサイズを変更するとき」にプログラム内で多くの修正が必要となります。

このようなことから、一般的には レスポンシブデザイン (=コンテナのサイズに合わせてレイアウトが自動的に調整される画面設計) が推奨されます。詳細な解説は中級以上の話題となるため、ここでは詳細な解説は省きますが、先の qt-1.py は、次のような レスポンシブデザイン を採用した別のプログラム qt-02.py に書き換えることができます。

import sys
import PySide6.QtWidgets as Qw
import PySide6.QtCore as Qc

# レイアウト設定用変数
sp_exp = Qw.QSizePolicy.Policy.Expanding

class MainWindow(Qw.QMainWindow):
  
  # コンストラクタ(初期化)
  def __init__(self):

    super().__init__() 
    self.setWindowTitle('MainWindow') 
    self.setGeometry(100, 50, 640, 240) 

    # メインレイアウトの設定
    central_widget = Qw.QWidget(self)
    self.setCentralWidget(central_widget)
    main_layout = Qw.QVBoxLayout(central_widget) # 垂直レイアウト

    # ボタン配置の水平レイアウトを作成します。
    button_layout = Qw.QHBoxLayout()
    button_layout.setAlignment(Qc.Qt.AlignmentFlag.AlignLeft) # 左寄せ
    main_layout.addLayout(button_layout) # メインレイアウトにボタンレイアウトを追加

    #「実行」ボタンの生成と設定
    self.btn_run = Qw.QPushButton('実行')
    self.btn_run.setMinimumSize(50,20)
    self.btn_run.setMaximumSize(100,20)
    self.btn_run.setSizePolicy(sp_exp,sp_exp)
    button_layout.addWidget(self.btn_run)

    #「クリア」ボタンの生成と設定
    self.btn_clear = Qw.QPushButton('クリア')
    self.btn_clear.setMinimumSize(50,20)
    self.btn_clear.setMaximumSize(100,20)
    self.btn_clear.setSizePolicy(sp_exp,sp_exp)
    button_layout.addWidget(self.btn_clear)
    
    # テキストボックス
    self.tb_log = Qw.QTextEdit('')
    self.tb_log.setPlaceholderText('(ここに実行ログを表示します)')
    self.tb_log.setMinimumSize(20,100)
    self.tb_log.setSizePolicy(sp_exp,sp_exp)
    main_layout.addWidget(self.tb_log) # メインレイアウトにテキストボックスを追加
 
    # ステータスバー
    self.sb_status = Qw.QStatusBar()
    self.setStatusBar(self.sb_status)
    self.sb_status.setSizeGripEnabled(False)
    self.sb_status.showMessage('プログラムを起動しました。')

# 本体
if __name__ == '__main__':
  app = Qw.QApplication(sys.argv)
  main_window = MainWindow()
  main_window.show()
  sys.exit(app.exec())

レスポンシブデザインを採用したプログラムは、固定レイアウトのものと比較して行数が増え、コードもやや複雑になる傾向があります。しかし、ウィジェットの追加や削除が 柔軟かつ最小限のコード修正 で行なうことができるという大きな利点があります (仕様変更に強い設計になります)。特に、動的にウィジェットの追加・削除を行なうプログラム (=プログラムの起動後に、処理やユーザー設定に応じてウィジェットの追加や削除をするプログラム) では、レスポンシブデザインの利点が顕著に現れます。

このプログラムの詳細について知りたい場合は、ChatGPTなどを利用してください (ChatGPT:質問と回答例)。


演習1 qt-02.py を実行し、マウスでウィンドウサイズを変更してレスポンシブデザインになっていることを確認せよ (一方で 元の qt-01.py はレスポンシブデザインではないことを確認せよ)。

img

6 イベント処理

qt-01.py および qt-02.py は、画面上のボタンを押下しても何も起きないプログラムでした。ここでは「マウスによるボタンの押下」や「キーボード入力」のような「イベント」に対して、何らかの「応答」をするようにプログラムに改良していきます。

6.1 GUIアプリのアーキテクチャ

GUIアプリ開発では、主に「イベントループ型」と「イベント駆動型」の2つのアーキテクチャ (=ソフトウェアの基本的な構造) が存在します。

イベントループ型」のアーキテクチャでは、プログラムのなかに「イベントループ」という無限ループを構成し、このループのなかで定期的に (非常に短い時間間隔で)「ユーザーからのマウスやキーボード入力イベント」や「OSからのイベント」などを監視・検出する構成をとります (このような監視・検出のプロセスを「ポーリング」といいます) 。そして、検出したイベント (=マウスクリック、キー入力、の押下) について、条件分岐を使って振り分け、そのイベントに応じた処理を実行するようにプログラムを記述します。

例えば Pygame を使ったゲーム開発Arduino による組込み開発 などは、基本的にはイベントループ型アーキテクチャに従ってプログラムを設計・記述することになります。具体例として Pygame のプログラムの例を以下に示します。第15行目 から 第34行目 にかけてイベントループ (無限ループ) を構成し、そのなかの 第18行目 において、pg.event.get() によりイベントを監視・検出し、それを条件分岐で振り分け、イベントに応じた処理をするような構成となっています。

なお、イベントループは、ゲーム系の開発では「ゲームループ」、マイコン系の開発では「メインループ」のようによぶことがあります。

import sys
import pygame as pg

def main():

  # 初期化処理
  pg.init()
  pg.display.set_caption('PyGame')
  screen = pg.display.set_mode((640,240)) 
  clock  = pg.time.Clock()
  exit_flag = False
  screen.fill(pg.Color('Black'))

  # イベントループを構成
  while not exit_flag:

    # システムイベントの監視 (ポーリング)
    for event in pg.event.get():

      # タイトルバー[X]の押下イベントに対する処理
      if event.type == pg.QUIT: 
        exit_flag = True

      # スペースキーの押下イベントに対する処理
      if event.type == pg.KEYDOWN:
        if event.key == pg.K_SPACE:
          screen.fill(pg.Color('Black')) 
      
      # マウスの押下イベントに対する処理
      if event.type == pg.MOUSEBUTTONDOWN:
        pg.draw.circle(screen,'Green',event.pos,10)
        
    pg.display.update() # 画面の描画更新
    clock.tick(30)      # 同期

  pg.quit()
  sys.exit()

if __name__ == '__main__':
  main()

一方で「イベント駆動型」アーキテクチャにおいては、プログラマはコードのなかに明示的なイベントループを構成、記述しません。その代わりに各種イベント発生時に実行する処理を記述した自作関数を定義して、それを「ウィジェットに登録」するような構成のプログラムを記述します (このように、事前に登録だけしておいて、あとから適切なタイミングで呼び出してもらうような関数を「コールバック関数」といいます)。

そして、プログラム実行時には、GUIライブラリ・フレームワークによってイベントループが回され、「イベントを検出すると事前登録しておいたコールバック関数が、GUIライブラリ・フレームワークによって自動的に呼び出される」という仕組みでプログラムが機能します。

例えば、Qt (PyQt/PySide) や TKinter などを利用するGUIプログラムは、このイベント駆動型アーキテクチャに従って設計・記述することになります。イベント駆動型アーキテクチャについては、説明を読んでもイメージがつかめないと思うので、次のセクションに示す具体的なプログラムを読みながら理解に努めてください。

6.2 PySideにおけるイベント処理

qt-03.py というファイルを新規作成して、以下のプログラムを貼り付けて実行し、その動作を確認してください。このプログラムは「実行」ボタンと「クリア」ボタンを押下したときの処理を追加したものとなります。「実行」ボタンを押下すると、ウィンドウ下部のステータスバーのメッセージが変化することにも注意してください。

import sys
import random as r
import datetime as dt
import PySide6.QtWidgets as Qw

# カードガチャ関連 ####
card_t = ('SSR','SR','R','N') # タイプ
card_p = (1,10,20,25)         # 排出比
card_i = list(range(len(card_t))) # インデックス
def gacha():
  x = r.choices(card_i, k=1, weights=card_p)
  return x[0]

# 時刻関連 ####
def get_datatime():
  t = dt.datetime.now() # 現在時刻の取得
  return t.strftime('%Y/%m/%d %H:%M:%S')

# MainWindowクラス定義 ####
class MainWindow(Qw.QMainWindow):
  
  def __init__(self):
    super().__init__() 
    self.setWindowTitle('MainWindow') 
    self.setGeometry(100, 50, 640, 240) 

    # ランクごとのカード所持数と総課金額の初期化
    self.card_counts = [0] * len(card_p)
    self.charges = 0

    #「実行」ボタンの生成と設定
    self.btn_run = Qw.QPushButton('実行',self)
    self.btn_run.setGeometry(10,10,100,20)
    self.btn_run.clicked.connect(self.btn_run_clicked) # ■■注目■■

    #「クリア」ボタンの生成と設定
    self.btn_clear = Qw.QPushButton('クリア',self)
    self.btn_clear.setGeometry(120,10,100,20)
    self.btn_clear.clicked.connect(self.btn_clear_clicked) # ■■注目■■

    # テキストボックス
    self.tb_log = Qw.QTextEdit('',self)
    self.tb_log.setGeometry(10,40,620,170)
    self.tb_log.setReadOnly(True)
    self.tb_log.setPlaceholderText('(ここに実行ログを表示します)')

    # ステータスバー
    self.sb_status = Qw.QStatusBar()
    self.setStatusBar(self.sb_status)
    self.sb_status.setSizeGripEnabled(False)
    self.sb_status.showMessage('プログラムを起動しました。')

  #「実行」ボタンの押下処理
  def btn_run_clicked(self):
    idx = gacha()
    self.card_counts[idx]+=1  # カード所持数の更新
    self.charges += 300       # 課金総額の更新

    # テキストボックスの表示を更新
    log = f'({get_datatime()})\n'
    log += f'【{card_t[idx]}】ランクのカードを1枚取得しました !! \n\n'
    log += self.tb_log.toPlainText()
    self.tb_log.setPlainText(log)

    # ステータスバーの表示を更新
    self.update_status()

  #「クリア」ボタンの押下処理
  def btn_clear_clicked(self):
    self.tb_log.setPlainText('')

  # ステータスバーの表示を更新
  def update_status(self):
    msg = ''
    for i in range(len(card_t)):
      msg += f'{card_t[i]} : {self.card_counts[i]}枚   '
    msg += f'課金総額 {self.charges:,} 円'
    self.sb_status.showMessage(msg)

# 本体
if __name__ == '__main__':
  app = Qw.QApplication(sys.argv)
  main_window = MainWindow()
  main_window.show()
  sys.exit(app.exec())

6.3 ボタン押下のイベント処理

このプログラム (qt-03.py) では、第54行目 から 第66行目 にかけて btn_run_clicked という関数 (メソッド) を定義しています。この関数は、「実行」ボタンを押下したときに実行したい処理を定義したもの となります。この関数は、第34行目 において self.btn_run.clicked.connect(self.btn_run_clicked) のようにコールバック関数として登録されます。このように登録することで、プログラムの実行時に「実行」ボタンが押下されると (PySideのフレームワークによって) btn_run_clicked メソッドが自動的に呼び出されるようになります (PySide6によって、この仕組みが提供されます)。

同様に、「クリア」ボタンを押下したときに実行したい処理を記述した関数 (メソッド) を 第69行目 から 第70行目 に定義し、それを 第39行目 で「クリア」ボタンのオブジェクト (btn_clear) に登録しています。

6.4 現在時刻と整形文字列の取得

第14行目 から 第17行目 にかけて定義される get_datatime 関数は、現在時刻を取得して「yyyy/mm/dd hh:mm:ss」形式にフォーマット (整形) した「文字列」を返す関数になります。例えば「yyyy年mm月dd日 hh時mm分ss秒」形式にフォーマットしたい場合は strftime メソッドの引数を '%Y年%m月%d日 %H時%M分%S秒' のように変更します (大文字・小文字に注意してください)。詳しくは「python strftime フォーマット」で検索してください。

なお、時刻を扱うために 第03行目datetime ライブラリをインポートしています。datetime は標準ライブラリであるため、pip によるインストールは不要です。


演習1: テキストボックス (ログ) に表示される時刻の形式を「2024-01-15 09:45」のように変更せよ。

演習2 (Ex): テキストボックス (ログ) に表示される時刻の形式を「2024年01月15日(月) 09時45分20秒」のように変更せよ。ヒント: 「Mon」ではなく「月」を出力するためには、ロケールの設定が必要になります。

(Ex) の演習は、授業時間外に取り組んでください。

6.5 ガチャ抽選のシミュレーション

第07行目 から 第12行目 では、ガチャ抽選シミュレーションに関連した関数や変数を記述しています。

第07行目 から 第09行目 では、ガチャ要素であるカードのタイプ (ランク) の文字列、排出比、インデックスを設定しています。変数 card_i の内容は [0,1,2,3] となります。これらの変数 card_t card_p card_i は、関数内部でもクラス内部でもない位置で宣言 (初期化) されているため「グローバル変数」となります。つまり、プログラムのどこからでも参照できる変数となります。グローバル変数については、第24回講義において既に学習済みです。

第11行目choices は、リストから「重み付きのランダム抽出」を行なう関数で、これは既に第08回講義のなかで学習しました。

自作関数 gacha() を実行すると、整数値で 0 から 3 までの数値が取得でき、この値をインデックス idx として card_t[idx] とすれば、そのカードのランクを表す文字列が取得できるようになっています。抽選で得られたカードについて、そのランクを表す文字列ではなく、インデックスを返しているのは、第28行目 で定義している self.card_counts (ランクごとのカード所持数) を更新しやすくするためです。


演習: 新たにカードランク「UR」を追加し、それが適切に反映されるようにプログラムを書き換えよ。UR: Ultimate Rare。

7 演出効果の追加

先ほどのプログラムに簡単な「演出効果 (ガチャ抽選のエフェクト) 」を追加します。qt-04.py というファイルを新規作成して演出効果を追加したプログラムからコードをコピーして、貼り付けて実行してください。

プログラムコードのコピー方法

リンクから GitHub にアクセスして、アイコンをクリックすれば、コードをクリップボードにコピーすることができます。

img

先のプログラム qt-03.py からの主な変更箇所は、以下に抜粋するように 第05行目第06行目 のライブラリのインポート、第61行目 から 第73行目 の「プログレスバーダイアログ」の表示処理になります。

import PySide6.QtCore as Qc
import PySide6.QtTest as Qt
  #「実行」ボタンの押下処理
  def btn_run_clicked(self):
    idx = gacha()
    self.card_counts[idx]+=1  # カード所持数の更新
    self.charges += 300       # 課金総額の更新

    # プログレスバーダイアログ (演出効果) の表示
    gacha_msg = ['  ++++++  ガチャ抽選中  ++++++  ',
                 '  ------  ガチャ抽選中  ------  ' ]
    p_bar = Qw.QProgressDialog(gacha_msg[0],None,0,100,self)
    p_bar.setWindowModality(Qc.Qt.WindowModality.WindowModal)
    p_bar.setWindowTitle('ガチャ抽選')
    p_bar.setCancelButton(None)
    p_bar.show()
    for p in range(101):
      p_bar.setValue(p)
      if p % 10 == 0:
        p_bar.setLabelText(gacha_msg[p//10%2])
      Qt.QTest.qWait(20)
    p_bar.close()

    # テキストボックスの表示を更新
    log = f'({get_datatime()})\n'
    log += f'【{card_t[idx]}】ランクのカードを1枚取得しました !! \n\n'
    log += self.tb_log.toPlainText()
    self.tb_log.setPlainText(log)

    # ステータスバーの表示を更新
    self.update_status()

演習: 演出効果のプログラムについて読解せよ。また、パラメータを調整したり、プログラムを書き替えて演出効果を改良せよ (例えば、カードランクに応じて演出効果を調整するなど)。プログラムの読解に際しては、ChatGPT などを適切に活用すること

(プロンプト例)

次のプログラムの「# プログレスバーダイアログ (演出効果) の表示」から「p_bar.close()」について、初心者向けに詳細に解説してください。

(ここに qt-04.py の全体コードを貼付ける)

8 自動セーブとロード機能の追加

先ほどのプログラム (qt-04.py) に「課金総額とカード取得枚数のデータを 自動セーブ&ロード する機能」を追加実装します。具体的には、第18回講義で学んだ pickle ライブラリを利用して、プログラムの起動時にデータファイルを自動ロードし、また、プログラム終了時に自動セーブするような機能を実装します (つまり、課金総額とカード取得枚数のデータを「永続化」します)。

qt-05.py というファイルを新規作成して自動セーブとロード機能を実装したプログラムを貼り付け、実行して動作を確認してください。初回起動後、プログラムを終了する際に qt-05.dat というデータファイル (バイナリファイル) が qt-05.py と同じフォルダ内に新規作成されます。

以下、主な変更箇所を抜粋して示します。

8.1 ライブラリのインポート

import os
import pickle

第01行目では、データファイルの有無をチェックするために os ライブラリをインポートしています。ファイル (フォルダ) の有無を確認する os.path.isfile (os.path.isdir) については第22回講義 のなかで学習しました。

また、第02行目では、データをシリアライズおよびデシリアライズするために利用する pickle ライブラリを読み込んでいます。ospickle も標準ライブラリ (Pythonインストール時に自動的に導入されるライブラリ) であるため、pip によるインストールは不要です。

8.2 データのロード

MainWindowクラスのコンストラクタの最後に、データのロード (読込み) 処理を追加しています。ここでは qt-05.dat という名前でデータファイルを扱います。

第58行目 では、データファイルの有無を確認して、データファイルが存在すればロードして、変数 self.card_countsself.charges に上書きし、第63行目 でステータスバーの表示を更新しています。データファイルが存在しない場合は、第32行目第33行目 で初期化した値 (つまり、課金総額もカード取得枚数も「0」) をそのまま使用します。

# データファイルが存在すれば読み込む
self.data_file = './qt-05.dat'
if os.path.isfile(self.data_file):
  with open(self.data_file,'rb') as file:
    data = pickle.load(file)
    self.card_counts = data['card_counts']
    self.charges = data['charges']
    self.update_status()
else :
  self.sb_status.showMessage('プログラムを起動しました。')

8.3 データのセーブ

このプログラムでは、アプリが終了するタイミグでデータを保存するように設計しています。

PySide6.QtWidgets.Qw.QMainWindowクラスでは、ウィンドウが閉じられる直前に、クラス内に定義される closeEvent というメソッドが呼び出されるようになっています。そのため「PySide6.QtWidgets.Qw.QMainWindow」を継承した「MainWindowクラス」においても closeEvent という名前のメソッドを定義しておけば、ウィンドウが閉じられる直前に、そのメソッドが呼び出されるようになります (この仕組みを オーバーライド といい、オブジェクト指向プログラミングの重要な概念のひとつとなっています)。

なお、closeEvent メソッドは、self に加えて event という引数を持ちます。closeEvent メソッドのなかで event.ignore() として 関数を抜けると終了処理がキャンセル されます。この機能は「本当に終了してよろしいでしょうか?」のような Yes と No のダイアログと組み合わせて使用します。

# 終了処理
def closeEvent(self, event):
  with open(self.data_file,'wb') as file:
    data = {}
    data['card_counts'] = self.card_counts
    data['charges'] = self.charges
    pickle.dump(data, file)
    print('データファイルを更新セーブしました。')
  event.accept()

演習1: 第75行目event.accept()event.ignore() に書き替えると、ウィンドウ右上の ボタンを押下しても機能しなくなることを確認せよ。

演習2 (Ex) : closeEvent メソッドはプログラムを外部から強制終了するような場合には呼び出されない。例えば、次の図のようにVSCodeのデバッガから強制停止したような場合は closeEvent メソッドが呼ばれず、データが保存されない。これを回避するように「ガチャをまわした直後」にデータが保存されるようにプログラムを書き替えよ。

img

演習3 (Ex) : QMessageBox を利用して、アプリの終了確認の機能 (終了確認ダイアログ) を実装せよ。

img

次回の講義では、引き続き PySide を扱います。ここまでの内容を十分に理解しておいてください。解決しない疑問などは、個別に質問してください。