一. 前言: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_change 或 on_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_resource。
st.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.txt或pyproject.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長到一千行才開始整理;那時候不是重構,是考古。拍拍君不推薦。🦫