137
144

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

Python(Flet)でリアクティブなUIを作る方法を考える

Last updated at Posted at 2023-05-14

前置き

前回この記事を書いた者です。意外と需要があったようで正直驚きました。

現在は本格的にFletを使った業務アプリの開発に取り組んでいるのですが、今回はそこで得た技術的知見を共有したいと思います。

したがって、前回の記事の10倍位は中身に踏み込んだ話になります。 基本的な資料は他の方がQiita等に記事を上げていますのでそちらをご参考に。

Fletが如何ほどの物なのか知りたい方は是非最後までご覧ください。

経緯

詳細は先ほどの記事に書いておりますが、
事の発端は、業務効率化の一環で「業務の自動実行」ができるアプリを開発しようと考えたところから始まります。

自動化できる部分はPythonで自動化し、出来なかった部分はアプリ上(GUI上)で業務支援ができるようにしたかったのです。

GUI部分をC#(WPF)やJavaScript(Electron)を使用して実装する事も考えましたが、チーム内のスキル・保守性も考えてGUIもPythonで作る事にしました。

そこで登場するのが「Flet」というフレームワークです。

Fletとは?

Fletは、Pythonでクラスプラットフォームなアプリを作る事が出来るフレームワークです。
Flutterをベースにしており、「簡単に」「それっぽい」アプリを作る事を得意としています。

image.png

Fletについて詳しく知りたい方は、こちらの記事や、公式ドキュメントをご参考下さい。

Fletの基本構文

まずFletの基本構文を見ていきましょう。
Fletでは、テキストやボタン等のUI部品の事を「Control」と呼びます。
アプリを構成する際は、アプリにこの「Control」を追加していくような形になります。

ちなみに、物によっては子要素を追加できるControlもあります。FletではそのようなControlクラス(Container,Column,Row等)を使ってレイアウト調整やUIの階層化を行います。

以下は、公式から引用したカウンターアプリのサンプルコードです。

counter.py
import flet as ft

def main(page: ft.Page):
    page.title = "Flet counter example"
    page.vertical_alignment = ft.MainAxisAlignment.CENTER

    # 値を動的にしたい部分のControlインスタンスを作成
    txt_number = ft.TextField(value="0", text_align=ft.TextAlign.RIGHT, width=100)

    # マイナスボタンクリック時の処理
    def minus_click(e):
        #Controlインスタンスのvalueプロパティに代入
        txt_number.value = str(int(txt_number.value) - 1) 
        #ページを更新。(txt_number.update()としても良い。updateは子要素に伝達する)
        page.update() 

    # プラスボタンクリック時の処理
    def plus_click(e):
        txt_number.value = str(int(txt_number.value) + 1)
        page.update()

    page.add(
        ft.Row(
            [
                ft.IconButton(ft.icons.REMOVE, on_click=minus_click),
                txt_number,
                ft.IconButton(ft.icons.ADD, on_click=plus_click),
            ],
            alignment=ft.MainAxisAlignment.CENTER,
        )
    )

ft.app(target=main)

実行するとこのようなアプリが起動します。
flet_counter.gif

FletにおけるUIの描画変更処理

Fletでは、表示を変更する際に以下の様なプロセスを踏みます。

  1. Controlインスタンス(ここではtxt_number)のプロパティに値をセットする
    txt_number.value = str(int(txt_number.value) - 1)
  2. 自身または親要素の.update()メソッドを呼び出して表示を更新する
    page.update()

Fletは、このような命令的なアプローチを取ってUIを更新するような設計になっています。
昨今のフロントエンド界隈は、宣言的UI(データを変えるとUIも自動で変わる)が主流ですが、アプリ開発初心者にとっては何が起きているか分かりづらいため、そういった人の開発ハードルを下げるのが目的のようです。

Fletの「弱点」

ただし、複雑なUIを持つアプリや、複数の画面を持つアプリだと、「複数の場所で1つの状態を参照したい」 といったパターンも良く出てきます。例えばこのような場合を考えてみましょう。
image.png

このように、テキストボックスの値を複数個所で参照する場合、その使用箇所1つ1つに対して、テキストボックスの値が変更された際に変更処理を行う必要があります。
これをFletで実装する場合、特に「コンポーネントや画面が分かれている際」には非常に厄介 です。
例えばこのようなアプリを作ろうとすると以下のようなコードになります。flet_reactive.gif

example.py
import flet as ft

# コンポーネント化するにはflet.UserControlクラスを継承し、buildメソッドでControlインスタンスを返すようにします。
class InfoAria(ft.UserControl):
    def __init__(self):
        super().__init__(self)
        self.text_len_label = ft.Text('文字数:', size=20)
        self.text_val_label = ft.Text('テキストボックスに「」と入力されています。')
        #※画面を跨ぐ際の記述は複雑なので省略します。

    def on_change_handler(self, e):
        value = e.control.value
        self.text_len_label.value = f'文字数:{len(value)}'
        self.text_val_label.value = f'テキストボックスに「{value}」と入力されています。'
        self.text_len_label.update()
        self.text_val_label.update()

    def build(self):
        return ft.Column([self.text_len_label, self.text_val_label])

def main(page: ft.Page):
    page.title = "Flet example"
    info_aria = InfoAria()

    # 値を動的にしたい部分のControlインスタンスを作成
    text_box = ft.TextField(on_change=info_aria.on_change_handler)
    
    page.add(ft.Column([text_box, info_aria]))

ft.app(target=main)

保守性を高めるために、UIの共通部分を纏めてコンポーネント化したい時があると思いますが、コンポーネントを跨ぐデータ渡しをする際は、このように更新用のメソッドを用意して外部に呼び出してもらったり、グローバル変数を使う等、通常とは少し異なるアプローチをしなければなりません。

(2023/5/16追記)記載に不備がありました。公式でも紹介されていますが、自作コンポーネントには任意のプロパティ値を含める事が出来るので、それを用いたデータ渡しが可能です。
例えば、このような2つのカウンターで、値を連動させたい場合を考えてみましょう。
flet_counter_2.gif

プログラム全文
#ボタンを押すとカウントが増え、かつカウントアップした際に任意の処理を実行できるコンポーネント
import flet as ft

class Counter(ft.UserControl):
    def __init__(self):
        super().__init__(self)
        #追加したプロパティ
        self.counter = 0 
        self.on_countup = lambda: None
        self.text = ft.Text(str(self.counter))

    def count_up(self):
        self.counter += 1
        self.text.value = str(self.counter)
        self.update()

    def on_click_handler(self, e):
        self.count_up()
        self.on_countup() 

    def build(self):
        return ft.Row([self.text, ft.ElevatedButton("Add", on_click=self.on_click_handler)])

def main(page: ft.Page):
    page.title = "Flet example"
    counter1 = Counter()
    counter2 = Counter()
    counter1.on_countup = lambda: counter2.count_up()
    counter2.on_countup = lambda: counter1.count_up()
    page.add(counter1, counter2)

ft.app(target=main)
counter1 = Counter()
counter2 = Counter()
counter1.on_countup = lambda: counter2.count_up()
counter2.on_countup = lambda: counter1.count_up()

このように任意に用意したプロパティに値を登録できます。今回の例ではon_countupというコールバック関数登録用のプロパティを用意し、カウントアップ時にもう片方のカウントアップ関数を起動するといったサンプルです。
また、コンポーネントのインスタンスを引数で渡す事でコンポーネント間を連携させることも可能です。
他にも、先程の例の様に更新用メソッドを用意する・グローバル変数を使うといった方法もあります。

小規模であればこれで全く問題ないのですが、問題はこれが中規模/大規模になった時です。
このような処理が増えてくると、コンポーネント間の関係性が複雑になり、
最終的にスパゲティコード化して誰も手が付けられなくなる、というのはアプリ開発ではよくある問題です。

また、最初に紹介した例ではTextFieldのon_changeを使って値の変更を検知していましたが、これが普通の変数に対しての参照だった場合は変更検知処理もしなければならないので、より面倒です。
プロパティに値をセットしてupdate()をかける処理も、アプリの規模が大きくなってくると何回も記述しなければならず段々煩わしくなってきます。

Fletはこのように、アプリが中規模位になってくると、途端に「命令型」であるデメリットが顕著に出てきます。

複雑化にどう対処するか?

今回はFletにおいて、「規模が大きくなると複雑化する」という問題を避けるべく、以下の手法を用いて対策します。

  • データフローの整理
  • データバインディング機構の実装(リアクティブなUI)

今回の記事では、データフローを清流化し、そのデータに対してリアクティブ(データが変わると表示も変わる)なUIを定義する事によって、アプリの複雑化を抑える事を目的とします。

今回採用する手法

複雑化しにくいアプリを作るには、まずデータの流れを限りなくシンプルにすることが必要です。
今回は「単方向データフロー」を参考にする事とします。
ざっくり言うとこんなイメージです。
image.png

単方向データフローは、「UI(View)は状態(State)を反映したもので、UIはActionを通じて状態を更新できる」という前提があり、逆転は許されません。(Actionが直接Viewを弄ったり、ViewがStateを直接変更する事はNGです。)
表示を変更する際は、まず状態を変更する必要があります。

そして状態が変更された後に、UIを自動で更新する必要があるのですが、
これはState側に「変更検知機能」を持たせ、状態の変更を検知をした際に、View(UI)側で定義した描画更新処理を起動させることで実現させます。

ちなみに今回のサンプルコードについては、厳密な単方向データバインディングではなく、それを踏襲した実装にしただけですのでご了承ください。

尚、本人はアプリ開発2年程度の素人ですので何か意見ありましたら是非コメントで教えてください。

1.状態管理クラスを作成する

Fletには状態管理機能が(現時点では)ありませんので、自作します。
Observerパターンと呼ばれるものを参考に作成します。

Observer パターン(オブザーバー・パターン)とは、プログラム内のオブジェクトに関するイベント(事象)を他のオブジェクトへ通知する処理で使われるデザインパターンの一種。 通知するオブジェクト側が、通知されるオブジェクト側に観測・観察(英: observe)される形になることから、こう呼ばれる。(wikipediaより)

state.py
from typing import TypeVar, Generic, Union, Callable

T = TypeVar('T')

#状態管理クラス。bind()で状態変更時に呼び出したい処理を登録できる。
class State(Generic[T]):
    def __init__(self, value: T):
        self._value = value
        self._observers: list[Callable] = []

    def get(self):
        return self._value #値の参照はここから

    def set(self, new_value: T):
        if self._value != new_value:
            self._value = new_value #新しい値をセット
            for observer in self._observers: observer() #変更時に各observerに通知する

    def bind(self, observer):
        self._observers.append(observer)# 変更時に呼び出す為のリストに登録

この状態管理クラスの使用例を示します。

from state import State

text = State('')
text.set('12345')
print(text.get()) # -> 12345

text.bind(lambda v: print(f'変更を検知しました。新しい値: {v}')#変更検知イベントに登録
text.set('aaaaaaa') # -> 変更を検知しました。新しい値: aaaaaaa
text.set('qwerty') # -> 変更を検知しました。新しい値: qwerty
print(text.get()) # -> qwerty

このように、状態が変更されると登録されたイベントが呼び出されるようになります。
次は、このクラスを使って、変更検知イベントにFletの描画変更処理を登録し、状態変更と同時に表示が更新されるような処理を実装します。

2.状態管理クラスと描画処理を紐づける

状態変更時に自動でUIを変更させるには、Stateクラスの.bind()メソッドに描画処理を追加すればOKです。

import flet as ft
+ from state import State
#※今回はグローバルステート化したが、本来はStoreクラスを用意して格納したり引数で渡すのが望ましい
+ text = State('')

class InfoAria(ft.UserControl):
    def __init__(self):
        super().__init__(self)
        self.text_len_label = ft.Text('文字数:', size=20)
        self.text_val_label = ft.Text('テキストボックスに「」と入力されています。')
        #データバインディング。変更時にon_change_handlerメソッドが呼び出される
+       text.bind(self.on_change_handler)

-   def on_change_handler(self, e):
+   def on_change_handler(self):
-       value = e.control.value
+       value = text.get()
        self.text_len_label.value = f'文字数:{len(value)}'
        self.text_val_label.value = f'テキストボックスに「{value}」と入力されています。'
        self.text_len_label.update()
        self.text_val_label.update()

    def build(self):
        return ft.Column([self.text_len_label, self.text_val_label])

def main(page: ft.Page):
    page.title = "Flet example"
    info_aria = InfoAria()
-   text_box = ft.TextField(on_change=info_aria.on_change_handler)
+   text_box = ft.TextField(on_change=lambda e: text.set(e.control.value))
    
    page.add(ft.Column([text_box, info_aria]))

ft.app(target=main)

これによって単方向データバインディングらしきものが実現できました。今回の例だと単にコードが増えただけであまりメリットは感じないかもしれませんが、これが、1つのStateに対して複数画面から参照するようなコードになってくると恩恵を受けられるようになります。

3.コンポーネント化する

ここまでの記述でデータフローの簡素化に成功しましたが、コードの規模が更に大きくなると

「更新処理が多くてコードが見づらい」
「同じような更新処理を何度も記述しないといけない」

といった問題に直面します。

そこで、各コントロールに対し、「状態が変わると、UIも自動で変わる」ような処理を追加したWrapperクラス(コンポーネント)を作成 し、使いまわせるようにします。

reactive_text.py
import flet as ft
from state import State
from typing import Union

StateProperty = Union[T, State[T]]
# StateProperty[str]の場合、strかState[str]の引数のみ使用可能

class ReactiveText(ft.UserControl):
    def __init__(self, text: StateProperty[str], size: StateProperty[int] = 17):
        super().__init__()
        self.control = ft.Text('')
        
        #プロパティ代入処理
        self.text = text
        self.size = size

        self.set_props() #初期値を設定する

        # 引数がStateだったらデータバインディング
        if isinstance(self.text, State): self.text.bind(lambda: self.update())
        if isinstance(self.size, State): self.size.bind(lambda: self.update())

    # プロパティを設定する。Stateの場合は最新の状態をget()メソッドで取得する
    def set_props(self):
        self.control.value = self.text.get() if isinstance(self.text, State) else self.text
        self.control.size  = self.size.get() if isinstance(self.size, State) else self.size

    #状態変更時に呼び出される。最新の状態をプロパティにセットし、表示を更新する。
    def update(self):
        self.set_props()
        self.control.update()

    def build(self):
        return self.control

このように記述する事で、引数にStateを渡すことで自動的にデータバインディングされるクラスが完成 しますので、これを各種コンポーネント内で使いまわすことでコードを簡潔に出来ます。

データバインディング可能なプロパティを追加したい場合は、引数とプロパティを増やし、set_props関数とupdate関数に処理を追加すればOKですし、他のControlにも応用可能です。

4. Stateもリアクティブにする

ただし、先程書いたReactiveTextクラスはこのような使い方をしてもリアクティブにはなりません。

text_state = State('')
ReactiveText(f'文字数:{len(text_state.get())}') #引数はStateではない為、リアクティブにならない

このように、Stateを元に別の値を生成している場合は、元となるStateの変更時に、それを使用して別の値を生成しているStateに対しての変更処理を書かなければなりません。

text_state = State('')
len_text_state = State(f'文字数:')
#親Stateが変更されたら子Stateの値も変える
text_state.bind(lambda : len_text_state.set(f'文字数:{len(text_state.get())}'))
ReactiveText(len_text_state) #stateを渡しているのでリアクティブになる

もしくは、この処理を自動的に行ってくれる新たなStateクラスを定義します。

state.py
# ...Stateクラスの定義...

# 依存しているStateの変更に応じて値が変わるクラス。
class ReactiveState(Generic[T]):
    #formula: State等を用いて最終的にT型の値を返す関数。
    #例えばlambda: f'value:{state_text.get()}'といった関数を渡す。

    #reliance_states: 依存関係にあるStateをlist形式で羅列する。
    def __init__(self, formula: Callable[[], T], reliance_states: list[State]):
        self.__value = State(formula())# 通常のStateクラスとは違い、valueがStateである
        self.__formula = formula
        self._observers: list[Callable] = []

        for state in reliance_states:
            #依存関係にあるStateが変更されたら、再計算処理を実行するようにする
            state.bind(lambda : self.update())

    def get(self):
        return self.__value.get()

    def update(self):
        old_value = self.__value.get()
        #コンストラクタで渡された計算用の関数を再度呼び出し、値を更新する
        self.__value.set(self.__formula())

        if old_value != self.__value.get():
            for observer in self._observers: observer() #変更時に各observerに通知する

    def bind(self, observer):
        self._observers.append(observer)# 変更時に呼び出す為のリストに登録

このクラスはこのように使うことが出来ます。

text_state = State('')
len_text_state = ReactiveState(lambda: f'文字数:{len(text_state.get())}', [text_state])
#第2引数に渡したStateに自動で紐づける

text_state.set('12345') #text_stateを変更するとlen_text_stateも変わる
print(len_text_state.get()) # -> 文字数:5

第1引数には、Stateを取得して、それを元に新しい値を返す関数を、第2引数には依存関係のあるStateをlist形式で指定します。第2引数に渡したStateが変更されると、自動で第1引数で渡した関数が呼び出され、値が更新されるようになっています。

ここまで来るとそれなりに複雑になってきますが、このReactiveStateの様なクラスを使うとコードのさらなる簡略化が可能です。

5.ReactiveStateを使ったコンポーネントを作る

Reactive Stateをコンポーネントで使う前に、まずstate.pyを弄って、コンポーネント内で簡単に記述できるようにします。

state.py
# ...StateクラスとReactiveStaetクラスの定義...

# リアクティブなコンポーネントの引数(ReactiveStateを追加)
StateProperty = Union[T, State[T], ReactiveState[T]]

# コンポーネント内でpropsに、Stateになる可能性のある引数を渡す。
# StateやReactiveStateが渡された場合、自動でbind_funcを変更検知イベントに登録する
def bind_props(props: list[StateProperty], bind_func: Callable[[], None]):
    for prop in props:
        if isinstance(prop, State) or isinstance(prop, ReactiveState):
            prop.bind(lambda : bind_func())

# Stateであれば.get()メソッドを呼び出し、通常の変数であればそのまま値を取得する
def get_prop_value(prop: StateProperty):
    if isinstance(prop, State):
        return prop.get()
    elif isinstance(prop, ReactiveState):
        return prop.get()
    else:
        return prop

これでコンポーネントでReactiveStateを扱う準備が出来たので、
これを元に先程作成したreactive_textコンポーネントを改造します。

reactive_text.py
import flet as ft
from state import StateProperty, bind_props, get_prop_value

class ReactiveText(ft.UserControl):
    def __init__(self, text: StateProperty[str], size: StateProperty[int] = 17):
        super().__init__()
        self.control = ft.Text('')
        self.text = text
        self.size = size

        self.set_props()
        bind_props([self.text, self.size], lambda: self.update())#自動でデータバインディング

    def set_props(self):
        self.control.value = get_prop_value(self.text)#通常の変数かStateかを判断して値を取得
        self.control.size  = get_prop_value(self.size)

    def update(self):
        self.set_props()
        self.control.update()

    def build(self):
        return self.control

そしてそのコンポーネントを使う事で、最終的にはこれだけの記述でリアクティブなアプリを作成可能になります。

main.py
import flet as ft
from reactive_text import ReactiveText
from state import State, ReactiveState

text = State('')
class InfoAria(ft.UserControl):
    def __init__(self):
        super().__init__(self)
        self.text_len = ReactiveState(lambda: f'文字数:{len(text.get())}', [text])
        self.text_val = ReactiveState(lambda: f'テキストボックスに「{text.get()}」と入力されています。', [text])

    def build(self):
        return ft.Column([ReactiveText(self.text_len), ReactiveText(self.text_val)])

def main(page: ft.Page):
    page.title = "Flet example"
    page.add(ft.Column([ft.TextField(on_change=lambda e: text.set(e.control.value)), InfoAria()]))

ft.app(target=main)

最初のコードと比較するとこんな感じ。(左が最終系、右が最初のコード)
image.png

UI部分のコード量が劇的に減った訳ではありませんが、アップデート処理のコードが丸ごと消えたので、かなり宣言的な記述に近づきました。

まとめ

今回はFletでリアクティブなUIを実現する方法について考えてみました。
やってみた感想ですが、正直小規模アプリの実装であればここまでする必要は全く無いと思います。

中規模アプリを作る際には役立つかもしれませんが、本来このようなリアクティブなUIを作ったり、状態管理をするのはJavaScript等の方がライブラリが豊富で得意分野です。
ElectronやEel等のフロントエンド技術を使ってデスクトップアプリを作れるフレームワーク等を使った方が良い場合もあります。

Fletを使ってこのようなアプリを作る場合は、しっかりとプロジェクトを分析し、PythonでGUIを作るメリットがデメリットを上回れるのか考えてから採用するようにしましょう。

おまけ

Fletの開発ロードマップを見ていると、このような記述があります。
image.png
Reactive approach for Flet apps. とあり、どうやらリアクティブなアプローチを公式が考えているようです。 そうすればこのような処理を自前で用意する必要が無くなりそうです。

Fletは現在絶賛開発中であり、かなり凄いスピードで機能が追加されています。
個人的にPythonのGUIフレームワークの中で最も有望だと思っているので、今後の動きに注視していきたいですね。

137
144
3

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
137
144

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?