NTTドコモR&Dの技術ブログです。

StreamlitアプリのUXを向上させるプチテクニック2選

1. はじめに

こんにちは、ドコモ・テクノロジの小泉です。「ドコモ・テクノロジ」はNTTドコモの機能分担会社の一つであり、主にNTTドコモのR&D業務を分担しています。その中で、私はドコモにおけるデータ活用促進に関わる内製開発や、新たな機械学習(ML)技術の商用化に関わる研究開発を行っています。

昨年は「StreamlitでコードとUIをスッキリさせるためのノウハウをまとめてみた」と題してStreamlitに関するノウハウを投稿しましたが、今年はその続編として重い処理を実行した際のUXを向上させる方法にフォーカスして、Streamlitの機能を使い込んだ実装をご紹介できればと思います。

本記事は、巷のWebサイトでよく紹介されている入門的なStreamlitアプリの実装からもう一歩踏み込んで、よりUI/UXにこだわったデータ活用アプリを作れるようになりたい、と考えているデータサイエンス〜AI/ML界隈のPythonユーザを対象としています。

nttdocomo-developers.jp

2. 重い処理をもつStreamlitアプリでUXを向上させるには

Streamlitが優れている点は、データサイエンティストをはじめとするPythonのコーディングスキルを有するエンジニアが、データの分析結果や集計結果をビジネスサイドのメンバに素早くwebアプリとして見せることができる、という点です。Streamlitは基本的にユーザからのUI操作がある度にページ全体をリロードするシンプルな作りになっています。ただ、そのシンプルさゆえに、ページ内に重い処理が入っていると、何度もユーザを待たせてしまうことになります。データの分析や集計では重い処理がしばしば発生するため、この課題はUXに大きく影響してしまいます。そこで本記事では、少量のPythonコードで統一感のあるUIのアプリを実装できるというStreamlitの強みを活かしつつ、重い処理が含まれていてもUXを向上させる実装テクニックとして、今年新たにStreamlitに搭載された『フラグメント』技術を採用してコードを断片化することで待ち時間を低減する実装、および、『UIをクリア』することで待ち時間にユーザが画面操作してしまうことを防止する実装、の2つを紹介します。

3. アプリのコードを断片化する

Streamlitの大きな特徴の1つは、「ユーザからのUI操作をきっかけに都度コード全体が再実行される」というシンプルな仕組みです。この仕組みがStreamlitのコードが直感的に理解し易いということに大きく貢献している一方で、逆にこれが災いして、コード内に高負荷なデータ処理が存在する場合において、UI操作の度にユーザに長い待ち時間を強いることになる〜アプリの動作がモタモタする、という問題に直面することになります。この対策としてStreamlitには従来からst.session_statest.cache_dataを用いる方法が用意されていました。2024年、そこに新たに『フラグメント』が追加導入されました。『フラグメント』は、2024年4月のVer.1.33.0でst.experimental_fragmentとして試験的に導入された後、2024年7月のVer.1.37.0にて正式にst.fragmentとして導入されました。その後のバージョンアップでも着々とバグ修正が施され、最近では安定して使用できるようになってきた印象です。

『フラグメント』がどのような機能かを一言で言えば、入力ウィジェットの操作により再実行される範囲をコード全体でなく一部のみに限定する機能です。既存のst.session_statest.cache_dataは、「処理結果をストアすることにより高負荷なデータ処理をスキップする」という思想の機能でしたが、コードが再実行される範囲を決められる機能が導入されたことで、ユーザを待ち時間から解放する選択肢が増加したことになります。具体的な使用方法ですが、操作することでコードの再実行(以下、リランと呼ぶ)が発生する範囲を制限したい入力ウィジェットを含むコードの一部を関数として切り出し、かつ、その切り出した関数をデコレータ@st.fragmentでデコレートするだけです。以下の2つのサンプルアプリを使って具体的に説明します。

<フラグメント未適用のサンプルアプリ>

import streamlit as st
from time import sleep

OPTION = ("①あか", "②かさ", "③さいふ", "④ふせん")

st.subheader("フラグメント未適用", divider='gray')
with st.spinner("高負荷の計算を実行中です..."):
    sleep(3)    # 高負荷な処理を模擬したスリープ
st.pills("1つ選択してください", OPTION)
st.button("決定")

図1.フラグメント未適用の画面遷移イメージ

こちらの1つ目のアプリでは、高負荷な処理を持つページにおいて、ユーザからの入力を受け付ける状況を模擬しています。このコードの場合、024年11月のVer.1.40.0で追加導入された選択UIのst.pills(ピルUI)をユーザが操作する度にリランが発生し、都度長い時間(3秒)待たせることになってしまいます。ここにフラグメントを適用することで、待ち時間を解消することができます。以下のコードを上のコードと比較しながらご覧ください。

<フラグメント適用済みのサンプルアプリ>

import streamlit as st
from time import sleep

OPTION = ("①あか", "②かさ", "③さいふ", "④ふせん")

@st.fragment
def step_n():
    st.pills("1つ選択してください", OPTION)

st.subheader("フラグメント適用済み", divider='gray')
with st.spinner("高負荷の計算を実行中です..."):
    sleep(3)    # 高負荷な処理を模擬したスリープ
step_n()
st.button("決定")

図2.フラグメント適用済みの画面遷移イメージ

こちらの2つ目のアプリでは、UIを記述するコードを切り出して関数化した上でフラグメントを適用することで、ユーザによるUIの操作をキッカケとするコードの再実行範囲を同UIが記述された関数の範囲内に制限することができるようになります。実際に試していただくと、ピルUIを操作してもリランに伴う待ち時間が発生しないことがわかります。以上、簡単な例で紹介しましたが、これが実装例です。

一点注意事項があります。フラグメントした関数の内部で行ったst.session_state(以下、状態変数と呼ぶ)の変更をフラグメントの外部、つまりグローバルに反映するためには、フラグメントした関数内でのst.rerun()の実行、もしくは、フラグメント外でユーザからのUIの操作によるリランが必要です。フラグメントされたUIを操作した後に、別途用意したボタンなどで自ずと全体がリランされるようなつくりとなっていれば特に問題は生じないと思います。

少し難しいことも書き加えておきますと、フラグメントしたUI関数をネストする(つまり、フラグメントした関数の中で、別のフラグメントした関数を呼び出す)ことも許容されているようです。その際、st.rerun(scope="fragment")を用いることでリランする範囲をコントロールすることもできるようで、コードの工夫次第では従来のStreamlitよりも階層の深い高機能なUIを構築できる可能性も秘めていそうです。

また、st.fragmentと同時にリリースされたものとしてst.dialogというものもあります。こちらも使い方や機能的にはst.fragmentと同じなのですが、UIとしてはモーダルダイアログ(〜ポップアップされる小さめのウィンドウ)として表示されます。よりユーザの注意を引いて入力してもらいたい内容についてはこちらを使う手もあります。

4. 待ち時間における残像UIをクリアする

前述の工夫を施しても、少なくとも最初の一度はユーザに待ってもらう必要があります。そこで待ち時間中に表示される内容にも焦点を当てたいと思います。Streamlitでは、リランが発生した場合、UI再描画が完了するまでは直前に表示されていたUIを薄く表示し続ける(以下、残像UIと呼ぶ)仕様となっていることはご存知の方も多いかと思います。これは、ボタンのようにユーザが操作可能なウィジェットについても当てはまります。そのため、ユーザがリラン後の再描画完了を待っている間に、薄く表示されている残像UIを操作しようとしてしまう可能性があります。この場合、ユーザは自分の操作をアプリに伝えられていないと感じてしまい、待ち時間に加えてUXをさらに低下させてしまいます。この事象を回避するために、リランが発生したタイミングで即座に残像UIを消去してしまおう、というのがこのセクションでお伝えしたいノウハウです。この対処策の効果を確認することができるサンプルアプリを紹介します。

<待ち時間における残像UIのクリアを確認するサンプルアプリ>

import streamlit as st
from time import sleep

def ui_clear(ph):
    ph.empty()
    sleep(.1)    # 上のemptyを有効にするためのマジック

st.suheader("待ち時間における残像UI", divider='blue')
with (ph := st.empty()).container():
    with st.spinner("前処理を実行中..."):
        sleep(3)
    st.info("前処理完了後に表示されるUI相当")
    st.button("リラン@残像放置")
    st.button("リラン@残像消去", on_click=ui_clear, args=(ph,))

図3.待ち時間における残像UIの画面遷移イメージ

上記アプリの動作を説明します。『リラン@残像放置』ボタンを押した場合は、前処理が完了するまで直前の『前処理完了後に表示されるUI相当』の残像UIが薄くウィンドウ内に表示され再描画完了まで継続する一方、『リラン@残像消去』ボタンを押した場合は、ボタン押下直後にウィンドウ内の残像UIがきれいにクリアされることがおわかりになるかと思います。

コードの中身について簡単に解説します。『リラン@残像消去』ボタンには、コールバック関数を設定しており、このコールバック関数でボタン押下時に残像UIをクリアするというのが基本的な実装方針です。まず、関数ui_clearが、残像UIをクリアするためのコールバック関数です。この関数は引数phでクリア対象のUIコンテナを受け取り、ph.empty()でクリアを実行します。そのため、消去したい対象ののUIをUIコンテナとしてコールバック関数に渡してあげることがポイントです。しかし、これだけだとバックエンドで動作するJavaScriptの実行タイミングとの関係性なのか期待通りにUIをクリアすることができません。そこで残像UIクリアがうまく動作するようにsleep(.1)というマジックを加えています。一方、アプリ本体のコードでは『リラン@残像消去』ボタンの引数on_clickでコールバック関数として先の関数を呼び出すように指定しています。なお、on_clickで指定したコールバック関数に対して引数を渡してあげるためには引数argsを用いますが、argsには必ずタプル(tuple)で引数を指定する必要があります。そのため、phという単一の引数を渡したい場合でも『(』と『,)』で括っています。クリア対象のUIコンテナphは、アプリ本体のコード冒頭のwith句で定義しています。

5. ステップbyステップの雛型にも適用してみた

このセクションは付録的な位置付けですので、昨年紹介したステップbyステップUIに対し、今回紹介した実装を追加した雛型を紹介します。

<フラグメントとUIコンテナのクリアを採用したステップbyステップUIの雛型>

import streamlit as st
from streamlit import session_state as ss
from time import sleep

VIEW = "View"
STEP = ["ステップ1", "ステップ2", "ステップ3"]

if VIEW not in ss:
    ss.update({VIEW: STEP[0]})

def update(ph, **kwargs):
    ph.empty()
    sleep(.1)
    ss.update(**kwargs)

@st.fragment
def step0():    # ステップ1のUI
    ...

@st.fragment
def step1():    # ステップ2のUI
    ...

@st.fragment
def step2():    # ステップ3のUI
    ...

lt, rt = st.columns([5,1])
lt.title("アプリのタイトル")
rt.button("リセット", on_click=ss.clear)
with (ph := st.empty()).container():
    if ss[VIEW] == STEP[0]:
        with st.spinner(f"『{STEP[0]}』の前処理中..."):
            ...     # ステップ1の前処理
        step0()
        st.button("次へ", on_click=update, args=(ph,), kwargs={VIEW: STEP[1]})
    if ss[VIEW] == STEP[1]:
        with st.spinner(f"『{STEP[1]}』の前処理中..."):
            ...     # ステップ2の前処理
        step1()
        st.button("次へ", on_click=update, args=(ph,), kwargs={VIEW: STEP[2]})
    if ss[VIEW] == STEP[2]:
        with st.spinner(f"『{STEP[2]}』の前処理中..."):
            ...     # ステップ3の前処理
        step2()
        st.button("終了", on_click=update, args=(ph,), kwargs={VIEW: STEP[0]})

上記のままでは動作させることはできませんが、簡単に上記の雛型コードの内容も解説します。実際に動作させる場合は『...』の部分を適切に埋めて実行してみてください。

  • 関数updateは、次のステップへUIを遷移させるボタンに用いるコールバック関数です。引数で受け取ったUIコンテナをクリアするとともにビューを制御する状態変数の値を更新して直後に開始されるリランに備えます。
  • 各ステップのUIをフラグメントするためにそれぞれを関数として定義しています。待ち時間発生の原因となりそうな処理はこの関数内には記述しないようにします。UIを構成するにあたって処理の結果を用いる場合は、適宜引数を介して受け取ることが可能です。
  • アプリ本体のコードでは状態変数ss[VIEW]の値に基づくif文にてステップごとに前処理とUIを記述して行きます。前処理に関してはst.spinnerwith句内に記述することでメッセージ付きのスピナーをユーザに提示して待ち状態であることをユーザに知らせます。また、ステップ間でUI(ビュー)を遷移する際の待ち時間において残像UIが表示され続けないように、冒頭で変数phを定義してUIコンテナ化し、以降のUI描画のコードを全てそのwith句内に収めています。

6. まとめ

今回の記事では、高負荷な処理を伴うStreamlitアプリのUXを向上させるテクニックとして、『コードの断片化』および『UIコンテナのクリア』に関する実装方法を具体的に紹介してきました。これらのテクニックを駆使して、ご自身で生み出したデータ分析/データ可視化/AI/ML開発の成果を快適な使用感を備えたWebアプリにしてユーザにスピーディーに届けていただくことで、みなさんのまわりでのデータドリブンな意思決定・ビジネスの効率化や発展に繋がっていくと嬉しいです。