Python で GUI アプリを作る

ちょっと思う所があったので（もちろんお仕事関係）、頑張って作ってみたよ。

また、M1 Mac ではどうなるか分からない。俺たちの楽園は失われてしまった。OS 非搭載のマシン買って Ubuntu 載せようぜ・・・

# 例題

[Wordle](https://www.nytimes.com/games/wordle/index.html) というゲームが人気だそうだ。どれぐらい人気かと言うと、Google で "Wordle" と検索すると、Google のアイコンが Wordle するぐらい人気だ。

やってみたら面白かったので、自分で作ってみた。

https://github.com/tetutaro/wordle

しかしこれは今回の本題ではない（ただの自己顕示欲）。

このようにゲームっぽいものは、当然 GUI が必要である。そして私は普段 Python ばかり書いているので、あまり Python 以外の言語を使いたくない。しかし究極の目標として、どのプラットフォーム（Mac OS, Linux, Windows）でも動作する、ワンクリックだけで起動するアプリにしたい。

今回の例はゲームだけど、普通にビジネスでも「アプリを作ってくれ」という事はあり得る。こういう時にどうすれば良いのか。それを考えて試したものを、まとめたのがこの記事である。

# 1. Webブラウザ + Backend

通常の Web アプリ開発で backend に Python を使う場合は、以下の形が多いと思う。

* Fontend : nginx などの web サーバ上で動くものを JavaScript や golang で作り、計算処理が必要なものは Backend に投げる
* Frontend - Backend 間通信 : REST API
* Backend : [Flask](https://flask.palletsprojects.com/en/2.0.x/) や [FastAPI](https://fastapi.tiangolo.com/ja/) で REST API の受け口を作り、内部の処理を普通に Python で書いて応答するものを作る

Flask と FastAPI のどちらが良いのかという問題は難しい。ぱっと見はどちらも同じだから。新しいし [Pydantic](https://pydantic-docs.helpmanual.io/) を使っているので、私は FastAPI の方が好み。

今回は「なるべく」Python だけを使いたいので、[Jinja](https://jinja.palletsprojects.com/en/3.0.x/) で HTML テンプレートに値を埋め込んで、HTML を生成するところまでやることにする。

これは何も難しいことはない。ググればやり方はいくらでも出てくる。

# 2. Electron + Backend

まず不満なのは、Web ブラウザなので「アプリ感」が無いことである。そして動かすまでに、Backend サーバを立ち上げて、それからブラウザを立ち上げて、さらに表示する URL を叩くという３ステップが必要なので、めんどくさいというのがある。

まずは「表示する URL を叩く」というステップを減らすために、専用ブラウザを作る。

専用ブラウザと言っても、localhost の特定のポートにアクセスするだけの [Electron](https://www.electronjs.org/) を作るだけである。

[たった 30 行の JavaScript を書く](https://github.com/tetutaro/wordle/blob/main/browse.js)だけなので、これも簡単。

既に「Python だけ」じゃなくなってるけど、Frontend を作る大変さを思ったら不満は無い。

# 3. Electron から Backend を立ち上げる

さらに「Backend サーバを立ち上げる」というステップを減らすために、Electron の中から Backend サーバを立ち上げる。

具体的には、`child_process` パッケージを用いて fork して、Python を動かす場合には `spawn()`、バイナリを起動する場合には `execFile()` すれば良い。[サンプル](https://github.com/nekobean/electron-flask-template)もあるし。

ここまででもうかなりアプリ感はある。そしてここまでで満足したならば、全人類が幸せだった。

# 4. Backend をバイナリ化する

究極の目的として「マルチプラットフォーム」があるので、Python で動かしている backend サーバをまるっとアプリという形にしたい。特に Python はみんな普通に仮想環境を作って動かしているだろうから、その仮想環境をまるっと全部バイナリにしたい。

これを実現するのが [PyInstaller](https://pyinstaller.readthedocs.io/en/stable/) である。

ところが PyInstaller には以下の問題がある。（伝聞も含む）

1. 作成されるバイナリのサイズがデカイ
2. Framework としてインストールされた Python じゃなければならない
3. FastAPI が動かない
4. `--onefile` オプションを使うと起動するまで非常に時間がかかる
5. 外部ファイルにアクセスするプログラムだと非常に不安定になる

1 番の問題に関しては、とにかく使う Python Package を限りなく少なくするしかない。今回は numpy ぐらいだったので、27MB 程度で済んでいる。

2 番目の問題は、PyInstaller を使おうとした一番最初にぶつかった壁。とりあえず使っていた仮想環境を一旦消し、`env PYTHON_CONFIGURE_OPTS="--enable-shared" pyenv install 3.10.2` 等として特定のバージョンの Python を Framework とすることにして、仮想環境を作り直すことで対処した。

そして悲しい 3 番目。良く分からないけど、とにかく動かない。「そのポートは既に使われてるよ」とエラーメッセージが出るので、Uvicorn の問題なのかもしれない。解決する力量は残念ながら私には無い。このために FastAPI で書いたものを全部 Flask に書き直した。（大した手間じゃない）

さらに 4 番目。`--onefile` を付けると起動まで 30 秒ぐらいかかる。何故は不明。`--onefile` をつけないとフォルダが作られて色々なライブラリが詰め込まれている状態になっているが、まぁ特に問題はない。この諦めの境地に至るまで随分と時間を浪費した。ちなみに `--onefile` を付けなくても、一回目の起動には何故か時間がかかる。そんなものだと諦める。Linux だとサクっと動くので、Mac OS の問題なのかもしれない。

最後に 5 番目。これが最もハマった。

`--onefile` オプションをつけて全体のひとつのファイルにまとめたり、そうでなくても仮想環境を丸ごと複製しているので、ファイルの場所は当然変わってくる。まぁしかし、実行するメインのバイナリとの相対ファイルパスが同じであれば（同じ様に参照できるならば）問題は無い。はずである。

実際に `--add-data` オプションがあり、ご丁寧にも生成前後のパスを指定しなければならないので、ちゃんと指定すれば良いだけのはずである。しかし、使うファイルのパスには以下のパターンがある。

```
./ （このディレクトリで python -m some_app と起動する）
├── another_module/
│   ├── __init__.py
│   ├── hogehoge.py
│   └── outer_data.txt
└── some_app/
    ├── __init__.py
    ├── __main__.py
    └── data/
        └── inner_data.txt
```

今回でいうと、Wordle で使う単語を収集するスクリプトを Wordle 本体とは別のモジュールとして作成し、そのデータを Wordle 本体に読み込ませていた。この時、上の例で言えば `some_app` を `python -m some_app` というコマンドで起動することにしてしまえば、`some_app` から `outer_data.txt` は `another_module/outer_data.txt` というパスで参照できる。

Wordle は他にも読み込むデータ（ファイル）がある。HTML テンプレートや CSS ファイルである。これらは（Flask の慣例的に）モジュール本体のディレクトリ内部に置かれている。（上の例で言えば `inner_data.txt` に相当）

じゃあこれを PyInstaller でバイナリ化したいので両方とも取り込みたいってなった時に、それぞれ `--add-data` にどうやって指定するかと言うと、試行錯誤の結果、こうなった。

`pyinstaller --add-data some_app/data:data --add-data another_module:another_module some_app/__main__.py`

よく考えればおかしい話で、本体の下のディレクトリは本体のディレクトリを省いて指定している。要するに pyinstaller で固めた先は、このモジュールの中の世界（`some_app` というディレクトリが基本となっている世界）のはずだ。しかし本体の外にあるものは、そのままのパスで指定している。要するに、python を叩いたディレクトリが基本になってしまっている。通常の Python ならば後者が当然なのだが、pyinstaller でバイナリ化した後はどちらが本来あるべき姿なのか。

理由をすっ飛ばして結論から言えば、前者が正しいのだろう。だって `outer_data.txt` に相当するものを Python スクリプトの中に強引に埋め込んで `some_app` の中に入れたら、すごい快適に動くようになった。

Linux でもそうだったので、Mac OS に限定した話ではないと思う。

1. 外部のファイルを読み込むな。Python にデータを埋め込んで、それを読ませろ
2. どうしても外部のファイルを読みたい場合は、同じモジュール内（同じディレクトリ内）に置け

PyInsterller を使う際には守りたいルール。

# 5. 全部をまとめてインストーラーを作る

後はこれらを electron-builder を使ってパッケージを作るだけだ・・・と思ったのだが・・・

まだ罠はあった。electron-builder の圧縮ツールである `asar ` の問題。

`asar` での圧縮を OFF にするとビルドの際に怒られる。しかし圧縮すると、動作が重くなる、プロセス名が分からなくなって終了時に backend を落とせなくなるなどの問題が発生した。恐らくは PyInstaller でバイナリ化してるのに、さらに `asar` で圧縮しているのが良い無いのかなぁと。

下に状況をまとめてみた。

| PyInstaller `--onefile` | electron-builder `asar` | dmg のサイズ | 起動         | backendの終了 |
| ----------------------- | ----------------------- | ------------ | ------------ | ------------- |
| なし                    | なし                    | 103MB        | 初回だけ遅い | 可能          |
| あり                    | なし                    | 103MB        | 遅い         | 可能          |
| なり                    | あり                    | 103MB        | 遅い         | 出来ない      |
| あり                    | あり                    | 103MB        | 遅い         | 出来ない      |

サイズもあんまり変わらないので、PyInstaller `--onefile` も electron-builder の `asar` もやらないことにした。具体的には、`package.json ` の `build` セクションに `"asar": false` を追加するだけである。

# 6. Tkinter を使う

やっぱり Python と Electron を混ぜて作るのは難しい、俺は Python only でやりたいんだ、という場合は、Electron をすっぱりやめて Python 製 GUI を使うしかない。有名かつマルチプラットフォームなのは以下だろうか。

* [Tkinter](https://docs.python.org/ja/3/library/tkinter.html)
* [wxPython](https://www.wxpython.org/)
* [PyGObject](https://pygobject.readthedocs.io/en/latest/)
* [PyQt](https://riverbankcomputing.com/software/pyqt/)（[PySide](https://wiki.qt.io/Qt_for_Python)）
* [Kivy](https://kivy.org/#home)

一番モダンそうなのは Kivy だが、とりあえず一番資料が多くて作れそうな Tkinter でやってみる。

が、ここにも落とし穴があった。Mac OS だと Button の色が変えられない。

[tkmacosx](https://pypi.org/project/tkmacosx/) を使うことで何とか回避した。

ということで、これも作っております。

https://github.com/tetutaro/wordle/tree/tkinter

作った感想：

* Flask, PyInstaller, electrion, electron-builder と複雑に組み合わせ作っていたのが、非常にコンパクトになった感がある
* 実際キビキビと動く
* でも GUI 作るのホントめんどい。HTML + CSS ってなんて偉大な発明だったんだと気付く
* どうしても作りきれない所がある
* でも頑張って作ると、これはこれで良いんじゃないかと思えてくる
* 複雑なことは出来ないので、簡単な GUI で良ければ充分選択肢になる
* Mac OS だと PyInstaller に `--windowed` オプションを付けると、Wordle.app というアプリの形にしてくれる

# まとめ

あ、Windows のことスッカリ忘れてたネ（作為的）