Streamlit 核心 (1.58.x) — 执行模型、状态与刷新 是做什么的?
核心理念: 每当用户与任何组件交互时,Streamlit 都会从上到下重新执行整个脚本。没有事件处理器,没有持久化的局部变量,也没有主循环。每次运行等价于 st.session_state 加上当前组件值的纯函数。以下所有内容均源于此。
每个论断的可运行证明:本技能文件夹中的 st_core_lab.py 文件
(执行命令:python -m streamlit run ~/.roo/skills/st-core/st_core_lab.py)。
已在 Streamlit 1.58 上验证,API 在 1.55 上检查通过:本技能中的所有内容在两个版本中均存在(st.fragment(run_every=) 需 ≥1.37)。
硬性规则(违反即导致经典的应用崩溃)
- 绝不在脚本级别使用
while True:。 脚本必须在每次运行中完成。如需实时区域,应使用@st.fragment(run_every="2s")(见下文)。只有在单次运行内部,为完成有限任务(如进度条、st.write_stream)时,才可使用循环。 - 绝不能无条件调用
st.rerun()(也不能在下次运行时仍为真的条件下调用)—— Streamlit 没有循环保护机制,会导致无限循环。每个st.rerun()必须置于一个你事先翻转的状态守卫之后:if st.session_state.get("needs_refresh"): st.session_state.needs_refresh = False # 在 rerun 之前翻转 refresh_data() st.rerun() - 绝不要使用
time.sleep(n); st.rerun()实现自动刷新。 页面永远不会稳定:组件闪烁,点击事件可能被睡眠期间吞噬。应使用@st.fragment(run_every=...),或者如果“刷新”仅意味着获取新数据,可使用st.cache_data(ttl=...)。Streamlit 没有原生的st.autorefresh(已验证 —dir(st)中没有此函数)。PyPI 上的streamlit-autorefresh包是第三方的,它会在每次计时时重新运行整个脚本(重置所有组件,窃取焦点);推荐使用原生且作用域限定的@st.fragment(run_every=...)。 st.experimental_rerun()不存在(1.27+ 版本已移除)。应使用st.rerun()。同样地,st.experimental_memo/singleton→st.cache_data/st.cache_resource;st.experimental_fragment→st.fragment;@st.cache也已移除。- 大多数
st.rerun()调用是不必要的。 组件交互已经会触发重新运行,而on_click/on_change回调会在那次重新运行之前执行,因此它们的状态写入无需请求另一次重新运行即可生效。合法用途:关闭st.dialog,应用在其渲染位置下方的程序性状态更改(规则 2 模式)。
st.button 是瞬时的 — #1 输出消失错误
st.button() 仅在一次运行中返回 True(点击的那次),然后再次变为 False。所以 if st.button("run"): st.write(result) 会显示结果,直到用户触碰任何其他组件,然后结果就会消失。而且嵌套的按钮永远不能同时为真:点击内层按钮会使外层按钮再次变为 False(已验证)。
# 错误写法 # 正确写法 — 通过状态持久化
if st.button("compute"): if st.button("compute"):
r = model() st.session_state.r = model()
st.write(r) # 下次运行消失 if "r" in st.session_state:
st.write(st.session_state.r)
多步流程:在 session_state 中使用一个 step 键加上根据规则 2 使用的 st.rerun(),永远不要在按钮内部嵌套按钮。
session_state 规则
- 仅初始化一次:使用
st.session_state.setdefault("k", default)(或if "k" not in st.session_state:形式)。在脚本顶部,组件之前执行。 - 如果在本次运行创建了
key="k"的组件之后写入st.session_state.k,会引发StreamlitAPIException: cannot be modified。要以编程方式设置组件值,应在组件行之前写入该键(通常:设置一个标志 +st.rerun(),在下次运行开始时应用写入)— 或者在回调中写入,这样总是安全的。 - 如果
key已存在于 session_state 中,则value=参数会被忽略 — 状态优先;唯一的痕迹是服务器日志中的一条警告(“使用默认值创建,但也通过 Session State API 设置了其值”),UI 中没有任何提示。不要同时传递两者并期望默认值生效。 - 在本次运行中未执行其代码行的组件会丢失其状态(例如通过条件隐藏的标签页、折叠的分支 — 已验证:一个值为 8 的滑块在隐藏后显示为 0)。为了在隐藏时保留状态,请镜像到您自己的键:
st.slider(..., key="_w", on_change=lambda: st.session_state.update(w=st.session_state._w))并通过“写入在组件之前”的模式将其反馈回去。(st.tabs是安全的 — 所有标签页的代码每次都会执行,无论是否隐藏;这也是为什么重标签页需要缓存的原因。) - 两个类型和参数完全相同的组件会引发
StreamlitDuplicateElementId— 请传递不同的key=。在循环中的组件:使用key=f"row-{i}"。
回调优于手动 rerun
在渲染点之下变更的状态会在一次运行中读取到旧值(已验证:if st.button(...) 分支在其上方计数器绘制之后执行)。回调修正了执行顺序 — 它们在脚本重新运行之前执行:
st.session_state.setdefault("count", 0)
def inc(): st.session_state.count += 1
st.button("+1", on_click=inc) # count 在同一次运行中就是最新的
st.metric("count", st.session_state.count)
on_change 在输入/选择组件上同样工作。回调可以写入任何状态键(甚至包括组件的键,因为其行尚未运行)。无需 st.rerun()。
- 回调内部的
st.rerun()是空操作(已验证):它会在用户界面显示一条警告Calling st.rerun() within a callback is a no-op.并中止回调体的其余部分 — 不进行额外的重新运行,也不抛出异常。点击操作已经安排了重新运行;在回调中只需修改session_state然后返回即可。在其中添加st.rerun()“以应用更改”会静默地截断您的回调。
Forms — 阻止每次按键都重新运行
每次组件交互都会重新运行脚本;N 个输入意味着用户在填写时经历 N 次重新运行(每次都会再次命中你的数据层)。将它们批量处理:
with st.form("params"):
a = st.slider("a", 0, 10)
b = st.selectbox("b", opts)
go = st.form_submit_button("Apply")
if go: run_query(a, b)
提交之前不会发生任何重新运行(已验证:运行计数器总共 +1)。在表单内部:不能使用 st.button,除了提交按钮外没有基于每个组件的回调。
“在我打字时页面不断重新运行”几乎从来不是按键引起的。 组件在不同事件上提交(Streamlit 前端行为):text_input/text_area/number_input 在 回车或失焦 时触发,slider/select_slider 在 释放时 触发 — 不是每次按键,也不是拖动过程中;checkbox/radio/selectbox/toggle 会立即触发。一次提交的更改 = 一次重新运行,并且当前端的值未更改时不会发送任何内容。因此,打字时的持续重新运行意味着存在一个并发的整个应用自动刷新(定时器 / st_autorefresh / sleep+rerun)在窃取焦点 — 请将该刷新限定在一个不包含该输入的 @st.fragment 中,或者将输入包裹在 st.form 中。不要对按键进行防抖处理;Streamlit 不需要。
缓存 — 大多数“糟糕的刷新”实际上是因为这个
@st.cache_data— 用于 DataFrame/可序列化结果。每次调用返回一个新的副本(可变操作安全,已验证is为False)。对于需要刷新的数据,使用ttl="15m";在 ↻ 按钮上使用Foo.clear()或st.cache_data.clear()。@st.cache_resource— 用于连接、模型、线程。所有会话返回同一个对象(已验证)— 永远不要在每个用户基础上修改它,如果需要请添加锁。- 参数必须是可哈希的,并构成缓存键;为不可哈希的参数添加
_前缀以排除它们(def load(_conn, day):)。 - 没有缓存时,每次组件交互都会重新运行脚本中的所有数据加载操作 — 应用“感觉卡顿”,因为每次点击都需要几秒钟。先缓存,再调试重新运行。
正确实现实时刷新 — st.fragment
@st.fragment(run_every="2s") # 整数秒 / timedelta / "2s" 字符串
def ticker():
st.metric("last", get_price()) # 仅此函数重新执行
ticker()
- 页面其余部分不会重新运行(已验证:外部运行计数器在片段滴答时冻结)。片段内部的组件仅重新运行片段本身;
st.rerun(scope="fragment")仅重启该片段。 - 陷阱(已验证):
scope="fragment"如果片段体是作为完整应用运行的一部分(首次绘制)执行时,会引发异常 — 请对其进行守卫,或者翻转状态并让run_every接管。 - 片段可以自由读写 session_state;要将更改推送到整个页面,请写入状态然后调用
st.rerun()(作用域为"app",这是默认值)。 - 从外部脚本停止或重新设定
run_every片段的计时 — 片段内部的组件无法做到。run_every仅在完整的应用运行中才会被重新读取,因此片段内部的间隔滑块或停止按钮(它们仅重新运行片段本身)永远无法重新设定定时器(已验证)。请从外部对片段调用进行门控:在完整的重新运行时,secs = st.slider("interval", 1, 10, 2) # 在片段外部 run = st.session_state.get("run", True) tick = st.fragment(run_every=secs if run else None)(_body) tick() if st.button("stop"): st.session_state.run = False; st.rerun()run_every=None(或不调用它)不会发送自动刷新消息,定时器停止。 - 片段的返回值仅在完整运行时到达调用者(已验证)。在
run_every滴答或片段内部的组件变更时,外部脚本并未重新执行,因此data = ticker()保留旧值,下游图表永远不会更新。通过在片段内部写入st.session_state来推送实时数据(st.session_state.data = fetch()),在外部读取 — 或者在片段本身内部绘制图表。不要将片段视为可以在不同滴答之间赋值的普通函数。 - 片段在应用的单线程上运行 — 它不是一个后台线程。 内部的
time.sleep会冻结整个页面在该持续时间;让run_every成为节奏,永远不要睡眠。允许嵌套片段(已验证),但每个都是独立的重新运行作用域 — 子片段的滴答不会重新运行其父片段,因此请将run_every放在拥有数据的片段上;不要为相同数据嵌套定时器。对于真正的后台工作(提交一个慢速作业,继续导航,结果到达)→ [st-jobs]。 - 不要同时在片段内外创建相同键的组件。
- 片段在滴答但值从未改变?你的实时读操作被缓存了。 一个没有
ttl的@st.cache_data加载器会执行其体一次(已验证),并在每次滴答时返回那个冻结的值 —run_every重新执行了函数,但缓存短路了它。这是“我的片段不刷新”的头号原因。修复方法:不缓存实时读取,或者给它一个ttl≤ 刷新周期(@st.cache_data(ttl="2s")),或者每次滴答时改变一个参数。
将片段用作作用域限定的工作盒 — 现在获取,稍后使用(无需完全重新运行)
没有 run_every 的片段是一个页面内的盒子,其组件仅重新运行该盒子本身 — 这是“在此处查找参考数据,在后续操作中消费”的模式,而无需每次查找都重新运行整个页面:
@st.fragment
def fetch_box(): # 此处的点击仅重新运行该盒子
with st.form("lookup"): # 片段内的表单:合法(已验证)
code = st.selectbox("entity", codes, key="box_code")
go = st.form_submit_button("fetch details")
if go:
st.session_state.details = get_details(code) # 通过状态传递
st.success(f"fetched {code}")
fetch_box()
if st.button("run request"): # 外部 → 其点击即是完整重新运行,
d = st.session_state.get("details") # 读取了暂存的 details
if d is None: st.warning("fetch first")
else: run_request(d)
- 消费者不需要
st.rerun():其自身的点击是一次读取新状态的完整运行(已验证)。仅当外部页面必须立即显示获取的数据时(在任何下一次点击之前),才在写入状态后的片段内部使用st.rerun()— 这会造成一次完整运行的开销。 - 通过
session_state传递,绝不能使用片段的返回值(在仅重新运行盒子时是过时的 — 上文已阐述)。对查找操作使用@st.cache_data,这样重新获取相同实体是免费的。
工作时显示状态(在单次运行内部 — 允许循环)
ph = st.empty() # 占位符,原地覆盖
for i, step in enumerate(steps):
ph.text(f"{i+1}/{len(steps)} {step.name}")
step.run()
ph.empty()
也可使用 st.progress、st.status("...", expanded=True)、st.spinner、st.toast。这些在一次运行内动画化 — 不涉及重新运行,没有循环风险。
调试清单:“卡在循环中 / 持续刷新”
- 搜索
st.rerun— 每个是否都处于一个先翻转状态的状态守卫之后(规则 2)? - 搜索脚本级别的
while True/time.sleep→ 应替换为 fragment(规则 1/3)。 - 一个组件写入一个状态键,而另一个行又写回该键 → 导致乒乓式重新运行;每个键只保留一个写操作。
key=冲突或每次运行都会改变的 f-string 键(例如包含time.time())— 每次运行一个新键 = 每次运行组件都会重置。- 未缓存的慢速加载会使正常的重新运行看起来像循环 — 添加
st.cache_data和一个运行计数器(st.session_state.runs += 1)以观察实际发生了什么。 - 实时区域在其定时器上刷新但显示旧值 → 一个不带
ttl的@st.cache_data函数为其提供数据;片段重新运行但缓存返回旧值。添加ttl或不缓存该实时读取。