ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Python+PySide6手把手开发桌面天气应用:从API接入到打包发布

Python+PySide6手把手开发桌面天气应用:从API接入到打包发布 说句实在话天气预报这个需求手机App早就做得很好了为什么我还要折腾一个桌面版因为在电脑前工作的时候用手机查天气实在太割裂了要解锁、找到App、等加载、看完了再锁屏放下。一天重复三五次烦躁感是实实在在的。后来我干脆用 Python 写了一个桌面天气应用开机自启、常驻桌面抬头就能看到当前温度和今天会不会下雨。这篇文章就是把我从选型到编码再到打包的完整过程记录下来包括踩过的坑和排查思路给想动手做一个桌面小工具的朋友做个参考。这个项目涉及的关键点很集中桌面 GUI 框架选择、天气数据 API 接入、界面布局设计、定时刷新机制以及开箱即用的打包发布。我会把每一步的取舍逻辑说清楚尽量让没有写过桌面应用的朋友也能跟着复现。1. 项目背景与需求拆解桌面天气应用能解决什么问题1.1 三个痛点决定了这个项目值得做第一个痛点是信息获取效率低。手机天气 App 的信息密度其实很够但放在手机里就注定不能“常驻”。微信里聊天、编译代码、写文档这些场景都在电脑前眼睛盯着屏幕的时候手机反而成了另一个需要转移到视线和手指的设备。键盘上敲了一行指令后抬头扫一眼屏幕角落这个动作比滑开手机快得多。第二个痛点是通知干扰多。手机天气 App 的通知推送经常是一天好几条今天是“紫外线强”、明天是“空气轻度污染”看似贴心但大多数推送和当下场景无关。桌面应用不一样我可以自己定义什么时候刷新、要不要悬浮显示、显示哪些字段信息主权掌握在自己手上。第三个痛点是可定制性。市面上主流的桌面天气工具要么带广告、要么皮肤丑、要么功能多到冗余。自己写的版本界面想做成什么样就什么样——我就想要一个纯粹的“温度 天气现象 未来几小时降雨概率”的小面板不要那些没用的“生活指数”“穿衣建议”。1.2 目标用户与使用场景这个应用适合三类人长时间在电脑前工作的人比如程序员、设计师、在线客服希望在工作的间隙快速感知天气变化对隐私和数据推送比较敏感的用户不想让手机上的天气 App 保留你家的定位历史想练手桌面开发的初学者天气应用是典型的“麻雀虽小五脏俱全”项目涉及网络请求、JSON 解析、GUI 布局、后台定时任务、打包分发覆盖了很多桌面开发的基本功。使用场景也很明确开机后自动启动常驻系统托盘当前城市的天气数据定时刷新。需要看详细预报的时候点开主窗口不需要的时候就让它安静待在托盘里。2. 技术选型对比为什么最后选了 Python PySide62.1 四条技术路线我挨个试过桌面应用的技术栈选择很多我实际试过四种方案也看过不少人的实现这里直接给对比表格技术方案开发效率打包体积内存占用适合程度Python PySide6高中等约80-150MB中等非常适合快速开发Electron高偏大普遍150MB高界面花哨但资源占用大Tauri中极小几MB低需要 Rust 环境入门成本高C Qt低中等低性能最好但编码慢、心智负担大我最终选择 Python PySide6核心原因是开发效率和界面效果的平衡点最好。PySide6 是 Qt 官方的 Python 绑定能用 Python 写完整的原生桌面应用支持样式表QSS做出来的界面观感远超 Tkinter又不用像 Electron 那样背上整个 Chromium 的包袱。Electron 我也确实做过一版UI 用 HTML/CSS 写起来特别顺手可打包出来 180MB 的体积和常驻 300MB 以上的内存让我这个轻度工具型应用显得非常臃肿。Tauri 体积确实诱人但我当时没有 Rust 环境为了写个天气工具还得先配一套编译环境学习成本不划算。C/Qt 是我最早考虑的方案可想到要手写信号槽和内存管理还是放弃了——工具又不是生产项目研发速度才是第一位。2.2 天气数据源的选择和风天气免费版就够用天气应用的重点是数据。国内能用的天气 API 不少我对比了心知天气、高德天气、和风天气等几家最后选了和风天气对个人开发者最友好。它的免费版支持每日请求量配额对自用来说完全够返回的 JSON 结构清晰、字段齐全还提供了独立的地理编码 API可选城市多中国县级以上城市基本全覆盖。注册后在控制台创建一个项目拿到一个 API Key 就能用不用签合同也不用绑卡。如果你在国际环境或者有更稳的网络诉求OpenWeatherMap 也可以数据结构类似就是免费版请求频率更松一些。我的代码最后封装了一层数据客户端切换数据源就改一个类的事有兴趣的可以顺手扩展。3. 手把手实现从 API 接入到界面渲染的完整过程3.1 第一步接入地理编码与天气数据接口和风天气的接入流程分两步先用地理位置 API 把城市名转成经纬度再用经纬度拉天气数据。为什么要绕一下因为天气接口要求location参数支持经纬度或城市 ID直接传城市名虽然也支持但不精确——全国叫“城关镇”的地方多了去了不查经纬度很容易拿错数据。下面这段是我的数据请求封装注意我设置了超时时间和返回码校验import requests class WeatherClient: def __init__(self, api_key): self.api_key api_key self.geo_base https://geoapi.qweather.com/v2 self.weather_base https://devapi.qweather.com/v7 self.timeout 8 def get_location(self, city_name): params { location: city_name, key: self.api_key, number: 1 } resp requests.get(f{self.geo_base}/city/lookup, paramsparams, timeoutself.timeout) data resp.json() if data[code] ! 200: raise RuntimeError(f地理编码失败: {data[code]}) loc data[location][0] return { lat: loc[lat], lon: loc[lon], name: loc[name], id: loc[id] } def get_now(self, loc_id): params {location: loc_id, key: self.api_key} resp requests.get(f{self.weather_base}/weather/now, paramsparams, timeoutself.timeout) data resp.json() if data[code] ! 200: raise RuntimeError(f天气数据请求失败: {data[code]}) now data[now] return { temp: int(float(now[temp])), feels_like: int(float(now[feelsLike])), text: now[text], wind_dir: now[windDir], wind_scale: now[windScale], humidity: now[humidity], precip: now[precip], update_time: data[updateTime] } def get_7days(self, loc_id): params {location: loc_id, key: self.api_key} resp requests.get(f{self.weather_base}/weather/7d, paramsparams, timeoutself.timeout) data resp.json() daily_list [] for item in data.get(daily, []): daily_list.append({ date: item[fxDate], temp_min: int(float(item[tempMin])), temp_max: int(float(item[tempMax])), text_day: item[textDay], text_night: item[textNight], precip: item[precip] }) return daily_listrequests库的timeout参数非常关键默认是不限制等待时间的最坏情况是界面卡死几十秒。我在实际使用中把超时设为 8 秒超过时间直接抛异常界面层捕获后显示“网络开小差了”。细心的朋友会发现我只在get_location里传了number1这是为了限制返回结果数量。比如搜“北京”会返回好几个行政区取第一个就默认是直辖市的主城区够用了。3.2 第二步主窗口 UI 布局与卡片设计界面我用的是纯 QSSQt样式表实现的卡片风整体布局从上到下分成三段当前天气大卡片、7 天预报卡片列表、底部状态栏。宽度固定为 420 像素高度自适应这个比例在桌面上不会抢视线信息观感也舒服。主窗口代码的结构大概是这样的from PySide6.QtWidgets import (QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QLabel, QFrame, QPushButton, QSystemTrayIcon, QMenu, QLineEdit) from PySide6.QtCore import Qt, QTimer from PySide6.QtGui import QIcon, QAction class WeatherWindow(QMainWindow): def __init__(self, client): super().__init__() self.client client self.current_location_id None self.setWindowTitle(桌面天气) self.setFixedWidth(420) self._build_ui() self._build_tray() self._start_timer() def _build_ui(self): central_widget QWidget() self.setCentralWidget(central_widget) root QVBoxLayout(central_widget) root.setContentsMargins(16, 16, 16, 16) root.setSpacing(12) # 搜索行 search_row QHBoxLayout() self.city_edit QLineEdit() self.city_edit.setPlaceholderText(输入城市名回车搜索) self.city_edit.returnPressed.connect(self.on_search) search_row.addWidget(self.city_edit) root.addLayout(search_row) # 当前天气大卡片 self.current_card QFrame() self.current_card.setObjectName(currentCard) card_layout QVBoxLayout(self.current_card) self.temp_label QLabel(--°C) self.temp_label.setObjectName(tempLabel) self.text_label QLabel(--) self.extra_label QLabel() card_layout.addWidget(self.temp_label) card_layout.addWidget(self.text_label) card_layout.addWidget(self.extra_label) root.addWidget(self.current_card) # 7天预报列表 self.days_container QVBoxLayout() root.addLayout(self.days_container) # 底部刷新信息 self.status_label QLabel(等待数据加载...) self.status_label.setObjectName(statusLabel) root.addWidget(self.status_label)QSS 我单独写了一个style.qss文件运行时会加载进来。卡片样式很朴素圆角 浅底色 适当留白白底深字没有花哨渐变。这个设计对文字可读性最好屏幕在阳光下和暗光环境都不费眼。QFrame配合setObjectName是 PySide6 样式设置的关键习惯。不用 objectName 的话QSS 很难精确选中某个控件只能全局乱刷。给组件起好名字后面调样式你才知道是在改哪一块。3.3 第三步数据渲染与定时刷新机制界面拿到数据后怎么把数据填进去我这里直接生成 7 天的卡片而不是预先写死 7 个 QLabel。因为城市切换时数据完全变化动态生成比隐藏/显示更干净也方便以后扩展。def render_current(self, data): self.temp_label.setText(f{data[temp]}°C) self.text_label.setText(f{data[text]} 体感 {data[feels_like]}°C) self.extra_label.setText( f湿度 {data[humidity]}% | {data[wind_dir]} {data[wind_scale]}级 ) def render_days(self, days): while self.days_container.count(): item self.days_container.takeAt(0) widget item.widget() if widget: widget.deleteLater() for day in days: row QFrame() row.setObjectName(dayCard) layout QHBoxLayout(row) date_label QLabel(day[date]) temp_label QLabel(f{day[temp_min]}°C ~ {day[temp_max]}°C) text_label QLabel(day[text_day]) layout.addWidget(date_label) layout.addStretch() layout.addWidget(text_label) layout.addWidget(temp_label) self.days_container.addWidget(row)定时刷新我用的是QTimer每 30 分钟拉一次数据。这个频率对天气来说完全足够——气象数据更新的速度不会比这更快拉太频繁反而容易触发 API 的请求频率限制。def _start_timer(self): self.timer QTimer(self) self.timer.timeout.connect(self.refresh) self.timer.start(30 * 60 * 1000) # 30分钟 def refresh(self): if self.current_location_id is None: return try: now self.client.get_now(self.current_location_id) days self.client.get_7days(self.current_location_id) self.render_current(now) self.render_days(days) self.status_label.setText(f更新于 {now[update_time]}) except Exception as exc: self.status_label.setText(f刷新失败: {exc})这里有个细节值得说异常要接住但不要用弹窗干扰用户。桌面工具型应用网络闪断太正常了刷新失败更新一下状态栏文字就够了弹窗反而打断工作流。3.4 第四步城市搜索与系统托盘城市搜索绑定在输入框的returnPressed信号上回车触发搜索。拿到城市名后先调用地理编码接口再把返回的location_id存下来之后所有天气请求都用这个 ID。def on_search(self): city self.city_edit.text().strip() if not city: return try: loc self.client.get_location(city) self.current_location_id loc[id] self.setWindowTitle(f桌面天气 - {loc[name]}) self.refresh() except Exception as exc: self.status_label.setText(f城市查询失败: {exc})系统托盘的实现是另一个亮点。应用关掉主窗口后不应该退出而是缩到托盘里def _build_tray(self): self.tray QSystemTrayIcon(self) self.tray.setIcon(QIcon(weather.ico)) menu QMenu() show_action QAction(显示主窗口, self) show_action.triggered.connect(self.show_and_raise) quit_action QAction(退出, self) quit_action.triggered.connect(QApplication.quit) menu.addAction(show_action) menu.addAction(quit_action) self.tray.setContextMenu(menu) self.tray.setToolTip(桌面天气) self.tray.activated.connect(self.on_tray_clicked) self.tray.show() def on_tray_clicked(self, reason): if reason QSystemTrayIcon.ActivationReason.Trigger: self.show_and_raise() def show_and_raise(self): self.show() self.raise_() self.activateWindow()缩放托盘需要在closeEvent里拦截def closeEvent(self, event): event.ignore() self.hide() self.tray.showMessage(桌面天气, 已最小化到托盘双击图标恢复, QSystemTrayIcon.Information, 2000)这个逻辑对工具型应用很重要不需要每次打开都重新加载数据托盘常驻才是桌面工具的终极形态。对了第一次写的时候我漏掉了closeEvent重写导致点 X 后进程直接退出托盘图标也留不住——这个问题后面很多人也会遇到我专门放在第 4 部分讲。3.5 第五步开机自启的两种做法开机自启Windows 下最稳的是写注册表启动项。原理是往HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Run下加一个键值为可执行文件路径。import winreg def set_autostart(enabled): key_path rSoftware\Microsoft\Windows\CurrentVersion\Run app_name DesktopWeather exe_path sys.argv[0] if enabled: with winreg.OpenKey(winreg.HKEY_CURRENT_USER, key_path, 0, winreg.KEY_SET_VALUE) as key: winreg.SetValueEx(key, app_name, 0, winreg.REG_SZ, exe_path) else: try: with winreg.OpenKey(winreg.HKEY_CURRENT_USER, key_path, 0, winreg.KEY_SET_VALUE) as key: winreg.DeleteValue(key, app_name) except FileNotFoundError: pass如果你不想碰注册表也可以让用户手动按Win R输入shell:startup把快捷方式丢进启动文件夹。实测下来改注册表对无桌面环境比如某些精简系统可能失败启动文件夹方案兼容性最好。我的做法是把两个入口都做了界面上有个“开机自启”勾选框默认用注册表方案如果注册表写失败就在启动文件夹里创建快捷方式。4. 踩坑记录API、时区与打包的经典问题4.1 天气 API 请求失败返回码与 HTTP 状态码傻傻分不清和风天气的接口返回里有个code字段一开始我以为是 HTTP 状态码顺手就写成了判断resp.status_code 200。结果发现有时候 HTTP 是 200业务code却是403比如 API Key 无效、权限不足。后来我改成两层校验先检查resp.status_code 200再检查data[code] 200。另外有个容易忽略的点和风天气免费版默认请求量是有限额的短时间高频访问会返回402或者429。我把刷新间隔设成 30 分钟配合缓存城市搜索结果实际运行一天请求不到一百次距离配额远得很但如果你在调试阶段反复搜索城市就会一不小心触发限流。我踩过一次之后在get_location里加了缓存字典同一个城市名 24 小时内只查一次。4.2 预报日期差一天时区与缓存策略开发中我发现一个诡异现象晚上的时候7 天预报列表里的第一天总是显示昨天。排查了半天真相是 API 返回的updateTime是 UTC 时间而我本地界面显示的日期字段直接用了fxDate这个字段本身没问题是正常的东八区日期。出问题的其实是 QWeather 免费版对“昨天”的数据缓存请求7d接口时如果本地时间处于当天凌晨接口可能返回前一日起始的数据窗口。解决思路不是改日期字段而是约定以接口返回的fxDate为准不多做时区转换。fxDate本身就是按照当地时间生成的日期字符串你只需要原样展示。如果你用的是 OpenWeatherMap就要注意它的dt是时间戳必须加上 UTC 偏移量才能得到本地日期两家的字段设计差异不小。4.3 PyInstaller 打包体积过大与资源丢失打包这一步我踩的坑最瓷实。用 PyInstaller 的默认命令打出来的包有 130MB其实大部分是 PySide6 的 Qt 库文件。优化方法有两个第一用--exclude-module排除不用的模块。PySide6 附带了很多你用不到的 Qt 子模块可以排除QtWebEngineCore、QtQuick、QtMultimedia等。但要注意排除多了会跑不起来建议每次改动后用命令行跑一下主程序测试。第二把资源文件QSS、图标从外部文件改成内嵌。直接打包时 QSS 文件是作为外部资源随包发布的如果你启动了--onefile模式运行时临时解压目录和原目录不一样QSS 经常加载不到。我最后用PySide6自带的rcc工具把 QSS 和图标编译成.py资源模块彻底解决了路径问题。小的坑是--windowed或-w参数必须加上否则打包后的应用会带个黑色控制台窗口。而我第一次打包时图标用的是.ico但没设置QApplication.setWindowIcon导致任务栏和托盘图标一直是默认企鹅图标。打包命令我放在这里供直接参考pyinstaller -w --onefile --iconweather.ico main.py4.4 高 DPI 缩放下字体模糊现在高分屏很普及PySide6 在 Windows 下如果不设置高 DPI 策略文字和控件会明显发虚。在main.py最顶部加上这两行代码必须在创建QApplication之前执行import os os.environ[QT_ENABLE_HIGHDPI_SCALING] 1如果加完发现字体太大变形可以改成from PySide6.QtCore import Qt QApplication.setHighDpiScaleFactorRoundingPolicy( Qt.HighDpiScaleFactorRoundingPolicy.PassThrough )这两行分别解决“模糊”和“缩放比例不对”两个问题。我自己在 2K 分辨率的笔记本上实测不加策略字体虚得像隔层纱加上之后才正常。4.5 常见问题速查表问题现象关键原因解决办法HTTP 200 但 code 非 200业务层报错如 API Key 无效两层校验读取data[code]城市搜索返回错误数据同名城市过多加number参数限制必要时弹候选列表点关闭后应用退出没有重写closeEvent事件忽略并hide()保留托盘打包后字体图标丢失外部资源路径变化用 Qt 的 rcc 编译资源为.py屏幕缩放时界面模糊没启用高 DPI 策略设置QT_ENABLE_HIGHDPI_SCALING长时间挂机后数据不更新网络休眠导致定时器静默失败定时刷新加异常捕获并显示状态4.6 一个小技巧把天气现象文本映射成本地图标和风天气返回的天气现象是中文文本比如“晴”“多云”“小雨”直接用文本展示太单调。我建立了一个映射表把文本对应到 Unicode 符号或本地图标文件。做映射表时有个经验不要按所有天气类型一个个映射先只处理你所在城市最常见的 10 来种比如晴、多云、阴、小雨、中雨、大雨、雷阵雨、雪剩下的给一个默认“不明”图标。因为对个人自用工具来说覆盖 90% 的天气场景就够了。WEATHER_ICON { 晴: sunny.png, 多云: cloudy.png, 阴: overcast.png, 小雨: light_rain.png, 中雨: moderate_rain.png, 大雨: heavy_rain.png, 雷阵雨: thunderstorm.png, 小雪: light_snow.png, 中雪: moderate_snow.png, 大雪: heavy_snow.png } def map_weather_icon(text): return WEATHER_ICON.get(text, unknown.png)这个映射表看起来简单但真实使用中“晴间多云”“多云转阴”这类复合天气也经常出现所以最好加一个包含匹配逻辑比如判断雨 in text就走雨天分支。我在render_current里对文本做了二次处理效果比精确匹配好很多。5. 项目结构与文件组织建议代码写多了之后模块划分就成了维护效率的分水岭。我的项目文件结构是这样的给你参考desktop-weather/ ├── main.py # 入口加载QSS创建主窗口 ├── weather_client.py # API请求封装 ├── main_window.py # 主窗口和托盘逻辑 ├── autostart.py # 开机自启注册表/快捷方式 ├── icons/ │ ├── weather.ico │ ├── sunny.png │ └── ... └── resources/ └── style.qss模块划分的核心原则是数据层和界面层分离。weather_client.py里只有请求和解析不出现任何 GUI 引用main_window.py只负责展示和交互不直接拼 URL。这样以后想加一个命令行版本或者网页版本数据层代码可以原封不动搬过去。我也见过不少项目把所有代码堆在一个文件里初始版本确实快但一旦要加功能比如再加一个湿度趋势图就要开始痛苦地拆分了。拆分的成本其实不高趁早分模块反而是省时间。写在最后的一点体会这个桌面天气应用从立项到稳定使用大概花了我一个周末。再回头看真正花时间的不是 API 接入也不是界面布局而是处理各种“看起来是小问题但在特定环境下才会暴露”的坑——时区、托盘生命周期、高 DPI 缩放、打包路径。这些经验写出来只是一段话排查的时候都是逐个试出来的。如果让我重新做一次我会在一开始就用QTimer做节流控制而不是等 API 限流了再补救也会更早想清楚“要不要托盘常驻”这个问题因为这决定了整个窗口生命周期写法的走向。这些小决定看着不起眼却最影响开发体验。最后分享一个小技巧程序写好之后我把它放在了一个开发机的副屏角落每天工作时间都开着连续跑了两个月没崩过一次。桌面小工具的意义大概就是这种——平时你注意不到它的存在但它稳定地服务着你的日常。如果你也想动手做点桌面工具天气应用是个特别合适的选择希望这篇文章能让你少踩几个坑。
返回列表