最近我把自己一直在用的天气预报网页换成了桌面版,起因挺简单:浏览器里的天气标签越开越多,每天想看一眼温度和风力,总要在一堆标签页里找半天,碰上网络波动还要等网页慢慢转圈。桌面版天气预报应用的核心优势就是它在你需要的地方等着你,开机自启、不占注意力、点开就能看。
这个项目本身技术难度不算高,但涉及到的细节不少:数据源怎么选、界面怎么布局、请求失败怎么处理、最后怎么打包成 exe 发给朋友用。我前后花了大概两个晚上做完,这篇文章就把整个决策和实现过程拆开讲一遍,包含代码和截图里不会写的踩坑记录,给想动手做桌面小工具的朋友参考。
1. 为什么非要做成桌面应用,而不是继续用网页
1.1 桌面应用的真正价值:常驻、快捷、不被标签页淹没
很多人觉得看天气是小事,打开手机不就行了吗。但我个人的使用场景比较特殊:工作的时候要在多个平台之间切换,中间的碎片时间里,想确认"今天回家要不要带伞""晚上降温多少",这个动作必须足够快,快到不能打断我手头正在做的事情。
浏览器看天气有几个天生的问题。第一,标签页一多,你根本不知道哪个是天气;第二,不少天气网站首屏塞满了广告、推荐、新闻流,想要的信息反而被挤到不知名的地方;第三,浏览器每次冷启动都要加载各种脚本,哪怕只是看一个温度,都要等一两秒甚至更久。桌面应用在这三方面都是反着来的:窗口固定、内容纯粹、启动速度可以做到几百毫秒。
所以这个项目的定位很清晰:不需要做得多花哨,只解决"三秒内告诉我今天穿什么、带不带伞"这一个核心需求。设计目标明确之后,技术选型就变得非常简单了。
1.2 为什么选择了 Python + PySide6 而不是 Electron
关于桌面应用的跨平台方案,我其实认真对比过几套,最终选了 Python + PySide6(Qt 的 Python 绑定),主要原因有三个。
第一是开发效率。这个应用的核心逻辑就是"请求 API + 解析 JSON + 渲染到界面",用 Python 写起来非常直接,requests 库一发,几行代码就能拿到数据。PySide6 的信号槽机制做界面刷新特别顺手,比手写事件循环舒服太多。
第二是 Qt 的布局系统足够成熟。QVBoxLayout、QHBoxLayout、QGridLayout 这些布局管理器能保证窗口在任意尺寸下都不乱套,完全不需要手动计算坐标,这一点对不擅长前端布局的我来说非常友好。
第三是打包和分发相对省心。Python 程序用 PyInstaller 打包成单文件 exe 后,对方机器上不需要安装任何运行环境,双击就能跑,对非技术朋友来说零学习成本。
我还实际考虑过 Electron 和 Tauri。Electron 胜在界面表现力强,首屏渲染出来比 Qt 精致,但打包体积随随便便上百兆,内存占用也高,对一个"常驻后台"的小工具来说过于奢侈。Tauri 是个很好的方向,体积小、性能好,但要求你用 HTML/CSS/JS 重新实现一遍界面,还需要 Rust 环境编译,对一个偏数据逻辑的小项目来说,投入产出比不如 Qt。另外还有一个 tkinter 选项,它是 Python 自带的 GUI 库,入门极快,但控件风格比较朴素,想做出现代感十足的圆角卡片界面会比较费劲。如果你只要求功能能跑,tkinter 完全够;但既然做桌面版,我希望观感至少达到主流应用的水平。
1.3 软件层面的整体架构
架构上我没有搞得很复杂,就三个模块:
main.py:程序入口,负责初始化窗口、挂载事件、启动定时器。weather_api.py:封装所有网络请求和解析逻辑,对外只提供一个get_weather(city)函数,返回一个字典。ui_main.py:主窗口界面,包括搜索框、城市列表、天气展示卡片和托盘图标。
这样的分层对接下来的开发非常有利:界面和网络请求完全解耦,就算以后想把界面换掉,也不用动数据层;反过来,如果 OpenWeatherMap 挂了想换数据源,只需要改weather_api.py。实际开发时我建议你也这样做,别把所有代码堆在一个文件里,后面维护会非常痛苦。
2. 天气数据源:API 选起来容易,用起来全是细节
2.1 为什么用 OpenWeatherMap 作为数据源
桌面天气应用的数据来源有好几个选择:OpenWeatherMap、WeatherAPI、和风天气、心知天气等。综合免费额度、文档质量和请求方式,我最终选了 OpenWeatherMap 作为主力数据源。
OpenWeatherMap 的免费套餐(Free 档)足够个人使用:每分钟可以发约 60 个请求,每天有 100 万个请求的限制(这已经远远超出个人桌面应用的范围了),返回结果里包含了当前天气、体感温度、湿度、风速、风向、气压等字段,还有一个很有用的weather数组,里面带着官方的天气现象代码和描述文本。免费套餐已经包括了中文描述的支持,只需在请求 URL 里加上lang=zh_cn参数,返回的天气描述就是中文。
对于国内用户可能更关心的是访问速度。OpenWeatherMap 的服务器在海外,如果没有可用的访问通道,实际请求延迟会稍高,通常在几百毫秒到一两秒之间。如果你的使用场景对响应速度极其敏感,可以考虑国内天气服务商的 API,原理上完全一样。我下面写的处理逻辑和参数是通用的,换成任何一家服务商都只是改 URL 和解析字段的问题。
2.2 拿到 API Key 之后,第一件事是读懂返回结构
OpenWeatherMap 要求注册账号并创建 API Key,免费申请,过程不复杂。注册完成后在 dashboard 里拿到一串密钥,我的习惯是先放到环境变量或者本地配置文件里,坚决不硬编码到代码中,避免代码将来被别人看到时密钥泄露。
请求当前天气的 API 格式是这样的:
https://api.openweathermap.org/data/2.5/weather?q=Hangzhou&appid=你的API_KEY&units=metric&lang=zh_cn其中units=metric是切换单位的关键。这个参数会把温度数据从默认的开尔文(Kelvin)换算为摄氏度,风速则变成米/秒,能见度变成米,强烈建议加上。如果忽略这个参数,你在界面上看到的温度会是 296 这种开尔文数值,新手很容易在这里栽跟头,还以为是接口返回了数据错误。
服务端返回的 JSON 长这样:
{ "coord": { "lon": 120.16, "lat": 30.29 }, "weather": [ { "id": 800, "main": "Clear", "description": "晴", "icon": "01d" } ], "main": { "temp": 23.5, "feels_like": 24.1, "pressure": 1015, "humidity": 60 }, "visibility": 10000, "wind": { "speed": 2.1, "deg": 120 }, "name": "Hangzhou" }里面最有用的是weather[0].id,这个整数是天气现象代码,比如 800 表示晴天,500 表示小雨,202 表示雷阵雨伴有强降水。代码和现象的对应关系在文档里有一张表,我的建议是不要直接拿description字符串去渲染,因为不同语言的返回内容差异很大,而id是一个稳定的、跨语言的标识。我会在本地做一个 id 到中文文案的映射表,例如:
WEATHER_TEXT = { 800: "晴天", 801: "晴间多云", 802: "多云", 803: "阴", 804: "阴", 500: "小雨", 501: "中雨", 502: "大雨", }这块映射表不用覆盖特别完整,常见的十几二十个状态就够用了。因为description已经有中文,我也只是把温度之外的文案统一成自己的表达方式。
2.3 城市定位的细节:城市名、地理编码与坐标
查询城市天气有两种常见方式。一种是直接用城市名和地区代码写在请求 URL 里,比如q=Hangzhou,好处是简单,坏处是 OpenWeatherMap 对同样的城市名可能有多个匹配。比如London可能是英国的伦敦,也可能是加拿大安大略省的伦敦,还可能来自其他国家。直接用城市名很容易踩到歧义坑。
另一种更可靠的方式是先调用 OpenWeatherMap 的地理编码接口(Geocoding API),输入一个城市名和可选的国家代码,拿到精确的经纬度坐标,再用坐标去请求天气。地理编码接口的地址是:
http://api.openweathermap.org/geo/1.0/direct?q=Hangzhou&limit=5&appid=你的API_KEY返回结果里会有多个{ name, lat, lon, country, state }条目,我从里面选择country为CN的那一个,把经纬度存下来,再请求:
https://api.openweathermap.org/data/2.5/weather?lat=30.29&lon=120.16&appid=你的API_KEY&units=metric&lang=zh_cn这样的好处是:只要定位确认过,后续所有城市切换都用坐标,完全避开同名城市的歧义,而且坐标请求不需要再解析城市名,接口处理更快、更稳定。我在界面里维护了一个城市列表,支持添加和删除城市,切换城市时其实就是在切换一组经纬度和显示名称。
对于中文城市名,OpenWeatherMap 对拼音的支持比较好。对于简体中文输入,建议先做一次城市名到拼音的转换,或者按照地理编码接口的规则把中文名转换成拼音再查询。我一开始直接用中文名发起请求,结果部分城市返回 404,改成拼音后就没这个问题了。以后换数据源这步可能需要调整,但"先编码城市再请求坐标"的思路不会变。
3. 主界面从零搭建:布局、渲染与交互
3.1 界面框架设计:一屏就能看完整天的信息
这一版界面我用了比较经典的卡片式布局,整体从上到下分成三块区域:
- 顶部操作栏:城市搜索输入框、添加按钮、城市下拉切换列表。
- 主信息卡片:大字号显示当前温度,配一个大图标,加上天气描述文字,比如"晴 23.5°C"。
- 详细参数区:用一组小卡片展示体感温度、湿度、风速、风向、气压、能见度。
窗口的初始尺寸设为 420x520,这个大小刚好能在桌面角落放下,不会遮挡主工作区。无论你放大缩小窗口,布局都不会乱,因为全部用的 Qt 布局管理器,只设置了控件之间的间距,没有写死任何坐标。
主界面代码的结构大概是这样:
from PySide6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, QLineEdit, QPushButton, QComboBox, QLabel, QFrame ) class MainWindow(QWidget): def __init__(self): super().__init__() self.setWindowTitle("桌面天气预报") self.resize(420, 520) # 外层容器 outer = QVBoxLayout(self) outer.setContentsMargins(16, 16, 16, 16) outer.setSpacing(12) # 顶部操作栏 top_bar = QHBoxLayout() self.city_input = QLineEdit() self.city_input.setPlaceholderText("输入城市拼音,例如 Hangzhou") self.add_btn = QPushButton("添加") self.city_select = QComboBox() top_bar.addWidget(self.city_input) top_bar.addWidget(self.add_btn) top_bar.addWidget(self.city_select) # 主信息卡片 self.temp_label = QLabel("--°C") self.temp_label.setAlignment(Qt.AlignmentFlag.AlignCenter) self.desc_label = QLabel("加载中...") self.desc_label.setAlignment(Qt.AlignmentFlag.AlignCenter) ... outer.addLayout(top_bar) outer.addWidget(self.temp_label) outer.addWidget(self.desc_label) outer.addWidget(self.details_frame)是否需要使用QMainWindow并不关键。对于这种固定内容的小窗口,直接用QWidget作为根组件,配合布局管理器就完全够用了,代码更精简。
3.2 天气图标:我没有用图片文件,而是用 emoji 字体
天气图标这块我纠结了一会。正常方案是下载 OpenWeatherMap 官方的图标 PNG 文件存在本地,按weather[0].icon字段加载。官方的图标确实做得不错,但需要额外维护几十个文件,打包时也要注意包含进去。
第二个方案是用图片素材网站找一套天气 icon 放进去,需要解决许可证和风格统一的问题。第三个方案最省心:直接用系统 emoji 字体渲染天气图案。比如晴天用 "☀"、多云用 "☁"、下雨用 "🌧"、下雪用 "🌨"。在 Qt 里,QLabel 天然支持 Unicode 文本,设置一个比较大的字号,就能渲染出清晰好看的天气图标。
我最终采用了第三种方案。理由是桌面应用的主导语言是中文,emoji 字体在 Windows 和 macOS 上都有原生支持,不需要额外引入资源文件,也不会出现图片缺失的情况。你只需要维护一个天气代码到 emoji 的映射:
WEATHER_EMOJI = { 800: "☀", 801: "🌤", 802: "⛅", 803: "☁", 804: "🌥", 500: "🌧", 501: "🌧", 502: "🌧", }这里要提醒一个细节:emoji 在不同操作系统上的渲染效果并不完全一样,Windows 上的 "☀" 可能看起来不如 macOS 上那么精致。如果你特别在意观感一致性,那还是用官方 PNG 图标更好;如果和我一样追求开发效率,emoji 是完全够用的。
3.3 异步请求、定时刷新和信号槽配合
GUI 应用最忌讳的一行代码是直接在 UI 线程里发网络请求。如果请求耗时两秒,这两秒里窗口就会进入"未响应"状态,鼠标点击没反应,标题栏还会出现"正在等待响应"的提示,非常掉价。
我用QThread来处理网络请求。具体做法是建立一个WeatherWorker类,继承QObject,把请求和解析的逻辑放在里面,然后把它移动到单独的QThread实例中运行。线程完成请求后,通过信号把解析好的字典发回主线程,主线程再更新界面控件。
核心写法示意:
from PySide6.QtCore import QThread, Signal, QObject import weather_api class WeatherWorker(QObject): finished = Signal(dict) error = Signal(str) def __init__(self, lat, lon): super().__init__() self.lat = lat self.lon = lon def run(self): try: data = weather_api.get_weather_by_coords(self.lat, self.lon) self.finished.emit(data) except Exception as e: self.error.emit(str(e)) def start_refresh(self, lat, lon): self.thread = QThread(self) self.worker = WeatherWorker(lat, lon) self.worker.moveToThread(self.thread) self.thread.started.connect(self.worker.run) self.worker.finished.connect(self.on_weather_ready) self.worker.error.connect(self.on_weather_error) self.thread.start()需要注意worker和thread的生命周期。每次点击刷新按钮,我先清理掉上一次的线程对象,再创建一个新线程,避免旧线程残留或者信号重复连接。刷新频率方面,我设置了一个QTimer,每 30 分钟自动刷新一次,确保长期开着窗口时数据不会过期。
4. 实测过程中踩过的坑,和对应的补丁方案
4.1 第一次启动白屏:把"加载中"做成一种状态,而不是空白
这个坑其实不完全是天气应用特有的,几乎所有涉及网络请求的应用都会遇到。按理说,窗口启动后应该先展示信息,再后台请求数据,但实际上很多人的第一版代码是 "启动 -> 发请求 -> 等结果 -> 更新界面",进程阻塞在请求这一步,窗口就一直是白茫茫一片。
开了 Qt 自带的高效之后,画面空白会稍微少一点,因为控件渲染完成后才开始请求,但显示区域里全是"加载中"的占位文案。我平时也见过不少写得不细致的例子,启动后直接用QLabel显示空字符串,数据没回来时用户面对的就是一张完全没信息的白纸。
我的解决方案是把界面拆成三种状态:加载中、正常、错误。加载中状态显示一个暂时文案如"正在获取天气..."; 正常状态显示温度和详情;错误状态显示"网络不可用,请检查连接"并提供重试按钮。不管请求多久才返回,用户始终能知道程序正在干什么。
顺带一提:如果你用的是 PySide6 中自带的请求网络库(比如QNetworkAccessManager),也需要做一些异步同步的细节,myself 这里图省事直接用了 requests 库,因为QNetworkAccessManager的写法个人觉得远不如 requests 直接,尤其在处理 POST JSON 等场景时。
4.2 单位、格式化与"感觉温度"的呈现
units=metric让我们直接拿到摄氏温度,但仍有两个细节值得处理。
第一是温度显示的精度控制。接口返回的温度可能是 23.45,界面没必要显示两位小数,一般保留 0 位或 1 位小数就够了。我做了一个判断:温度在 -10 到 35 之间时保留一位小数,超出这个区间则取整。这样夏天显示 32°C 不会出现 32.0°C 这种别扭的写法。
第二是体感温度和实际温度的差异。体感温度是综合湿度、风速计算出来的,比单独看温度更能反映"到底冷不冷"。我在详情卡片里把体感温度专门标出来,并做了一行简单文案,比如"体感温度比实际温度低 2.3°C,注意添衣"。这个文案的逻辑很简单,实际温度和体感温度相差大于 1 度时显示,否则显示"体感温度接近实际温度"。
这里的判断逻辑要注意浮点数比较的精度问题,例如温差计算时建议四舍五入到一位小数之后再比较,直接比浮点数有时候会得到 0.9999 这种极端结果。
4.3 多城市管理与系统托盘:让应用真正融入桌面
多城市管理是我在第二版才加上的功能。原因是日常使用中,除了呆在工位,还要知道家里和另一个常去地区的天气情况。我在顶部放了下拉框QComboBox,城市列表通过一份本地 JSON 文件维护。添加城市时,输入拼音并回车,程序先通过地理编码接口拿到坐标和城市名,再把它加到列表里,最后写入配置文件。
再一个体验升级是系统托盘。很多常驻类应用都应该有托盘图标,不然最小化之后就找不到窗口,还得去任务栏翻。我用QSystemTrayIcon做了三件事:
- 点击托盘图标:显示或隐藏主窗口。
- 右键菜单:提供"立即刷新"和"退出程序"两个选项。
- 退出程序时真正结束进程,而不是让你点击关闭按钮后进程还在后台杵着。
托盘图标不需要单独用图片素材,可以直接用当前天气的 emoji 文字渲染到QPixmap上。每次刷新完,托盘图标更新一次,这样即使窗口被藏在后面,扫一眼托盘也能知道大概天气。
from PySide6.QtGui import QPixmap, QPainter def update_tray_icon(self, emoji): pixmap = QPixmap(64, 64) pixmap.fill(Qt.GlobalColor.transparent) painter = QPainter(pixmap) font = painter.font() font.setPixelSize(48) painter.setFont(font) painter.drawText(pixmap.rect(), Qt.AlignmentFlag.AlignCenter, emoji) painter.end() self.tray.setIcon(QIcon(pixmap))要注意托盘图标不是一个完整的桌面小组件,如果你想要常驻桌面左下角那种 GUI 小组件效果(像 Widgets 那样的),需要另走无边框置顶窗口或者透明窗体风格的路线。那是一个比较大的工程方向,目前这版先不做。
5. 打包分发和一版进步的空间
5.1 PyInstaller 打包,体积控制在可接受范围
开发完成后,打包成 exe 给朋友用是最后一步。我用的是 PyInstaller,命令行如下:
pip install pyinstaller pyinstaller -w -F --name WeatherDesk --hidden-import PySide6.QtSvg main.py参数解释一下:-w表示打包成窗口程序而不是控制台程序,这样运行的时候不会弹出黑色命令行窗口;-F表示打包成单个 exe 文件,发给别人最方便;--name指定程序名;--hidden-import是 PySide6 偶尔需要显式带上的一个隐藏模块,不加的话部分环境下会报 SVG 相关错误。
由于 Qt 库本身很大,单文件打包后的体积通常会在 70MB 到 90MB 之间,对现代磁盘和网络来说还能接受。网上有很多"Pyside6 打包瘦身"的教程,原理主要靠--exclude-module排除不必要的模块,但瘦身容易引入运行时的缺失问题。我建议第一版直接默认打包,能跑起来再说优化。
配置文件这块,我放弃往 exe 旁边写文件。原因很现实:如果别人把你的 exe 放在了受保护的目录,或者使用快捷启动,程序就没有权限在可执行文件旁边创建 JSON。我会把配置写到用户目录下的独立文件夹:
import json from pathlib import Path CONFIG_DIR = Path.home() / ".weather_desk" CONFIG_FILE = CONFIG_DIR / "config.json" def save_config(config): CONFIG_DIR.mkdir(exist_ok=True) CONFIG_FILE.write_text(json.dumps(config, ensure_ascii=False, indent=2), encoding="utf-8") def load_config(): if not CONFIG_FILE.exists(): return {"cities": []} return json.loads(CONFIG_FILE.read_text(encoding="utf-8"))这样无论把 exe 放在哪里,配置文件都不会丢,卸载时也容易清理。
5.2 异常处理:弱网环境是应用测试最好的试金石
多说一点异常处理。天气应用看起来简单,实际上非常依赖网络的稳定性。我在开发过程中专门把电脑断网测试了一遍,发现两个问题。
第一个是超时设置。requests 默认没有超时上限,如果 DNS 解析卡住或者请求被墙,应用会一直等下去,线程永远不会返回。必须给每个请求加上timeout参数,我设置为 8 秒,超过就抛异常并显示错误状态。
第二个是接口返回的非 200 状态码。城市名不存在时,OpenWeatherMap 会返回 404,直接请求当前天气接口会拿到{"cod": "404", "message": "city not found"}。如果不检查状态码,代码很有可能因为缺字段报KeyError,然后整个应用的界面就崩了。我在weather_api.py里加了统一的响应检查,所有非 200 状态都转成带明确提示的异常,界面层捕获后展示——"未找到该城市,请检查拼音拼写"。
这一步不一定能在开发时充分暴露,建议你无论如何都要模拟一次断网和多城市测试。桌面应用给自己用的时候,能容忍各种小毛病,但一旦发给朋友,对方遇到一次崩溃就可能直接删除,口碑瞬间崩掉。
5.3 这套代码后续还能怎么扩展
如果你和我一样打算把第一版跑通之后再叠加功能,以下方向都不用改动太多代码。
- 加入 5 天预报甚至 24 小时逐小时预报。OpenWeatherMap 提供
/data/2.5/forecast接口,数据格式和当前天气类似,只是多了一个时间戳数组。我建议用列表控件加横向滑动卡片来展示。 - 加入条件提示。根据温度、风力、天气代码组合出更直接的决策建议,比如"高温超过 35°C,注意防晒""夜间有雨,出门记得带伞"。这个逻辑本质上是规则引擎,可以做得很简单,也可以做成可配置的。
- 加入系统级快捷键。Qt 里可以注册全局快捷键,即使窗口不在前台,按下组合键也能呼出天气窗口。这对桌面工具来说是很大的效率提升。
- 不同城市用不同背景色或主题。晴天的窗口背景用暖色调,下雨用冷色调,让整个应用在视觉上"呼吸"起来。
第一版能做到"查得快、显示清、不拖沓"就满足了我的核心诉求。桌面应用最重要的不是堆砌功能,而是尊重用户桌面空间,别人愿意把你这东西放在电脑里,就是最好的反馈。我这个项目虽然技术含量不算高,但做完之后用着是真顺手,早上打开电脑扫一眼,心里就有数了。