0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

個人開発のPython/tkinter製デスクトップアプリ、起動の「体感速度」を7つの視点で見直した話(v0.7.3)

0
Posted at

はじめに

個人でPython + tkinterのWindowsデスクトップアプリ「File Search Explorer」(ファイル検索アプリ)を開発しています。GitHub Releasesで無料配布しており、PyInstaller + Inno Setupでexe化して届けています。

今回のv0.7.3では、新機能を追加する代わりに、起動時の体感速度にフォーカスしてコードを見直しました。「なんとなく重そう」で放置していた箇所を洗い出し、1つずつ潰していった記録です。

見直した7つのポイント

1. 設定ファイルは「今すぐ必要な分」だけ先に読む

これまでは起動直後にsettings.jsonの中身を全部読み込み、それを使ってウィンドウの各種状態(テーマ、ウィンドウサイズ、通知設定、検索履歴、詳細検索の初期値…)を一括で構築していました。

しかし、画面が最初に見える瞬間に本当に必要なのは「テーマ」と「ウィンドウサイズ」くらいです。それ以外の設定は、画面がある程度組み上がってから使っても体感には影響しません。

そこで、起動直後に必要な項目だけを取り出す軽量な読み込み関数を用意し、それ以外の設定は1tick遅らせて読み込むように分割しました。

def load_settings_minimal():
    """起動直後の画面表示に必要な設定(テーマ・ウィンドウサイズ・
    UIフォント名のキャッシュ)だけを取り出す。"""
    with open(SETTINGS_PATH, "r", encoding="utf-8") as f:
        data = json.load(f)
    return {
        'theme': data.get('theme', DEFAULT_SETTINGS['theme']),
        'window_geometry': data.get('window_geometry', DEFAULT_SETTINGS['window_geometry']),
        'ui_font_family_cache': data.get('ui_font_family_cache', ''),
    }

class FileSearchExplorerApp(tk.Tk):
    def __init__(self):
        super().__init__()
        minimal = load_settings_minimal()
        self.geometry(minimal.get("window_geometry", "1000x650"))
        self.theme_var = tk.StringVar(value=minimal.get("theme", "light"))
        self.apply_theme(rebuild_menu=False)
        self.after(1, self._finish_startup)  # 残りはここで

    def _finish_startup(self):
        self.settings = load_settings()  # フル読み込みはここ
        self._build_menu()
        self._build_toolbar()
        ...

after(1, ...)で1tick遅らせるだけですが、これによって「ウィンドウの下地(テーマ・サイズ)を確定させる処理」と「メニュー・ツールバー・タブなど重い構築処理」が別のイベントループのターンに分かれ、後段の処理でUIスレッドが専有される時間を切り離せます。

2. UIフォントの自動判定結果をキャッシュする

Windows 11寄りの見た目にするため、「游ゴシック UI」→「Meiryo UI」の優先順でインストール済みフォントを判定しています。この判定にはtkinter.font.families()を使いますが、これは環境によって時間がかかることがあるOS API呼び出しです。

これまでは起動のたびに毎回この列挙処理を行っていましたが、一度判定した結果は変わらないので、設定ファイルに保存しておき、2回目以降はフォント列挙自体をスキップするようにしました。

def get_ui_font_family(cached_value=None):
    global _UI_FONT_FAMILY
    if _UI_FONT_FAMILY is not None:
        return _UI_FONT_FAMILY
    if cached_value:
        _UI_FONT_FAMILY = cached_value
        return _UI_FONT_FAMILY
    import tkinter.font as tkfont
    available = set(tkfont.families())
    for candidate in ("Yu Gothic UI", "Meiryo UI", "Yu Gothic", "Meiryo"):
        if candidate in available:
            _UI_FONT_FAMILY = candidate
            break
    else:
        _UI_FONT_FAMILY = ""
    return _UI_FONT_FAMILY

プロセス内キャッシュ(モジュールグローバル)はもともとあったのですが、それだと「同じ起動の中では2回目以降速い」だけで、次に起動したときにはまた列挙し直しでした。設定ファイル側にも持たせることで、次回起動時点から効果が出るようにしています。

3. IndexManagerがタブごとに増えていないか確認する

タブを増やすたびにSQLiteのインデックス管理オブジェクト(IndexManager)を作り直していないか、コードを読み直しました。結果、もともとアプリ全体で1つのIndexManagerapp.index_managerとして持ち、各タブはself.app.index_managerを参照する設計になっていることを確認できました。タブを増やしてもDB接続やスキーマ確認が重複しない設計はキープしつつ、念のためコードコメントを追加して今後の変更でも壊れにくいようにしています。

4. list_roots()の結果をキャッシュする

登録済みインデックスフォルダの一覧を取得するlist_roots()は、キーワード欄の候補取得やインデックス管理画面の表示などで何度も呼び出されます。これまでは呼ばれるたびにSQLiteへ問い合わせていましたが、内容が変わるのは「インデックス作成が完了したとき」「フォルダを削除したとき」くらいなので、結果をメモリにキャッシュし、該当のタイミングだけキャッシュを破棄する方式に変更しました。

def list_roots(self):
    with self._roots_cache_lock:
        if self._roots_cache is not None:
            return self._roots_cache
    conn = self._connect()
    rows = conn.execute(
        "SELECT root, file_count, indexed_at, COALESCE(total_size, 0) "
        "FROM index_meta ORDER BY root"
    ).fetchall()
    conn.close()
    with self._roots_cache_lock:
        self._roots_cache = rows
    return rows

def remove_root(self, root):
    ...
    self.dirty = True
    self._invalidate_roots_cache()

5. VACUUMは「手動実行」か「終了時」だけに限定する

SQLiteのVACUUMはDBファイルを再構築する重い処理です。これまで、インデックス管理画面で「存在しないフォルダをまとめて削除」した直後などに、自動でVACUUMを実行する作りになっていました。ユーザー操作の延長で暗黙にVACUUMが走るのは、体感速度の観点でも挙動の分かりやすさの観点でも良くないと判断し、以下のように整理しました。

  • 「DBを最適化(VACUUM)」ボタンを押したときの手動実行
  • アプリ終了時、かつ今回のセッションでインデックスに変更があった場合のみ1回だけ実行

終了時のVACUUMは、既存の「終了時の暗号化処理」の直前に挟み込む形にしています。

if (self.index_manager is not None and getattr(self.index_manager, 'dirty', False)
        and not getattr(self, '_exit_vacuum_done', False)):
    self._exit_vacuum_done = True
    manager_for_vacuum = self.index_manager

    def do_exit_vacuum():
        manager_for_vacuum.vacuum()
        self.after(0, self._finish_shutdown)

    threading.Thread(target=do_exit_vacuum, daemon=True).start()
    return

6. 検索タブのUI構築を「見える順」に段階分けする

検索タブ1つの中には、検索条件欄・検索結果一覧(Treeview)・プレビュー欄・詳細検索パネル(拡張子/お気に入り/複数フォルダ検索などをまとめたサイドパネル)と、それなりの数のウィジェットがあります。これまではタブを開いた瞬間にすべてを一気に構築していました。

v0.7.3では、優先度の高いものから順にafter(1, ...)でチェーンさせ、段階的に構築するようにしました。

def __init__(self, master, app):
    ...
    self._build_ui_stage1()          # 検索条件欄など基本部分
    self.app.after(1, self._build_ui_stage2)  # 検索結果一覧(Treeview)

def _build_ui_stage2(self):
    ...  # Treeviewの構築
    self.app.after(1, self._build_ui_stage3)  # プレビュー欄

def _build_ui_stage3(self):
    ...  # プレビュー欄(枠だけ)の構築
    self.app.after(1, self._build_ui_stage4)  # 詳細検索パネル

def _build_ui_stage4(self):
    self._build_advanced_panel(self._cond_frame)  # サイドパネル・右クリックメニュー・DnD

tkinterのウィジェットはgrid()で行番号を指定して配置しているため、構築の順番を入れ替えてもレイアウト自体は崩れません。「詳細検索パネル」はデフォルトで折りたたまれて非表示になっているパネルなので、後回しにしても操作上の違和感はありません。

7. 大量アイコンの事前生成はそもそも存在しなかった

「起動時に大量のアイコンを作っていないか」も確認しましたが、このアプリでは検索結果の行に付ける📁/📄はただの絵文字文字列で、ビットマップアイコンを事前生成するような処理はありませんでした。該当なしということで、今回は特に変更していません。「疑わしきは確認する」のも、パフォーマンスチューニングの大事な工程だと思います。

検証方法

GUIアプリなので手元での動作確認に加え、Xvfb(仮想ディスプレイ)上でも起動・タブ操作・検索・ダイアログ表示・終了処理までを一通り自動テストし、段階分けしたUI構築やキャッシュまわりで例外が出ないことを確認してから配布しています。

まとめ

今回は新機能ゼロ、内部のリファクタリングのみのバージョンでした。派手さはありませんが、

  • 本当に必要なものだけを先に読み込む
  • 重い処理はキャッシュする
  • 重い処理は自動で走らせず、手動 or 決まったタイミングだけに限定する
  • ウィジェットは優先度順に段階的に組み立てる

という、割とどんなGUIアプリにも応用できる考え方の詰め合わせになったかなと思います。個人開発でGUIアプリを作っている方の参考になれば幸いです。


配布・ソースの詳細はGitHub Releasesをご参照ください(ソースコード非公開、実行ファイルのみ無料配布)。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?