快轉到主要內容
  1. 教學文章/

Streamlit 進階:session_state、cache 與多頁 Dashboard 完全攻略

·11 分鐘· loading · loading · ·
Python Streamlit Dashboard Data-App Cache Session-State
每日拍拍
作者
每日拍拍
科學家 X 科技宅宅
目錄
Python 學習 - 本文屬於一個選集。
§ 52: 本文

featured

一. 前言:Streamlit App 長大以後
#

Streamlit 入門很快:st.title()st.slider()st.dataframe() 放一放,十分鐘就能做出資料小工具。 但 App 一長大,三個煩惱也會跟著出現。 第一,互動之後資料不見了:按一下按鈕,script 重新跑,剛剛暫存的結果好像被風吹走。 第二,每次互動都變慢:只是換一個下拉選單,卻又重新讀 CSV、打 API、載入模型。 第三,單一 app.py 變成義大利麵:頁面、資料讀取、圖表、設定、錯誤處理全部塞在一起。 今天拍拍君要整理三個進階能力:

  • st.session_state:讓使用者狀態活下來
  • st.cache_data / st.cache_resource:讓昂貴運算不要一直重跑
  • 多頁 Dashboard:讓專案結構從 demo 變成可以維護的工具 如果你還沒看過入門篇,可以先補:Streamlit:用 Python 快速打造互動式資料應用。 這篇假設你已經知道怎麼 streamlit run app.py,我們直接進工程化模式。

二. 建立範例專案
#

這次用 uv 建立專案;你也可以用 pip 或 conda。

uv init streamlit-advanced-demo
cd streamlit-advanced-demo
uv add streamlit pandas numpy plotly requests

建議目錄如下:

streamlit-advanced-demo/
├── app.py
├── data/sales.csv
├── pages/
│   ├── 01_📊_Overview.py
│   ├── 02_🔎_Explore.py
│   └── 03_⚙️_Settings.py
├── src/
│   ├── data.py
│   ├── charts.py
│   ├── state.py
│   └── ui.py
├── pyproject.toml
└── uv.lock

小 demo 一個 app.py 就夠;只要開始有兩個以上頁面,拍拍君建議早點拆出 src/。 先放首頁:

import streamlit as st

st.set_page_config(page_title="拍拍君 Dashboard", page_icon="📊", layout="wide")
st.title("📊 拍拍君資料儀表板")
st.write("這是 Streamlit 進階範例的首頁。")
st.page_link("pages/01_📊_Overview.py", label="前往總覽", icon="📊")
st.page_link("pages/02_🔎_Explore.py", label="前往探索", icon="🔎")
st.page_link("pages/03_⚙️_Settings.py", label="前往設定", icon="⚙️")

啟動:

uv run streamlit run app.py

看到側邊欄出現多頁選單,就代表基本架構成功。

三. 重新執行模型:為什麼一般變數會消失?
#

寫好 Streamlit 最重要的不是背 API,而是理解一句話: 每次使用者互動,Streamlit 會把目前頁面的 Python script 從頭到尾重新執行一次。 例如這段:

import streamlit as st

count = 0
if st.button("加一"):
    count += 1
st.write("count =", count)

你可能以為按三次會變成 3,但它通常只會顯示 1,甚至回到 0。 原因很單純:每次按按鈕,count = 0 都會重新執行。 這不是 bug,是 Streamlit 的設計。 它讓你可以用 top-to-bottom Python 寫 UI,但需要保存的狀態不能只放在一般變數裡。 這時候就輪到 st.session_state 出場。

四. st.session_state:保存互動狀態
#

st.session_state 可以想成「每個瀏覽器 session 專屬的一個 dict」。 同一個使用者在同一個 session 裡互動,裡面的資料會保留下來。

import streamlit as st

if "count" not in st.session_state:
    st.session_state.count = 0
if st.button("加一"):
    st.session_state.count += 1
if st.button("歸零"):
    st.session_state.count = 0
st.write("count =", st.session_state.count)

拍拍君寫 Streamlit 時,通常會把初始化集中處理:

# src/state.py
import streamlit as st


def init_state() -> None:
    defaults = {
        "selected_region": "全區",
        "date_range": None,
        "filters_applied": False,
        "last_query": None,
    }
    for key, value in defaults.items():
        if key not in st.session_state:
            st.session_state[key] = value

頁面開頭呼叫:

from src.state import init_state
init_state()

這樣狀態預設值不會散落在每個頁面裡。 小專案只是整潔,大專案會救命。

五. widget key:狀態管理的名字牌
#

Streamlit 的 widget 可以透過 key 自動連到 session_state

import streamlit as st

st.text_input("專案名稱", key="project_name")
st.slider("信心分數", 0, 100, key="confidence")
st.write("目前專案:", st.session_state.project_name)
st.write("信心:", st.session_state.confidence)

只要 widget 有 key,Streamlit 會幫你維護狀態;但 key 要穩定、明確、不要重複。 不好的 key:

st.selectbox("地區", regions, key="select")
st.selectbox("產品", products, key="select")  # 會出事

比較好的 key:

st.selectbox("地區", regions, key="filter_region")
st.selectbox("產品", products, key="filter_product")

拍拍君的習慣是用前綴:

  • filter_:篩選條件
  • form_:表單欄位
  • ui_:純 UI 狀態 這不是規定,但團隊裡有命名習慣,之後 debug 會快很多。

六. callback 與表單:控制互動節奏
#

widget 支援 on_changeon_click callback,適合用在「改了一個欄位,就需要同步更新其他狀態」的情境。

import streamlit as st

if "filters_applied" not in st.session_state:
    st.session_state.filters_applied = False

def mark_filters_dirty() -> None:
    st.session_state.filters_applied = False

def apply_filters() -> None:
    st.session_state.filters_applied = True

st.selectbox("地區", ["全區", "北部", "中部", "南部"], key="filter_region", on_change=mark_filters_dirty)
st.multiselect("產品線", ["拍拍餅乾", "拍拍咖啡", "拍拍筆記本"], key="filter_products", on_change=mark_filters_dirty)
st.button("套用篩選", on_click=apply_filters)
st.write("篩選已套用" if st.session_state.filters_applied else "篩選條件已變更")

如果你有五個篩選欄位,不希望每改一個就重新打資料庫,請用 st.form

import streamlit as st

with st.form("sales_filter_form"):
    region = st.selectbox("地區", ["全區", "北部", "中部", "南部"])
    min_revenue = st.number_input("最低營收", min_value=0, value=1000)
    include_refund = st.checkbox("包含退款資料", value=False)
    submitted = st.form_submit_button("查詢")
if submitted:
    st.json({"region": region, "min_revenue": min_revenue, "include_refund": include_refund})

在 form 裡,使用者可以慢慢改欄位;只有按下 submit,整組條件才一起送出。 如果 Dashboard 會查資料庫、API 或 ML model,這招非常值得用。

七. cache_data 與 cache_resource
#

Streamlit 現代版本主要有兩個 cache decorator:@st.cache_data@st.cache_resourcest.cache_data 適合「輸入相同,輸出資料也相同」的函式,例如讀 CSV、呼叫 API 後回傳 DataFrame、做資料清理。

import pandas as pd
import streamlit as st

@st.cache_data(ttl=3600)
def load_sales(path: str) -> pd.DataFrame:
    df = pd.read_csv(path)
    df["date"] = pd.to_datetime(df["date"])
    return df

sales = load_sales("data/sales.csv")
st.dataframe(sales)

ttl=3600 代表快取一小時。 st.cache_resource 適合「昂貴、可重用、通常不該複製」的資源,例如資料庫連線、模型物件、API client。

import sqlite3
import streamlit as st

@st.cache_resource
def get_connection() -> sqlite3.Connection:
    return sqlite3.connect("data/app.db", check_same_thread=False)

conn = get_connection()

簡單記法:DataFrame、list、dict、查詢結果多半用 cache_data;connection、client、model、engine 多半用 cache_resource

八. cache_data 實戰:讀資料與清理資料
#

把資料讀取放到 src/data.py

# src/data.py
from pathlib import Path
import numpy as np
import pandas as pd
import streamlit as st

@st.cache_data(show_spinner="正在讀取銷售資料...")
def load_sales_data(path: str | Path) -> pd.DataFrame:
    path = Path(path)
    if not path.exists():
        return make_demo_data()
    df = pd.read_csv(path)
    df["date"] = pd.to_datetime(df["date"])
    df["revenue"] = pd.to_numeric(df["revenue"], errors="coerce").fillna(0)
    return df

@st.cache_data
def make_demo_data(days: int = 120) -> pd.DataFrame:
    rng = np.random.default_rng(42)
    dates = pd.date_range("2026-01-01", periods=days, freq="D")
    regions = ["北部", "中部", "南部", "東部"]
    products = ["拍拍餅乾", "拍拍咖啡", "拍拍筆記本"]
    rows = [
        {"date": d, "region": r, "product": p, "revenue": int(rng.normal(8000, 1500)), "orders": int(rng.normal(120, 25))}
        for d in dates for r in regions for p in products
    ]
    return pd.DataFrame(rows)

兩個小細節:cache 函式的輸入參數要讓 Streamlit 能判斷是否改變;回傳的 DataFrame 不要在外面亂改原物件。 如果需要修改,先 .copy()

df = load_sales_data("data/sales.csv").copy()
df = df[df["region"] == "北部"]

這樣比較不容易讓 cache 裡的資料被意外污染。

九. 多頁 Dashboard:總覽、探索、設定
#

建立 pages/01_📊_Overview.py

import plotly.express as px
import streamlit as st
from src.data import load_sales_data
from src.state import init_state

st.set_page_config(page_title="總覽", page_icon="📊", layout="wide")
init_state()
st.title("📊 銷售總覽")
df = load_sales_data("data/sales.csv")

with st.sidebar:
    region = st.selectbox("地區", ["全區"] + sorted(df["region"].unique()), key="filter_region")
    products = st.multiselect("產品", sorted(df["product"].unique()), default=sorted(df["product"].unique()), key="filter_products")

filtered = df.copy()
if region != "全區":
    filtered = filtered[filtered["region"] == region]
if products:
    filtered = filtered[filtered["product"].isin(products)]

revenue = filtered["revenue"].sum()
orders = filtered["orders"].sum()
col1, col2, col3 = st.columns(3)
col1.metric("營收", f"{revenue:,.0f}")
col2.metric("訂單數", f"{orders:,.0f}")
col3.metric("平均客單價", f"{revenue / max(orders, 1):,.1f}")

daily = filtered.groupby("date", as_index=False).agg(revenue=("revenue", "sum"))
st.plotly_chart(px.line(daily, x="date", y="revenue", title="每日營收趨勢"), use_container_width=True)

探索頁可以用 st.query_params 分享目前狀態。

import streamlit as st
from src.data import load_sales_data

st.set_page_config(page_title="探索", page_icon="🔎", layout="wide")
st.title("🔎 資料探索")
df = load_sales_data("data/sales.csv")
products = sorted(df["product"].unique().tolist())
initial = st.query_params.get("product", products[0])
if initial not in products:
    initial = products[0]
product = st.selectbox("選擇產品", products, index=products.index(initial), key="explore_product")
st.query_params["product"] = product
st.dataframe(df[df["product"] == product], use_container_width=True)

設定頁可以放 debug 與重設功能。

st.toggle("顯示 debug 資訊", key="ui_debug")
if st.button("重設使用者狀態"):
    for key in list(st.session_state.keys()):
        if key.startswith(("filter_", "ui_", "explore_")):
            del st.session_state[key]
if st.session_state.get("ui_debug"):
    st.json(dict(st.session_state))

注意:debug 面板在正式環境要小心,不要把 secrets 或私人資料顯示出來。

十. 共用元件、layout 與錯誤處理
#

當頁面變多,不要每頁都複製一份 sidebar、讀資料、畫圖 helper。 把共用邏輯移到 src/,例如 src/charts.py 可以放 revenue_line(df)product_bar(df) 這類圖表函式。 頁面檔案最好讀起來像流程:讀資料、套篩選、顯示 KPI、畫圖,而不是塞滿資料清理細節。 layout 常用三招:

  • st.columns([2, 1]):左邊放主要圖表,右邊放 KPI 或摘要
  • st.tabs(["摘要", "原始資料"]):把原始表格藏到第二層
  • st.expander("進階設定"):收納低頻使用的選項 錯誤處理也要友善。
try:
    df = load_sales_data("data/sales.csv")
except FileNotFoundError:
    st.error("找不到 data/sales.csv,請先放入資料檔。")
    st.stop()
except Exception as exc:
    st.error("讀取資料時發生錯誤。")
    st.exception(exc)
    st.stop()

如果使用者輸入不完整,也可以 st.warning(...) 後接 st.stop(),不要讓後面一串錯誤把頁面炸掉。 Dashboard 不只是把資料丟上去;它是一個讓人快速做判斷的介面。

十一. secrets、效能與快取失效
#

本機 secrets 放在 .streamlit/secrets.toml,例如資料庫 URL、API token、feature flag。 程式中用 st.secrets["database"]["url"] 讀取即可。 請把 .streamlit/secrets.toml 加進 .gitignore,不要把 token commit 上去。 效能方面,先用簡單方法量時間:

from time import perf_counter
start = perf_counter()
df = load_sales_data("data/sales.csv")
st.caption(f"load_sales_data: {perf_counter() - start:.2f}s")

cache 失效常見策略有三種:

  • 設定 ttl,例如 @st.cache_data(ttl=600)
  • 把資料版本放進參數,例如 load_features("v2026-04-29")
  • 提供手動按鈕:st.cache_data.clear() 不要每次載入頁面時自動清 cache,不然 cache 就等於沒存在。 如果資料非常大,考慮先在資料庫端聚合,不要把所有原始資料都塞進瀏覽器。

十二. 部署前檢查清單
#

發佈 Streamlit App 前,拍拍君會檢查這些:

  • requirements.txtpyproject.toml 是否完整
  • secrets 是否沒有 commit
  • 大檔案是否沒有誤放進 git
  • 讀資料與昂貴運算是否有 cache
  • 錯誤訊息是否對使用者友善
  • 預設篩選條件是否合理
  • 頁面 title、icon、layout 是否設定
  • debug 面板是否關閉或有權限保護 如果部署到 Streamlit Community Cloud,通常需要 requirements.txt;用 uv 可以 uv export --format requirements-txt --output-file requirements.txt。 README 也要寫清楚本機啟動方式,例如 uv sync 後執行 uv run streamlit run app.py。 讓未來的自己少罵現在的自己一句,這也是工程師式的溫柔。

十三. 一個可複製的架構模板
#

最後把今天的重點整理成一個模板:app.py 放首頁,pages/ 放多頁介面,src/data.py 負責讀取與清理,src/resources.py 放 DB/API/model,src/charts.py 放圖表 helper,src/state.py 初始化 session state,src/ui.py 放共用 sidebar 與 layout。 每頁開頭固定做兩件事:init_state() 初始化狀態,render_sidebar() 顯示共用側邊欄。 這樣每個頁面都會有一致行為,也更容易維護。

結語
#

Streamlit 最迷人的地方,是它讓 Python 使用者可以很快把資料變成互動介面。 但真正讓 Streamlit App 從 demo 變成工具的,是今天這三件事:

  • st.session_state 管好互動狀態
  • st.cache_data / st.cache_resource 管好效能與資源
  • 用多頁架構和 src/ 拆分讓專案可以長大 拍拍君的建議很簡單:先快速做出能用的東西,然後在它開始變亂之前補上狀態、快取、結構。 不要等到 app.py 長到一千行才開始整理;那時候不是重構,是考古。拍拍君不推薦。🦫

延伸閱讀
#

Python 學習 - 本文屬於一個選集。
§ 52: 本文

相關文章

Streamlit:用 Python 快速打造互動式資料應用
·8 分鐘· loading · loading
Python Streamlit Data-Visualization Web-App Dashboard
Python functools 完全攻略:讓函式變得更強大的秘密武器
·7 分鐘· loading · loading
Python Functools Cache Functional Programming
Python argparse 實戰:CLI 參數解析、旗標設計與 subcommands 完全攻略
·9 分鐘· loading · loading
Python Argparse Cli Command-Line Automation Developer-Tools
Python virtual environments 大比拼:venv vs conda vs uv 怎麼選?
·8 分鐘· loading · loading
Python Venv Conda Uv Virtual Environments Packaging
Python ABC + Protocol:介面設計與 Structural Subtyping
·8 分鐘· loading · loading
Python Abc Protocol Typing Interface Oop
Python Packaging:從 pyproject.toml 到發佈 PyPI 全攻略
·7 分鐘· loading · loading
Python Packaging Pyproject.toml Pypi Pip Uv Setuptools