一、写在前面
最近上海的梅雨季节让我萌生了做一个 分区雨量看板 的想法。市面上大部分方案要么是网页、要么依赖付费地图 API,我想要的是:
- 桌面程序,双击就能用;
- 真实的行政区划矢量地图(不是自己画的方块示意图);
- 按雨量强度自动着色,一眼就能看出哪个区在下暴雨;
- 配置和历史数据要持久化,下次打开能"接着用";
- 网络不好也不能白屏,最好还能记住我上次手动放大到哪。
最后成品:wxPython + wx.html2.WebView + ECharts + 阿里 DataV.GeoAtlas 组合,约 500 行 Python。这篇博客把关键设计与代码逐个拆解一遍。
C:\myApp\shanghaiweather

二、技术选型
| 需求 | 方案 | 原因 |
|---|---|---|
| 桌面 UI | wxPython | 原生外观、支持嵌入 WebView、能被 PyInstaller 打包 |
| 地图渲染 | ECharts 5 + 阿里 DataV.GeoAtlas | 免 API Key、真实行政边界、支持缩放拖拽 |
| 天气数据 | wttr.in | 无需 Key、返回 JSON、覆盖全球城市 |
| 历史/兜底 | SQLite | 单文件、随程序目录走 |
| 配置持久化 | config.json | 人肉可读、易调试 |
选择 wx.html2.WebView 而不是 PyQt5 的 QWebEngineView,是因为 wxPython 打包出的 exe 体积更小,而且在 Windows 上会自动复用系统的 Edge WebView2 内核。
三、路径处理:让 .py 直接跑 和 PyInstaller 打包后跑 都能定位配置
这是很多初学者第一个踩坑点。如果你在代码里写:
python
CONFIG_PATH = "config.json"
那么打包成 exe 后,程序会去 当前工作目录 找配置,而不是 exe 所在目录------用户从桌面右键"以管理员运行"时工作目录可能变成 C:\Windows\System32,配置直接就丢了。
正确姿势是判断 sys.frozen:
python
def get_base_dir() -> str:
if getattr(sys, "frozen", False):
# PyInstaller 打包后:sys.executable 是 exe 真实路径
return os.path.dirname(os.path.abspath(sys.executable))
else:
return os.path.dirname(os.path.abspath(__file__))
BASE_DIR = get_base_dir()
CONFIG_PATH = os.path.join(BASE_DIR, "config.json")
DB_PATH = os.path.join(BASE_DIR, "weather.db")
无论怎么运行,config.json 和 weather.db 永远和可执行文件放在一起。
四、雨量分级:气象部门标准 + 语义化配色
按国标 1 小时降雨量分级,映射到一组「越深越危险」的配色:
python
def rainfall_level(mm: float):
if mm <= 0: return "无降雨", "#8a9099", "#f2f3f5"
if mm < 2.5: return "小雨", "#3a8ee6", "#e6f2ff"
if mm < 8: return "中雨", "#2266cc", "#d3e6ff"
if mm < 15: return "大雨", "#f0a020", "#fff2d9"
if mm < 30: return "暴雨", "#e6482f", "#ffe1db"
return "大暴雨", "#8c1c13", "#f5d0cb"
这里刻意用了 蓝→黄→红→深红 的顺序,符合直觉:蓝色是水,黄色是警告,红色是危险。绝对不要用彩虹调色板做数值可视化------那会让"中雨"和"大雨"完全没有语义关联。
五、抓天气:主链路 + 模拟数据双保险
wttr.in 是一个非常适合 demo 的免 Key 天气服务,请求 http://wttr.in/Pudong,Shanghai?format=j1 就能拿到 JSON:
python
def fetch_weather_one(code: str) -> dict:
name, query = DISTRICT_MAP[code]
now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
if HAS_REQUESTS:
try:
url = f"http://wttr.in/{query},Shanghai?format=j1"
resp = requests.get(url, timeout=6)
resp.raise_for_status()
data = resp.json()
cur = data["current_condition"][0]
return {
"code": code, "name": name,
"temp": float(cur.get("temp_C", 0)),
"rainfall": float(cur.get("precipMM", 0)),
# ...
"is_mock": False,
}
except Exception:
pass # 落到下面的兜底
# 兜底:随机模拟数据,保证界面演示可用
return {"is_mock": True, "...": "..."}
关键设计:
HAS_REQUESTS是启动时探测import requests是否成功------极端场景下即使连requests都没装,程序也不能崩;- 网络失败走
is_mock=True分支,UI 会顶部飘一条 ⚠ 提示条,用户不会误以为真的没下雨; - 每个区独立请求 + 独立 try/except,一个区挂了不影响其他区。
六、SQLite 历史记录:断网兜底 + 时间序列基础
选择 SQLite 而不是 JSON 文件保存历史,是因为将来要做趋势图:
sql
CREATE TABLE weather_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
district_code TEXT NOT NULL,
district_name TEXT NOT NULL,
temp_c REAL,
weather_desc TEXT,
rainfall_mm REAL,
humidity INTEGER,
wind_kmph REAL,
update_time TEXT NOT NULL
);
CREATE INDEX idx_district_time ON weather_history(district_code, update_time);
有了 (district_code, update_time) 复合索引,查最新一条 ORDER BY id DESC LIMIT 1 或者查最近 24 小时都会走索引。
巧思 :程序启动时 先从数据库读上次记录立刻渲染,再异步去抓新数据。用户永远不会看到白屏:
python
def __init__(self):
# ...
self._show_from_db_or_placeholder() # 秒开
self.on_refresh(None) # 后台刷新
七、核心亮点:接入阿里 DataV.GeoAtlas 的矢量行政区划地图
这是整个项目最有意思的部分。阿里云 DataV 提供了一个 完全免费、免鉴权 的中国行政区划 GeoJSON 服务:
https://geo.datav.aliyun.com/areas_v3/bound/310000_full.json
其中 310000 是上海市的行政区划代码,_full 表示包含下辖 16 个区的边界。返回的 GeoJSON 里每个 feature.properties.name 就是中文区名("浦东新区"、"黄浦区"...)。
7.1 用 ECharts 注册并渲染地图
javascript
fetch('https://geo.datav.aliyun.com/areas_v3/bound/310000_full.json')
.then(r => r.json())
.then(geoJson => {
echarts.registerMap('shanghai', geoJson);
const seriesData = geoJson.features.map(f => {
const name = f.properties.name;
const d = weatherData[name]; // Python 侧注入的雨量数据
if (d) return {
name: name,
value: d.rainfall,
itemStyle: { areaColor: d.color, borderColor: '#fff' },
label: { show: true, color: '#fff', fontWeight: 700 }
};
return { name, itemStyle: { areaColor: '#e5e7eb' } }; // 未选择
});
chart.setOption({
tooltip: { trigger: 'item', formatter: /* ... */ },
series: [{
type: 'map', map: 'shanghai', roam: true,
zoom: INIT_ZOOM, center: INIT_CENTER,
label: {
show: true,
formatter: p => {
const d = weatherData[p.name];
return d ? `${p.name}\n${d.rainfall.toFixed(1)} mm` : p.name;
}
},
data: seriesData
}]
});
});
几个细节值得单独说:
label.formatter里用\n换行,让区名和雨量数值都直接画在地图上,无需悬停;- 未选择的区依然渲染成浅灰色,保证上海全域轮廓完整;
roam: true打开拖拽 + 滚轮缩放,scaleLimit防止用户缩过头;emphasis单独指定深色描边,鼠标悬停时区块会"跳出来"。
7.2 Python 端注入数据
Python 侧的数据以中文名为 key 直接 json.dumps 塞进 HTML:
python
js_data[r["name"]] = {
"code": r["code"], "level": level, "color": color,
"rainfall": round(r["rainfall"], 1),
# ...
}
# ...
html = f"""...
<script>
const weatherData = {json.dumps(js_data, ensure_ascii=False)};
</script>
..."""
ensure_ascii=False 保留中文,否则 GeoJSON 的 "浦东新区" 和 Python 传过来的 "浦东新区" 匹配时容易出诡异 bug。
八、让地图"记住上次的缩放"------Python ↔ JS 单向消息通道
这是最有工程感的一个点。用户用滚轮把地图放大到某个区细看,下次打开程序希望还是那个视图。
难点是:HTML 端在 WebView 里跑,怎么把 zoom/center 告诉外面的 Python?
wxPython 的 wx.html2.WebView 没有像 PyWebView 那样的原生 JS Bridge。取巧方案 :用 document.title 当消息通道,因为 wxPython 会为 title 变化发出 EVT_WEBVIEW_TITLE_CHANGED 事件。
JS 端:debounce 后写 title
javascript
let _titleTimer = null;
function reportMapState(zoom, center) {
clearTimeout(_titleTimer);
_titleTimer = setTimeout(() => {
const cx = center ? center[0] : '';
const cy = center ? center[1] : '';
document.title = `MAPSTATE|${zoom}|${cx}|${cy}`;
}, 250);
}
chart.on('georoam', () => {
const s = chart.getOption().series[0];
reportMapState(s.zoom, s.center);
});
250ms 的 debounce 是为了避免滚轮连滚 20 下就写 20 次 config.json。
Python 端:绑事件 → 解析 → 落盘
python
self.webview.Bind(wx.html2.EVT_WEBVIEW_TITLE_CHANGED, self.on_webview_title)
def on_webview_title(self, event):
title = event.GetString() or ""
if not title.startswith("MAPSTATE|"):
return
parts = title.split("|")
try:
zoom = float(parts[1])
except (ValueError, IndexError):
return
try:
center = [float(parts[2]), float(parts[3])]
except ValueError:
center = self.cfg.data.get("map_center")
self.cfg.data["map_zoom"] = zoom
self.cfg.data["map_center"] = center
self.cfg.save()
下次启动时,build_map_html(map_zoom=..., map_center=...) 把这两个值再 f-string 进 HTML 的 INIT_ZOOM / INIT_CENTER 常量,用户拖到哪就恢复到哪。
这个模式很通用------任何嵌入 WebView 的桌面程序,都可以用 document.title 当廉价 IPC 通道 ,甚至可以协议化:SAVE|...、OPEN|...、LOG|...。
九、线程模型:一个不太起眼但很重要的细节
刷新按钮点击后,如果同步调用 requests.get,主线程会卡 6 秒------UI 直接白屏。所以必须开子线程:
python
def on_refresh(self, event):
if getattr(self, "_refreshing", False):
return # 防止重复触发
self._refreshing = True
self.btn_refresh.Disable()
threading.Thread(target=self._fetch_worker,
args=(codes,), daemon=True).start()
def _fetch_worker(self, codes):
try:
records = [] # 抓取逻辑省略
wx.CallAfter(self._on_fetch_done, records, any_mock, None)
except Exception as e:
wx.CallAfter(self._on_fetch_done, [], False, str(e))
三个易漏细节:
_refreshing布尔锁:防止定时器和用户点击同时触发,导致按钮状态错乱;daemon=True:主窗口关闭时子线程不会拖着进程不退出;- 必须 用
wx.CallAfter回主线程更新 UI------直接在子线程里SetLabel会随机崩溃或界面错乱。