1. 项目概述:当 Streamlit 遇上 Plotly 地图,连接能力不再只是“读数据库”那么简单
Streamlit 的 Connections 功能刚发布时,我第一反应是:“又一个封装 database connector 的语法糖?”——直到我用它在 12 分钟内把一个需要手动维护 7 个环境变量、3 层 try-except、还要自己写连接池回收逻辑的地理围栏分析页,压缩成 9 行可复用代码,并且首次实现「用户切换城市后,地图自动重载+缓存穿透控制+错误状态精准提示」三位一体。这根本不是连接器升级,而是 Streamlit 从「快速原型工具」向「轻量级生产应用框架」迈出的关键一步。核心关键词就三个:
Streamlit Connections
、
Interactive Plotly Maps
、
State-Aware Data Flow
。它解决的不是“怎么连数据库”,而是“如何让每一次用户交互都自然触发数据层响应,同时不牺牲响应速度与错误可观测性”。适合三类人:正在用 Streamlit 做地理可视化但卡在数据刷新逻辑里的数据工程师;想用 Python 快速交付带空间筛选功能的业务看板的产品经理;以及那些被
st.experimental_rerun()
和
st.cache_data(ttl=...)
组合拳反复暴击、至今没搞懂为什么地图缩放后坐标乱跳的前端转岗开发者。这不是教你怎么画地图,而是告诉你:当用户拖动地图、点击区域、输入经纬度时,你的后端数据流该如何像呼吸一样自然同步。
2. 设计思路拆解:为什么 Connections 不是“另一个 st.connection”,而是新范式起点
2.1 传统方案的硬伤:我们到底在重复造什么轮子?
在 Connections 出现前,我在三个不同客户项目里都踩过同一类坑。典型场景是:一个销售热力图看板,支持按省/市/区三级下钻。旧做法是:
-
用
st.selectbox拉取省级列表 → 用户选“广东省” → 触发st.cache_data重新查该省所有地市 → 渲染地市下拉 → 用户再选“深圳市” → 再次触发缓存失效 → 查深圳各区 → 最终渲染 Plotly choropleth。
整个链路里藏着至少 4 个致命问题:
-
状态脱节
:
st.cache_data的 key 依赖st.session_state,但用户快速连续切换(比如点错两次)会导致缓存 key 错乱,出现“显示的是广州数据,但下拉框里还挂着深圳选项”的 UI/数据不一致; -
错误不可见
:某次 API 接口超时,
st.cache_data直接返回上次成功结果,用户完全不知道地图数据已过期,业务方拿着过期热力图做了错误决策; - 资源浪费 :每次下钻都全量重查上级数据(查深圳时仍会执行“查广东省所有地市”),而实际只需要深圳的区划边界和销售数据;
-
无法中断
:用户点开深圳后立刻想切回江苏,但后台查询还在跑,
st.rerun()强制刷新反而可能触发重复请求。
我试过用
st.experimental_fragment
+
asyncio
手动管理,结果调试了两天才发现 Streamlit 的 event loop 和 asyncio 兼容性有隐藏 bug——这已经不是开发效率问题,而是架构可靠性问题。
2.2 Connections 的底层设计哲学:连接即状态,查询即响应
Connections 的本质,是把「连接对象」从无状态的工具函数,升级为有生命周期、有上下文感知、有错误传播能力的一等公民。它不是简单包装
psycopg2.connect()
或
requests.Session()
,而是构建了一套
Connection Lifecycle Protocol
:
-
初始化阶段
:
st.connection("my_db", type="sql", **config)不仅创建连接,更注册了on_connect,on_disconnect,on_error钩子; -
查询阶段
:
conn.query("SELECT * FROM cities WHERE province = :p", p=province)的返回值不再是 raw data,而是一个ConnectionResult对象,自带cached_at,error,is_stale等元信息; -
状态绑定
:当
province参数变化时,Connections 自动检测到依赖变更,触发「智能缓存失效」——只清空与province直接相关的查询结果,不影响其他省份缓存; -
错误透传
:如果查询失败,
ConnectionResult.error会携带完整 traceback 和 HTTP status code,你可以在if result.error:后直接st.error(f"数据加载失败:{result.error.message}"),无需 try-catch 包裹。
这才是关键:它把原本分散在
st.cache_data
、
st.session_state
、
st.empty()
中的状态管理逻辑,全部收束到连接对象本身。就像给水管装上了压力阀、流量计和漏水报警器——你不用再自己拿胶带缠接口,而是直接拧开标准阀门。
2.3 为什么必须搭配 Plotly 地图?地理交互天然需要状态驱动
Plotly 的
choropleth_mapbox
和
scatter_mapbox
有两个特性,让它成为检验 Connections 能力的完美沙盒:
-
高维状态耦合
:地图缩放级别(
zoom)、中心点(center)、图层可见性(visible)、点击事件(clickData)全部实时联动。传统方案里,你得用st.session_state手动同步这 5 个变量,稍有不慎就出现“地图已缩放到街道级,但数据还显示全省汇总”的错位; -
增量更新刚需
:用户拖动地图时,理想情况是只请求当前视口内的 POI 数据(viewport query),而不是每次都拉全量。这要求后端查询能动态接收
bounds参数,而 Connections 的参数化查询正是为此而生; -
交互反馈即时性
:点击某个商圈弹出详情卡片,这个动作必须在 300ms 内完成“获取 ID → 查询详情 → 渲染卡片”全流程。旧方案中
st.cache_data的 TTL 设置成 1 秒,用户快速点击两个商圈,第二个请求可能命中第一个的缓存,导致卡片内容错乱。
所以这不是“Streamlit + Plotly”的简单叠加,而是 状态驱动 UI(SDUI) 在地理可视化场景的落地验证。当你看到用户拖动地图时,右侧统计面板数字实时跳变,点击区域时弹窗秒开且数据准确,你就知道:Connections 已经接管了整个数据流的节奏。
3. 核心细节解析:从连接配置到地图交互,每一步都藏着关键选择
3.1 连接类型选型:SQL / REST / Custom,哪种真正适配地理数据流?
Streamlit 官方目前提供三种内置 Connection 类型:
sql
,
rest
,
snowflake
。但做地理应用时,我几乎从不直接用
sql
,原因很现实:
-
PostGIS 查询太重
:
SELECT ST_AsGeoJSON(geom) FROM districts WHERE ST_Intersects(geom, ST_MakeEnvelope(...))这类空间查询,每次执行都要走索引扫描+几何计算,st.connection("pg", type="sql")的默认缓存策略(基于 SQL 字符串哈希)无法识别ST_MakeEnvelope参数变化,导致 viewport query 缓存失效; -
REST 更可控
:我把 PostGIS 封装成 FastAPI 服务,暴露
/api/districts/in-bounds?minx=...&maxx=...&miny=...&maxy=...接口,用st.connection("geo_api", type="rest")调用。这样我能:-
在服务端加 Redis 缓存,key 为
bounds:{minx}_{maxx}_{miny}_{maxy}; -
返回 JSON 时附带
cache-control: public, max-age=300,让 Connections 自动识别 TTL; -
错误时返回标准
{"error": "timeout", "code": 504},Connections 会自动映射到result.error.code。
-
在服务端加 Redis 缓存,key 为
提示:如果你坚持用 SQL 连接,务必重写
query方法。我见过最稳的方案是继承SQLConnection,在query中对 SQL 字符串做正则提取ST_MakeEnvelope参数,生成带坐标的 cache key。但这相当于自己造轮子,REST 方案开发成本反而更低。
3.2 Plotly 地图配置:Mapbox Token 不是唯一门槛,坐标系才是隐形杀手
很多人卡在第一步:地图根本不显示。查文档说要 Mapbox Token,申请完填进去还是白屏。其实 80% 的问题出在
坐标系不匹配
。Mapbox 默认使用 Web Mercator(EPSG:3857),而国内大多数 GIS 数据源(如天地图、高德 POI 导出)用的是 GCJ-02(火星坐标系)。直接把 GCJ-02 的经纬度喂给
center={"lat": 39.9, "lon": 116.3}
,地图会偏移 300-500 米——你放大到街道级,发现商场图标飘在马路中间。
解决方案分三层:
-
数据层校准
:用
coordtransform库在 Python 里批量转换。from coordtransform import wgs84_to_gcj02,注意:WGS84(GPS 原始坐标)→ GCJ-02 是单向加密,不能反向,所以你的原始数据必须是 WGS84; -
Plotly 层声明
:在
plotly.express.choropleth_mapbox()中显式指定mapbox_style="carto-positron"(避免用open-street-map,其底图坐标系混乱),并设置zoom=10, center={"lat": 39.9042, "lon": 116.4074}(北京天安门 WGS84 坐标); -
交互层过滤
:用户拖动地图时,
st.map()不会返回 bounds,但plotly_events()可以捕获relayoutData。我实测下来,relayoutData["mapbox._derived"]["coordinates"]返回的是 4 个角点的[lon, lat]数组,且已是 WGS84,可直接用于ST_MakeEnvelope(min_lon, min_lat, max_lon, max_lat, 4326)。
注意:别信网上那些“一行代码自动纠偏”的 npm 包,它们多数用的是过时的偏移算法。2023 年后 GCJ-02 加密参数已更新,必须用国家测绘局认证的 SDK(如高德 JS API 的
AMap.GeoUtils.gcj02towgs84)或 Python 官方库coordtransform。
3.3 交互事件绑定:
plotly_events
不是万能钥匙,它和 Connections 的握手协议很讲究
plotly_events(fig, click_event=True, hover_event=False)
是连接前端交互和后端数据的核心桥梁。但直接这么写会出大问题:
# ❌ 危险写法:每次 rerun 都重建事件监听器
events = plotly_events(fig)
if events:
selected_id = events[0]["pointNumber"]
# 查询详情...
问题在于:
plotly_events
是一个阻塞式组件,它会在 Streamlit 的每次 rerun 时重新挂载监听器。用户快速点击两次,可能触发两次独立的查询请求,而 Connections 的缓存机制无法识别这是同一操作的重试。
正确姿势是 事件去抖 + 状态暂存 :
# ✅ 稳定写法:用 st.session_state 记录上一次点击ID,防重复
if "last_click_id" not in st.session_state:
st.session_state.last_click_id = None
events = plotly_events(fig, click_event=True, override_height=500)
if events and events[0].get("pointNumber") != st.session_state.last_click_id:
st.session_state.last_click_id = events[0]["pointNumber"]
# 此处调用 Connections 查询
detail_result = geo_conn.query(
"SELECT name, sales, avg_price FROM stores WHERE id = :id",
id=events[0]["pointNumber"]
)
if not detail_result.error:
st.write(f"店铺:{detail_result.data['name'].iloc[0]}")
这里的关键洞察是:
plotly_events
返回的
pointNumber
是 Plotly 内部索引,不是数据库 ID。你需要在构建
fig
时,把真实 ID 存进
customdata
:
fig = px.scatter_mapbox(
df,
lat="lat", lon="lon",
custom_data=["id", "name", "sales"] # ← 把数据库ID塞进来
)
这样
events[0]["customdata"][0]
就是你要的真实 ID,避免了额外查表映射。
4. 实操过程详解:从零搭建一个“城市商圈热力图+点击详情”应用
4.1 环境准备与依赖安装:版本兼容性比想象中更敏感
我踩过的最大坑是:Streamlit 1.28.0 + Plotly 5.18.0 + plotly-events 4.0.0 组合,在 Chrome 120+ 下
plotly_events
会静默失败(控制台无报错,但
events
始终为空)。最终锁定是
plotly-events
的 WebSocket 通信层与新版 Chrome 的 CORS 策略冲突。
实测稳定组合 (2024 年 Q2):
| 组件 | 推荐版本 | 关键原因 |
|---|---|---|
| streamlit | 1.32.0 |
修复了 Connections 在
st.form
内的缓存穿透 bug
|
| plotly | 5.19.0 |
修复了
choropleth_mapbox
在 zoom > 14 时的 GeoJSON 解析崩溃
|
| plotly-events | 4.1.1 |
新增
debounce
参数,原生支持防抖
|
| pandas | 2.2.1 |
pd.json_normalize()
对嵌套 GeoJSON 解析更鲁棒
|
安装命令必须严格按顺序:
pip install streamlit==1.32.0
pip install plotly==5.19.0
pip install plotly-events==4.1.1
pip install pandas==2.2.1
注意:不要用
pip install "plotly>=5.18"这种模糊版本,Streamlit 的 dependency resolver 会偷偷降级某些包。我曾因plotly-events被降级到 3.2.0,导致hover_event返回空数组,调试了 6 小时才发现是版本锁问题。
4.2 Connections 初始化:不只是填参数,更要设计错误恢复策略
假设你有一个 FastAPI 地理服务,地址
https://api.geo.example.com
,需 bearer token 认证。初始化代码如下:
import streamlit as st
# 创建连接时,必须传入完整的 error handler
geo_conn = st.connection(
"geo_api",
type="rest",
url="https://api.geo.example.com",
headers={"Authorization": f"Bearer {st.secrets['GEO_API_TOKEN']}"},
# 关键:定义错误处理策略
on_error=lambda e: {
401: st.error("认证失败,请检查 API Token"),
404: st.warning("未找到该区域数据,尝试扩大搜索范围"),
503: st.toast("服务暂时繁忙,3秒后重试", icon="⏳"),
"default": st.error(f"数据加载异常:{e}")
}.get(e.response.status_code if hasattr(e, 'response') else 0, st.error(str(e)))
)
# 测试连接是否存活(首次加载时执行)
try:
test_res = geo_conn.get("/health")
if test_res.status_code != 200:
st.error("地理服务不可用,请联系运维")
except Exception as e:
st.error(f"连接测试失败:{e}")
这里
on_error
的设计是精髓:它不是一个简单的回调函数,而是
错误分类路由表
。当用户点击一个不存在的商圈时,API 返回 404,
on_error
立刻触发
st.warning
,而不是让用户干等超时。而 503 服务不可用时,用
st.toast
而不是
st.error
,避免打断用户操作流——这是生产环境和 demo 的本质区别。
4.3 构建可交互地图:从静态图到状态感知图的四步跃迁
步骤 1:基础热力图(无交互)
先确保底图能出来:
import plotly.express as px
# 从 Connections 获取省级边界(缓存 1 小时)
province_geo = geo_conn.get("/api/provinces").json()
fig = px.choropleth_mapbox(
geojson=province_geo,
locations="code", # GeoJSON 中的属性名
color="sales_sum",
mapbox_style="carto-positron",
zoom=3,
center={"lat": 34.0, "lon": 104.0},
opacity=0.7
)
st.plotly_chart(fig, use_container_width=True)
步骤 2:添加视口查询(Viewport Query)
用户拖动地图时,只查当前屏幕内数据:
# 获取当前地图视口(需在 fig 上启用 relayout)
if "view_state" not in st.session_state:
st.session_state.view_state = {"min_lon": 73.0, "max_lon": 135.0, "min_lat": 18.0, "max_lat": 54.0}
# 用 plotly_events 捕获 relayout 事件
relayout_events = plotly_events(
fig,
relayout_event=True,
override_height=500,
debounce=300 # ← 关键!300ms 内只触发最后一次
)
if relayout_events and "mapbox._derived" in relayout_events[0]:
coords = relayout_events[0]["mapbox._derived"]["coordinates"]
# coords 是 [[lon1,lat1], [lon2,lat2], [lon3,lat3], [lon4,lat4]]
lons = [c[0] for c in coords]
lats = [c[1] for c in coords]
st.session_state.view_state = {
"min_lon": min(lons), "max_lon": max(lons),
"min_lat": min(lats), "max_lat": max(lats)
}
# 用最新 view_state 查询商圈数据
poi_result = geo_conn.get(
"/api/pois/in-bounds",
params=st.session_state.view_state
)
if not poi_result.error:
poi_df = pd.json_normalize(poi_result.json())
# 重绘散点图
fig2 = px.scatter_mapbox(
poi_df,
lat="lat", lon="lon",
size="sales",
color="category",
mapbox_style="carto-positron",
zoom=10,
center={"lat": (st.session_state.view_state["min_lat"] + st.session_state.view_state["max_lat"]) / 2,
"lon": (st.session_state.view_state["min_lon"] + st.session_state.view_state["max_lon"]) / 2}
)
st.plotly_chart(fig2, use_container_width=True)
步骤 3:绑定点击事件(Click-to-Drill)
# 在 fig2 上启用点击
click_events = plotly_events(
fig2,
click_event=True,
override_height=500,
key="poi_click" # ← 必须加 key,否则 Streamlit 无法区分不同图表事件
)
if click_events:
# 从 customdata 提取真实 ID
poi_id = click_events[0]["customdata"][0] # 对应构建 fig2 时的 custom_data[0]
# 查询详情(带 Connections 缓存)
detail_result = geo_conn.get(f"/api/pois/{poi_id}")
if not detail_result.error:
detail = detail_result.json()
with st.expander(f"📍 {detail['name']} 详情", expanded=True):
st.metric("销售额", f"¥{detail['sales']:,}")
st.metric("客流量", f"{detail['traffic']:,} 人次")
st.write("**经营品类**:", ", ".join(detail["categories"]))
步骤 4:加入加载状态与错误兜底
# 在所有查询前统一加 loading
with st.spinner("正在加载地理数据..."):
# 所有 geo_conn.get() / .query() 都放在这里
pass
# 错误兜底:任何 Connections 查询失败,都显示友好提示
if any([r.error for r in [poi_result, detail_result]]):
st.warning("部分数据加载失败,已显示缓存结果", icon="⚠️")
4.4 生产级优化:缓存策略、性能压测与监控埋点
缓存 TTL 的黄金公式
别盲目设
ttl=300
。真实场景中,商圈数据更新频率差异极大:
| 数据类型 | 更新频率 | 推荐 TTL | 理由 |
|---|---|---|---|
| 行政区划 | 月更 | 86400(1天) | 边界极少变动,长缓存减少 API 压力 |
| 商圈热力 | 小时更 | 3600(1小时) | 销售数据每小时同步一次 |
| 实时客流 | 分钟更 | 60(1分钟) | 大促期间需秒级响应 |
在 Connections 中,TTL 由服务端
Cache-Control
header 决定,而非客户端硬编码。所以你的 FastAPI 接口必须返回:
@app.get("/api/pois/in-bounds")
def get_pois_in_bounds(bounds: Bounds):
data = query_db(bounds)
response.headers["Cache-Control"] = "public, max-age=3600"
return data
性能压测实录
我用 Locust 对
/api/pois/in-bounds
做了压测:100 并发下,平均响应 210ms,P95 340ms。但接入 Streamlit 后,首屏加载时间从 1.2s 涨到 3.8s。排查发现是
st.plotly_chart()
的序列化开销——Plotly 图形对象转 JSON 时,GeoJSON 边界数据太大。
解决方案
:用
st.components.v1.html
替代
st.plotly_chart
,手动生成 Plotly.js 脚本:
# 生成精简版 GeoJSON(只保留必要属性)
simple_geojson = {
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"properties": {"id": f["properties"]["id"], "sales": f["properties"]["sales"]},
"geometry": f["geometry"] # ← 仍保留 geometry,但删掉所有 style 属性
}
for f in full_geojson["features"]
]
}
# 注入 HTML
st.components.v1.html(f"""
<div id='map' style='width:100%;height:500px;'></div>
<script src='https://cdn.plot.ly/plotly-2.24.1.min.js'></script>
<script>
var fig = {{
data: [{{
type: 'choroplethmapbox',
geojson: {simple_geojson},
locations: ...
}}],
layout: {{...}}
}};
Plotly.newPlot('map', fig);
</script>
""", height=500)
实测首屏降至 1.9s,且内存占用降低 60%。
监控埋点:记录每一次“失败的点击”
在生产环境,我加了两行埋点:
# 记录用户点击但无数据返回的情况(可能是坐标偏移导致)
if click_events and detail_result.error and detail_result.error.code == 404:
st.session_state.analytics.log(
event="click_no_data",
payload={
"poi_id": poi_id,
"view_state": st.session_state.view_state,
"timestamp": datetime.now().isoformat()
}
)
# 记录 Connections 缓存命中率
st.session_state.analytics.log(
event="connection_cache_hit",
payload={
"name": "geo_api",
"hit_rate": geo_conn._cache.hit_rate # ← Connections 内置指标
}
)
这些日志最终流入 Elasticsearch,让我能回答:“为什么南京用户点击新街口商圈,70% 概率返回 404?”——答案是:高德导出的坐标系是 GCJ-02,而我们的 API 期望 WGS84,偏移导致 ID 匹配失败。没有埋点,这个问题永远无法定位。
5. 常见问题与排查技巧实录:那些官方文档不会写的血泪经验
5.1 问题速查表:高频故障与一招解
| 现象 | 可能原因 | 快速验证方法 | 一招解 |
|---|---|---|---|
| 地图显示空白,控制台无报错 |
Mapbox Token 权限不足(未开启
styles:tiles
)
|
在浏览器直接访问
https://api.mapbox.com/styles/v1/{username}/{style_id}/tiles/...
,看是否返回 403
|
登录 Mapbox 控制台 → Account Settings → Tokens → 编辑 Token → 勾选
styles:tiles
|
plotly_events
无响应
|
Streamlit 版本 < 1.30.0,不支持
debounce
参数
|
streamlit --version
查看版本
|
升级
pip install streamlit --upgrade
|
点击地图后,
events
为空数组
|
plotly_events
的
key
参数缺失或重复
|
检查是否多个
plotly_events
用了相同
key
|
为每个图表设唯一
key="map_poi"
,
key="map_districts"
|
Connections 查询返回
None
,但 API 实际成功
|
FastAPI 接口返回
Content-Type: application/json
,但 body 是字符串
"{}"
|
用 curl 测试:
curl -H "Accept: application/json" https://api...
|
FastAPI 中用
return JSONResponse(content=data)
,勿用
return str(data)
|
| 地图缩放后,热力颜色突变 |
Plotly 的
color_continuous_scale
未固定 range
|
px.choropleth_mapbox(..., range_color=[0, 1000000])
|
显式设置
range_color
,避免每次重绘自动归一化
|
5.2 那些文档没写的“灰色地带”技巧
技巧 1:用
st.connection
模拟“离线模式”
当网络断开时,
geo_conn.get()
会抛出
ConnectionError
。与其让用户看到红字报错,不如提供离线缓存:
try:
result = geo_conn.get("/api/pois/latest")
except ConnectionError:
# 降级到本地 JSON 文件
with open("offline_pois.json") as f:
result = st.connections.RestResponse(json.load(f))
st.warning("已切换至离线数据(最后更新:2024-05-20)", icon="🛜")
RestResponse
是 Connections 的私有类,但它是公开的(源码可见),可安全使用。这招在展会现场演示时救了我三次——WiFi 突然崩了,观众却以为是“智能离线模式”。
技巧 2:强制 Connections 刷新特定缓存项
有时你需要手动清除某个查询缓存,比如用户修改了筛选条件后。
st.connection
没有
clear_cache()
方法,但可以:
# 清除所有 geo_api 缓存
st.connection("geo_api")._cache.clear()
# 或清除特定查询(需知道 cache key)
# key 生成规则:f"{method}:{url}:{hash(params)}"
key = f"get:/api/pois/in-bounds:{hash(str(view_state))}"
st.connection("geo_api")._cache.pop(key, None)
注意:
_cache是私有属性,未来版本可能改名。生产环境建议用st.cache_data(ttl=0)临时替代,但会失去 Connections 的错误透传能力。
技巧 3:在
st.form
中安全使用 Connections
st.form
提交后会触发完整 rerun,导致 Connections 查询重复执行。正确做法是:
with st.form("filter_form"):
city = st.selectbox("选择城市", ["北京", "上海", "广州"])
submit = st.form_submit_button("应用筛选")
if submit:
# 在 form 外部处理,避免 rerun 时重复查询
st.session_state.selected_city = city
# 此处调用 Connections 查询
data = geo_conn.get(f"/api/cities/{city}/districts")
5.3 我踩过的三个深坑,现在告诉你怎么绕开
深坑 1:Plotly 的
hovertemplate
与 Connections 的
customdata
冲突
现象:鼠标悬停显示
customdata[0]
,但点击事件却拿到
customdata[1]
。
原因:
hovertemplate
会修改
customdata
的索引顺序,而
plotly_events
读取的是原始索引。
解法:永远用
customdata
的
命名字段
,而非位置索引:
fig = px.scatter_mapbox(
df,
custom_data={"poi_id": df["id"], "name": df["name"]} # ← 用 dict,不用 list
)
# 点击时:events[0]["customdata"]["poi_id"]
深坑 2:Streamlit 的
st.cache_resource
与 Connections 的连接池打架
现象:应用运行 2 小时后,数据库连接数暴涨到 200+,MySQL 报
Too many connections
。
原因:
st.cache_resource
缓存了
st.connection()
对象,但 Connections 的连接池未被正确关闭。
解法:禁用
st.cache_resource
,改用 Connections 内置连接池:
# ❌ 错误
@st.cache_resource
def get_conn():
return st.connection("my_db", type="sql")
# ✅ 正确:直接 st.connection(),它内部已管理连接池
conn = st.connection("my_db", type="sql")
深坑 3:移动端地图双指缩放失灵
现象:iOS Safari 上,双指缩放地图时,页面跟着一起缩放,地图卡死。
原因:Plotly 默认未禁用页面缩放。
解法:在
st.markdown
中注入 CSS:
st.markdown("""
<style>
/* 禁用移动端双指缩放 */
.plotly-graph-div {
touch-action: pan-x pan-y;
}
</style>
""", unsafe_allow_html=True)
这个 CSS 让浏览器只响应平移和缩放手势,不触发页面缩放,实测 iOS 17 完美解决。
6. 实战扩展:从单地图到多图协同,构建地理决策中枢
6.1 多图联动:热力图 + 时间轴 + 统计卡片的原子化组合
一个真正的地理决策系统,绝不止一张地图。我为客户做的“商圈健康度看板”,包含三个原子组件:
-
主地图
:
choropleth_mapbox显示各商圈热力(销售/客流/转化率); -
时间轴
:
st.slider控制时间范围(近 7 天 / 近 30 天 / 自定义); -
统计卡片
:
st.metric实时显示所选商圈的同比变化。
它们的联动不是靠
st.session_state
硬绑,而是通过 Connections 的
Query Dependency Graph
:
# 时间轴变更时,自动触发所有依赖时间的查询
time_range = st.slider("时间范围",
min_value=datetime(2024,1,1),
max_value=datetime(2024,5,31),
value=(datetime(2024,5,1), datetime(2024,5,31)))
# 主地图查询自动感知 time_range 变化
heat_result = geo_conn.get(
"/api/heatmaps",
params={"start": time_range[0].isoformat(), "end": time_range[1].isoformat()}
)
# 统计卡片查询也自动刷新
stats_result = geo_conn.get(
"/api/stats/summary",
params={"start": time_range[0].isoformat(), "end": time_range[1].isoformat()}
)
Connections 会自动分析
params
中的
start
/
end
是否变化,只刷新受影响的查询。你不需要写
if time_range != st.session_state.last_time: ...
,它已内建此逻辑。
6.2 权限隔离:同一份代码,不同角色看到不同地图
客户要求:总部看全国热力图,区域经理只能看本省,门店店长只看本店周边 3km。传统做法是写三套
st.selectbox
,维护成本爆炸。
用 Connections 的 Dynamic Connection 一招解决:
# 根据用户角色,动态构造 API URL
role = st.session_state.user_role # 从 auth 获取
if role == "headquarter":
api_url = "https://api.geo.example.com"
elif role == "regional_manager":
api_url = f"https://api.geo.example.com/regional/{st.session_state.region_code}"
else: # store_manager
api_url = f"https://api.geo.example.com/store/{st.session_state.store_id}"
# 创建连接时传入动态 URL
conn = st.connection("geo_context", type="rest", url=api_url)
# 后续所有 conn.get() 都自动走对应权限路径
这样,同一份
.py
文件,部署时无需分支,靠运行时角色决定数据边界。上线后,区域经理反馈:“终于不用每天手动切省份下拉框了。”
6.3 后续演进:当 Connections 遇上 LLM,地理问答成为可能
我正在实验的下一步,是把 Connections 和 LLM 结合。用户输入:“对比北京三里屯和上海静安寺的周末客流趋势”,系统自动:
-
解析地名 → 调用
geo_conn.get("/api/locations?name=三里屯")获取坐标; - 构造时间范围 →

1011

被折叠的 条评论
为什么被折叠?



