マルチウィンドウアプリの設計【コンテキストパターン】

593, 2022-11-29

目次

マルチウィンドウアプリの設計とは?

ずいぶんTkinter(PythonのGUIライブラリ)で色々なGUIアプリを作った。
私にはかなりノウハウが蓄積されている状態である。

それでそのノウハウをアウトプットすれば誰かの参考になるかもしれない。
なのでここにそのノウハウの一部を書いておきたいと思う。

マルチウィンドウアプリの設計は複雑である。
だが、筆者はあるパターンを発見した。
このパターンを使えばマルチウィンドウアプリもかなり楽に実装してくことができた。

そのパターンを命名するなら「コンテキストパターン」である。

コンテキストパターンとは?

マルチウィンドウアプリではいろいろと厄介ごとが多い。
たとえばウィンドウを実装するわけだが、そのウィンドウからいろいろな関数を呼びたい場合がある。
他のウィジェットに実装されているメソッドを呼び出したりできれば実装も捗るのである。
だがその実現はあまり容易なことではない。

ウィンドウは単一で分離されており、関連性と言えば親ウィジェットへのリンクぐらいである。
たしかにリンクを辿っていけばやがて目的のメソッドには辿り着くだろう。
だが、そんなリンクを辿っていく処理をいちいち書いていては地球が消滅してしまう。

できるだけ簡単に他のメソッドを呼び出せてかつ文脈によって振る舞いを変えてくれる、というのが望ましい。
これを実現するのが今回紹介するコンテキストパターンである。

コンテキストパターンの詳細は?

サンプルコードがPythonのTkinterで申し訳ないが、コードにすると↓がコンテキストになるクラスである。

from myapp.eventmanager import EventManager


class Context:
    def __init__(
        self,
        prev_context=None,
        event_manager=None,
    ):
        self.prev_context = prev_context
        self.event_manager = event_manager if event_manager else EventManager()

    @property
    def root(self):
        cur = self
        while True:
            if cur.prev_context is None:
                break
            cur = cur.prev_context

        return cur

    def dispatch(self, name, *args, **kwargs):
        return self.event_manager.dispatch(name, *args, **kwargs)

    def add_listener(self, name, listener):
        return self.event_manager.add_listener(name, listener)

    def remove_listener(self, name, listener):
        return self.event_manager.remove_listener(name, listener)

ずいぶんコンパクトなクラスだ。
こんなのが本当に役に立つのだろうか?
ちなみにインポートしているEventManagerクラスは↓のような実装になっている。

class EventManager:
    def __init__(self):
        self.listeners = {}

    def dispatch(self, name, *args, **kwargs):
        if name in self.listeners.keys():
            result = None
            for func in self.listeners[name]:
                result = func(*args, **kwargs)
            return result
        else:
            print(f'EventManager: not found name "{name}"')

    def add_listener(self, name, listener):
        if name in self.listeners.keys():
            for func in self.listeners[name]:
                if func is listener:
                    return  # 同じ関数は2つ以上登録できない。
            self.listeners[name].append(listener)
        else:
            self.listeners[name] = [listener]

    def remove_listener(self, name, listener):
        if name not in self.listeners.keys():
            return

        self.listeners[name].remove(listener)

このコンテキストパターンは既存のデザインパターンを改良している。
既存のデザインパターンはPub/Subパターンである。

Contextのメソッドadd_listener()でメソッドをコンテキストに登録する
そして登録したメソッドはdispatch()で呼び出すことができる。

つまりこのContextは関数を登録してそれを任意の名前で呼び出せるというだけのクラスである。
既存のPub/Subパターンと違うのは属性のprev_contextの存在である。
このprev_contextはリンクリストにするための前のコンテキストへのポインタで、これがあることですでにあるコンテキストを片方向リストとして繋げていくことができる。

これはコードで見る方が早いだろう。

root_context = Context()
next_context = Context(prev_context=root_context)

こういう感じでリンクしてくことができる。
末尾のコンテキストからはルートに向かってコンテキストを辿っていくことができる。
ただルートからは末尾のコンテキストは辿れない。
辿るようにすることもできるがそうすると双方向リストにする必要がある。

コンテキストを双方向リストにするかというのは議論があるが、私は必要ないと思う。

それでこのコンテキストはどのように使うのか?

ウィンドウにコンテキストを持たせる

作成したいウィンドウにこのコンテキストを持たせるようにする。
たとえばルートのウィンドウを作る場合はルートのコンテキストを持たせる。

context = Context()
root_window = RootWindow(context)
root_window.mainloop()

そうしてウィンドウの属性のコンテキストを入れておく。

class RootWindow(tk.Tk):
    def __init__(self, context):
        super().__init__()
        self.context = context

あとはコンテキストに他のウィンドウから呼び出したいメソッドを名前付きで登録するだけである。

    self.context.add_listener('get_label_text', self.get_label_text)

さて、この登録した関数はどのように呼び出せるのか?
ではウィンドウをもう1つ作ってみよう。

class SubWindow(tk.Toplevel):
    def __init__(self, context):
        super().__init__()
        self.context = Context(prev_context=context)


sub_window = SubWindow(root_context)
sub_window.mainloop()

別ウィンドウではコンテキストは新しく作って親のコンテキストとリンクさせる。
このような工夫をすることで親ウィンドウと子ウィンドウでコンテキストを分離できる。
これはつまり親ウィンドウでは親ウィンドウで使うメソッドを、子ウィンドウでは子ウィンドウで使うメソッドを登録できるということだ。

しかし待てよ?
子ウィンドウから親ウィンドウのメソッドを呼び出したい場合はどうしたらいい?
そういう時は↓のようにして呼び出す。

    label_text = self.context.root.dispatch('get_label_text')
    print(label_text)

Contextrootプロパティは内部でリンクを辿ってルートコンテキストを見つけてそれを返す。
リンクを辿るのは簡単だ。片方向リストだ。
こうすることで末端のウィンドウからルートのウィンドウで登録されているメソッドを呼び出すことができるようになった。
子ウィンドウから親ウィンドウを終了させたい時は、

    self.context.root.dispatch('exit_app')

などを呼び出せるようにしておけば一発でアプリを終了できる。

そしてルートウィンドウではなく特定の親ウィンドウを探したい場合はこれを改造すればいい。
コンテキストに文字列の名前を持たせてそれを方方向リストの検索で見つけてウィンドウを返せばいい。

    self.context.find_context('my_window').dispatch('show_items')

うーん、これで我々はマルチウィンドウで自由を手に入れた。
素晴らしい!

コンテキストパターンの難点

だがこのパターンにももちろん難点はある。
それは片方向リストだ。

子から親へリンクを辿っていくことはできる。
だが親から子へ辿ることはできない。
なので親から子ウィンドウのメソッドを呼び出したい場合は

    self.my_window.show_items()

このようにドットでチェインを繋げ行くしかない。
この問題はコンテキストを双方向リストにすれば解決できる。
だが双方向リストにすると子ウィンドウが消えたときにリスト構造も修正しなくてはならない。
大した手間ではないと思うが、私はあまり必要性を感じないので実装はしたことがない。
ドットでチェインを繋げればそれで十分だからだ。

おわりに

今回はマルチウィンドウの設計パターンとしてコンテキストパターンを取り上げた。
このパターンは便利なので使ってみてほしい。
筆者はこのパターンでGUIアプリを何本か書いている。
なにか参考になれば幸いです。



この記事のアンケートを送信する