ARTICLE DETAIL

资讯详情

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

PyQt5实战:从零开发桌面天气预报应用的完整指南

PyQt5实战:从零开发桌面天气预报应用的完整指南 “桌面版”的搜索热度一直不低评论区隔三差五就有人问这类应用怎么做但大部分教程要么停在“能出个黑框窗口”要么直接甩给你几十个依赖包让新手当场劝退。这篇文章算是把我自己从零到一做完这个小工具的全过程复盘一遍包括我为什么弃用网页方案、为什么选择 PyQt5 而不是 Electron以及 API 接入、异步刷新、打包分发这些真实项目中绕不开的坎。1. 为什么是桌面版三个真实痛点与方案选型聊方案之前先说清楚我为什么要折腾一个桌面版本。纯粹从“获取天气信息”这个需求出发网页版和手机 App 确实都能满足。但作为一天十个小时坐在电脑前的开发者我遇到的实际问题有三个第一写代码写得正专注的时候掏手机解锁再滑到天气 App这个操作太重了直接打断心流第二浏览器里搜天气虽然快但页面里塞满了无关内容我需要三秒内看到“温度、湿度、风速、是否要带伞”这四个信息而不是先跟广告和推广位作斗争第三我经常要同时关注两个城市的天气一个是自己所在的城市另一个是老家那边来回切换网页的成本比想象中高得多。桌面版的好处在于它是一个常驻角落的小面板启动后不抢焦点想起来了瞥一眼就行它以“信息密度”为核心没有任何多余的东西而且它可以常驻托盘配合定时刷新真正做到“被动获取信息”。接下来是选型。桌面应用的常见方案有这么几个我做了一个简单粗暴的对比方案上手成本界面表现力打包体积跨平台是否适合这个项目Tkinter极低朴素控件风格偏旧小好适合练手不适合做“好看”的成品PyQt5 / PySide2中等强QSS 可定制控件丰富约 30-50MB好推荐灵活度和观感都够Electron较高很强前端自由的极致普遍 100MB 以上好性能浪费偏大内存占用高Tauri较高很强Web 前端渲染小好需要 Rust 环境偏重我最后选了 Python PyQt5。原因很直接我对 Python 最熟业务代码量不大PyQt5 的 QSS 机制让我可以像写 CSS 一样定制界面不需要额外开前端工程。而且 PyQt5 的生态成熟网上遇到的绝大多数问题都有现成答案打包虽然会到 30-40MB但比起 Electron 动辄上百兆稍轻一点。Tkinter 我也试过写代码是快但那个原生控件的外观在这个项目上实在撑不起“桌面应用”的质感遂放弃。数据源方面天气数据 API 我用的是和风天气QWeather。选它的理由有三点一是国内访问稳定不需要额外的网络环境二是它同时提供城市搜索GeoAPI和实时天气、逐日预报等接口一个平台全部覆盖三是免费额度对我这种个人使用场景完全够用注册后创建一个控制台项目就能拿到 API Key。如果你偏好国际平台也可以考虑 OpenWeatherMap但要注意它的请求限制和部分地区访问的稳定性个人项目里并不比和风天气更好用。2. 可维护的骨架模块划分与数据流链路很多新手做小工具习惯把所有代码塞进一个 main.py比如界面控件、网络请求、解析逻辑全搅在一起跑通没问题但一旦出现了一个网络异常或者想加一个新功能整个文件改起来非常痛苦。我这次从一开始就分成几个模块结构很小但边界清晰后面每一步改动都省了很多心。项目结构长这样weather-desktop/ ├── main.py # 程序入口负责创建 QApplication 和主窗口 ├── config.py # 配置文件存放 API Key、默认城市、刷新间隔 ├── api/ │ ├── __init__.py │ ├── geo.py # 城市搜索城市名 - LocationID │ └── weather.py # 实时天气与逐日预报请求、响应解析 ├── ui/ │ ├── __init__.py │ ├── main_window.py # 主窗口布局和交互逻辑 │ └── widgets.py # 自定义卡片控件、天气图标控件 └── requirements.txt这个划分的逻辑是api 模块只负责“拿到数据并整理成干净的对象”完全不知道界面长什么样ui 模块只负责“把数据画出来”不关心数据从哪来config.py 把 API Key、默认城市这类易变的配置集中管理。这样一来未来想换数据源只需要改 api 层想重新设计界面只需要改 ui 层。数据流链路是这条线用户输入城市名 - 调用 geo.py向 GeoAPI 发起城市搜索 - 得到匹配城市的 LocationID - 用 LocationID 调用 weather.py请求实时天气与 3 天预报 - 返回 JSON封装成统一的 WeatherData 对象 - 通过 Qt 信号发送到主线程 - 主窗口更新温度、天气文本、风向风力、未来三天卡片很多教程只会告诉你“调接口、解析 JSON、塞进 Label”但实际项目里最容易翻车的是中间那几步城市名是中文请求 URL 要不要编码多个同名城市怎么选请求超时怎么办线程回调时窗口已经关了怎么办这些我都会在后面展开讲。这里有个关键经验不管数据源返回的字段多简单我都建议在 api 层做一次统一封装。比如和风天气返回的温度是字符串25天气代码是数字100你直接把字典扔给 UI 层当然也能跑但万一某天接口字段改名了、或者返回了异常值你会在界面层面对一堆魔法字符串毫无头绪。用一个 dataclass 接住响应字段不合法时立刻抛错整个链路会稳非常多。3. 核心链路实现城市搜索、请求封装与卡片式 UI这一章直接上代码。我按“先取城市编码再取天气数据最后渲染界面”的顺序来讲。3.1 城市搜索LocationID 的获取与匹配和风天气的 GeoAPI 接口是GET https://geoapi.qweather.com/v2/city/lookup核心参数就两个location是城市名或经纬度key是 API Key。返回结果中每个城市对应一个id后续所有天气请求都用这个 id 作为位置标识。有人可能会问为什么不直接用城市名请求天气因为城市名重名的情况很多比如“高新区”“鼓楼区”遍地都是而城市 id 是全局唯一且稳定的所以正式思路一定是“先搜索、再拿 id、最后查天气”。请求代码很简单import requests def lookup_city(name: str, api_key: str) - list[dict]: resp requests.get( https://geoapi.qweather.com/v2/city/lookup, params{ location: name, key: api_key, }, timeout5, ) payload resp.json() if payload.get(code) ! 200: return [] results [] for item in payload.get(location, []): results.append({ name: item[name], id: item[id], adm1: item.get(adm1, ), adm2: item.get(adm2, ), }) return results这里最容易踩的坑是编码。我最初用字符串拼接 URL 的方式直接把中文城市名拼进地址里结果部分环境下请求直接失败。后来改用requests的params参数让库内部自动做 URL 编码问题就消失了。如果你手痒喜欢自己拼 URL记得先对中文做urllib.parse.quote。返回结果里的adm1是省份或直辖市名adm2是地级市这两个信息在展示搜索结果时非常有用。比如搜索“南京”你希望在列表里区分“江苏省南京市”和某个同名的地方通过adm1展示省名就够了。3.2 天气请求封装超时、错误码与统一对象实时天气和 3 天预报在和风天气里是两个接口实时天气GET https://devapi.qweather.com/v7/weather/now3 天预报GET https://devapi.qweather.com/v7/weather/3d两者的参数格式几乎一样都是location和key。我把它们封装在同一层并统一转成WeatherData对象。from dataclasses import dataclass, field dataclass class WeatherData: temp: str # 当前温度 icon: str # 天气代码 text: str # 天气现象描述 wind_dir: str # 风向 wind_scale: str # 风力等级 humidity: str # 相对湿度 pressure: str # 大气压强hPa forecast: list field(default_factorylist) # 未来三天数据这个对象的每个字段都是字符串或列表因为天气 API 返回的基础字段本来就不适合数字运算保持字符串展示是最稳的。你一定不要在这里做花哨的类型转换比如把温度转成 float反而引入不必要的报错点。请求函数核心逻辑如下def fetch_now(api_key: str, location_id: str) - dict: resp requests.get( https://devapi.qweather.com/v7/weather/now, params{location: location_id, key: api_key}, timeout5, ) payload resp.json() if payload.get(code) ! 200: raise RuntimeError(f天气请求失败: {payload.get(code)}) now payload[now] return { temp: now[temp], icon: now[icon], text: now[text], wind_dir: now[windDir], wind_scale: now[windScale], humidity: now[humidity], pressure: now[pressure], }有几个细节值得说。第一code字段在 JSON 里是字符串200所以比较时不要写成整数200否则会一直走失败分支。第二必须设timeout不设的话一旦网络异常请求可能挂几十秒而用户在桌面上看到的是整个界面无响应。第三错误处理抛异常而不是返回 None这样上层可以根据异常类型做不同的用户提示。3.3 UI 组装卡片式布局与信息层级界面我采用固定宽度 360 像素的窄面板设计放在屏幕右下角不遮挡主显示器的大部分区域。整个窗口从上到下分四块城市搜索框、当前天气区、未来三天卡片区、刷新状态栏。关键控件的组装逻辑如下# 当前温度大号字 self.temp_label QLabel(--°C) self.temp_label.setStyleSheet(font-size: 64px; font-weight: bold; color: #1a1a1a;) # 天气现象中等字号 self.text_label QLabel(--) self.text_label.setStyleSheet(font-size: 20px; color: #333333;) # 湿度、风向、气压统一用小字号灰色 self.detail_label QLabel(湿度: -- | 风向: -- | 气压: --) self.detail_label.setStyleSheet(font-size: 13px; color: #666666;)页面布局用QVBoxLayout从中间分成主区域和下方卡片区卡片区则用QHBoxLayout放三张 QFrame。每张未来三天卡片只展示三个信息星期几、天气代码对应的文本、最高/最低温度。我给这些卡片统一了 QSS 样式QFrame#weatherCard { background-color: #f5f7fa; border-radius: 12px; padding: 8px; }QSS 是 PyQt5 里最像前端体验的部分调整圆角、内边距、背景色非常直观。但要注意QSS 里写的选择器要和你代码里的setObjectName完全一致否则样式不生效。比如卡片要设置card.setObjectName(weatherCard)CSS 里用QFrame#weatherCard才能命中。3.4 天气代码映射与显示策略和风天气返回的icon字段是数字代码比如 100 表示晴101 表示多云305 表示小雨。我建了一张映射表把代码转换为中文描述同时决定当前天气区域的整体配色偏向icon 代码天气现象展示策略100晴明亮色系主背景偏白101多云中性色系104阴灰色调300阵雨冷色调提示“出门带伞”305小雨冷色调306中雨冷色调307大雨深色强调提示“减少外出”400小雪浅蓝冷色调407雨雪中性偏冷这个映射我放在api/weather.py里作为纯函数导出。UI 层拿到WeatherData后根据icon查表获取展示文案和配色而不是在界面代码里写一堆 if-else 判断。如果你想免除图标文件的管理也可以直接用文字“晴”“多云”“小雨”代替虽然视觉上朴素一点但信息传递足够清晰。4. 界面不冻死的秘密QThread 异步刷新与信号槽这一章是整个项目里最值得单独拎出来说的部分也是桌面应用和普通脚本的分水岭。4.1 为什么直接 requests 会让界面卡死很多第一次写 PyQt5 的人都会犯同一个错误在主线程里直接调requests.get()。表面上看窗口能弹出来点击按钮后数据也能显示但如果此时网络延时超过两秒整个窗口就会像“死掉”一样无法拖动、无法响应。原因是 Qt 的 GUI 事件循环是单线程的主线程一旦被阻塞的网络请求占住界面绘制和鼠标事件全部排队等待表现就是无响应。而天气请求恰恰是那种“平时快偶尔慢几秒甚至可能超时”的网络操作。所以正确做法是把网络请求放进工作线程完成后通过 Qt 信号把结果传回主线程更新界面。4.2 QThread 最小示例与信号槽写法我封装了一个RefreshWorker继承自QThread在run()方法中执行完整的“取实时天气 取 3 天预报”流程。from PyQt5.QtCore import QThread, pyqtSignal class RefreshWorker(QThread): finished pyqtSignal(dict) failed pyqtSignal(str) def __init__(self, api_key: str, location_id: str, parentNone): super().__init__(parent) self.api_key api_key self.location_id location_id def run(self): try: now_data fetch_now(self.api_key, self.location_id) forecast_data fetch_forecast(self.api_key, self.location_id, days3) self.finished.emit({ now: now_data, forecast: forecast_data, }) except Exception as exc: self.failed.emit(str(exc))在主窗口里这样调用def start_refresh(self, location_id: str): self._current_round 1 self.worker RefreshWorker(self.api_key, location_id) self.worker.finished.connect(self.render_weather) self.worker.failed.connect(self.show_error) self.worker.start()有两点非常重要。第一worker必须存为self.worker如果你只是局部变量线程对象会被 Python 垃圾回收导致程序直接崩溃或者线程根本没跑起来这是新手最常遇到的诡异问题。第二窗口关闭时如果线程还在跑要在closeEvent里做处理def closeEvent(self, event): if self.worker and self.worker.isRunning(): self.worker.quit() self.worker.wait(1000) event.accept()quit()告诉线程退出事件循环wait(1000)最多等它 1 秒避免强制结束时出现崩溃。4.3 竞态处理防止旧请求覆盖新请求那句话怎么说来着“你以为用户只点一下按钮实际上用户会手抖点三下还会同时快速切好几个城市。”如果用户连续输入“南京”“上海”“广州”三个刷新线程可能几乎同时返回最后一个返回的是谁不一定。如果你只是把信号直接连到渲染函数界面就会显示成“搜索广州结果却展示南京的数据”。我的处理方式是引入一个自增轮次号self._current_round 0 def start_refresh(self, location_id: str): self._current_round 1 self._expected_round self._current_round ... def render_weather(self, data): round_no data.get(round) if round_no ! self._expected_round: return # 丢弃过期结果 ...每个新请求产生时记录期望的轮次信号返回时先比对轮次不是最新请求的结果就丢弃。这个小技巧解决了我实际使用中遇到的大多数怪异现象。5. 从“能用”到“好用”异常兜底、托盘驻留与体验细节跑通主干功能只完成了 60%真正让人愿意每天都打开这个应用的是各种边界情况的处理和细节体验。5.1 异常兜底与降级设计网络请求总会失败API Key 也总有填错的时候。我设计了三个层级的兜底未配置 API Key程序启动后检测到 config.py 中的 key 为空界面所有数据位显示“未配置 API Key”并在状态栏给出引导提示同时提供一个简单的配置入口而不是直接抛异常崩溃。请求超时或返回错误码界面保留上一次成功加载的数据状态栏显示“更新失败数据为 20 分钟前”。极端情况比如城市搜索没有结果搜索结果列表为空时明确告诉用户“未找到该城市”而不是静默无反应。为了让“保留旧数据”可行我在主窗口里缓存了最近一次成功的WeatherData。每次刷新失败渲染函数会先用缓存数据填充界面再附加上更新时间。这种“尽力而为”的降级策略虽然简单但实际体感比直接弹错误框好太多了。5.2 系统托盘与最小化策略桌面天气应用要想常驻系统托盘是最自然的存在方式。我用QSystemTrayIcon实现窗口最小化或点击关闭按钮时只是隐藏窗口托盘图标维持运行托盘图标上还可以显示当前温度的 tooltip双击托盘图标恢复主窗口。self.tray_icon QSystemTrayIcon(self) self.tray_icon.setIcon(self.style().standardIcon(QStyle.SP_ComputerIcon)) self.tray_icon.setToolTip(桌面天气) self.tray_icon.activated.connect(self.on_tray_activated)需要注意不同桌面环境下托盘支持程度不同。Windows 上基本没问题部分 Linux 桌面需要安装 AppIndicator 扩展才能正常显示这属于环境限制不用太纠结。另外托盘图标建议在构建时用应用图标覆盖默认的 Computer 图标否则看起来会很不“正式”。5.3 体验级细节刷新频率与默认城市实际使用下来刷新频率设为 30 分钟一次比较合适。太频繁既浪费 API 配额又没必要天气短时间变化幅度没那么大。实现方式是用QTimerself.timer QTimer(self) self.timer.setInterval(30 * 60 * 1000) self.timer.timeout.connect(self.start_refresh) self.timer.start()另外我还做了一个“记住上次城市”的功能每次成功刷新天气后把location_id和城市名写入一个本地 JSON 文件下一次启动时直接读取并恢复免得每天打开都要重新搜索一遍。这个功能代码量很少但对使用体验的提升极其明显几乎可以算是桌面工具“有没有用心”的评判标准之一。6. 交付的最后一公里PyInstaller 打包与常见坑开发环境里跑得好好的不代表别人机器上能跑起来。PyInstaller 是 Python 桌面应用分发的主流方案但打包 PyQt5 项目需要一点额外配置。6.1 PyInstaller 打包 PyQt5 的配置我最终使用的打包命令是pyinstaller -w -F --name WeatherApp --iconapp.ico main.py参数含义-w打包成窗口程序不弹出黑色控制台。-F生成单文件可执行程序。--iconapp.ico设置程序图标。main.py程序入口。如果你的项目引入了其他模块PyInstaller 会递归分析 import 关系不需要手动指定。但 PyQt5 有个特殊情况偶尔需要显式声明隐藏导入pyinstaller -w -F --hidden-import PyQt5.sip --name WeatherApp main.py如果打包后运行报ModuleNotFoundError: PyQt5.sip加这个参数就能解决。6.2 启动速度与体积优化思路单文件模式-F的优点是分发方便一个 exe 直接传给别人就能用缺点是每次启动都要解压到临时目录启动速度明显比目录模式慢部分杀毒软件还会因为自解压行为而误报。如果对启动速度敏感可以改用-D目录模式打包结果是一个文件夹启动快很多代价是分发时要带上整个目录。体积方面PyQt5 打包后普遍在 30-50MB 左右这是 Qt 框架的底子没办法像 Tkinter 那样做到几 MB。你可以用 UPX 压缩减小体积但有些 Windows 环境下会触发杀毒误报我实际选择不压缩、保持稳定性优先。6.3 常见运行时报错排查现象原因处理方式双击 exe 无任何反应程序在初始化阶段崩溃但窗口模式屏蔽了控制台先用不带-w的命令打包观察控制台报错报错缺少 PyQt5.sipPyInstaller 未识别动态导入加--hidden-import PyQt5.sip托盘图标不显示环境缺少图形壳支持或代码未调用show()确认调用了tray_icon.show()Linux 下检查 AppIndicator单文件版启动变慢自解压 杀毒扫描换目录模式或将程序加入白名单打包完成后我建议把 exe 拿到一台没有安装 Python 的干净 Windows 机器上测一遍这是检验打包是否完整的唯一标准。很多“在我电脑上能跑”的典型问题在这种测试下都会暴露。项目做到这一步一个从零开始的桌面天气预报应用已经完整交付能搜索城市、能展示实时天气和未来三天预报、能托盘驻留、能自动刷新、还能打包给同事用。这个项目代码量不大但麻雀虽小五脏俱全网络请求、异步线程、界面刷新、异常兜底、打包发布这些桌面应用的关键环节全都过了一遍而这也是我整理这份复盘文章的真实目的——不只是给一份能跑的代码而是让你遇到同类需求时知道第一步做什么、第二步会踩什么坑、最后怎么体面地把程序交到别人手上。
返回列表