ARTICLE DETAIL

资讯详情

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

PyQt5桌面天气应用开发实战:从API接入到打包发布全流程

PyQt5桌面天气应用开发实战:从API接入到打包发布全流程 最近各种工具都在往桌面端挤AI 编程助手出桌面版、大模型客户端出桌面版GitHub Desktop、Docker Desktop 这些老牌工具更不用说了。这股风潮带火了一个问题桌面端软件到底怎么做我正好花两个晚上搓了一个桌面版天气预报应用从数据源接入到界面搭建再到打包发布完整走了一遍。如果你是刚接触 GUI 开发的新手或者想找个能真正落地使用的小项目练手这篇文章应该能帮你省掉不少弯路。这个项目不算大但涉及的东西很全天气数据源选型、城市编码转换、PyQt5 界面搭建、后台线程刷新、系统托盘驻留、PyInstaller 打包发布一条链路下来桌面应用开发的核心环节基本都覆盖了。下面按我实际的开发顺序展开先讲设计和选型再给核心代码最后把踩坑记录一并交代清楚。1. 动手前先想清楚需求拆解与技术选型1.1 最小可用功能清单与需求权衡很多人做小工具上来就写代码写到一半发现界面、数据、刷新逻辑全搅在一起改一行牵连半天最后项目烂尾。我习惯先列功能清单把必须做和可砍掉分清楚。我的 MVP 清单是这样的实时天气温度、天气现象、体感温度、湿度、风力风向未来 7 天预报每天的最高/最低温度和天气现象城市搜索输入城市名能定位并显示当地天气定时自动刷新避免手动点按钮系统托盘驻留不占任务栏空间在此基础上我明确砍掉了三样东西天气地图、逐小时降水曲线、推送告警。不是做不了而是第一版不需要。桌面工具的定位是看一眼就走信息密度高、打开快、不打扰才是核心体验。这些砍掉的功能如果后续真需要架构上留好扩展点就行没必要第一版就背上一堆包袱。需求权衡这件事其实就是在回答一个问题你的用户会在什么场景下用这个工具对我来说场景就是坐在电脑前写代码时不想掏手机也不想打开网页被广告糊脸。搞清楚场景功能优先级自然就出来了。1.2 桌面技术栈选型Tkinter、PyQt5 还是 Electron桌面 GUI 的技术方案多得很我列一下自己认真对比过的几个方案语言优点缺点适合场景TkinterPython内置库零依赖、上手极快控件老旧、样式丑、复杂布局吃力练手、内部工具PyQt5 / PySide6Python控件丰富、QSS 可定制、信号槽机制成熟包体偏大、学习曲线稍陡正经桌面工具ElectronJS/TS界面上限高、前端生态强内存占用高、包体动辄上百 MB重交互应用Flutter DesktopDart渲染一致性好生态相对年轻、中文资料少跨端统一 UI我最后选了 Python PyQt5。原因有三个第一Python 处理 HTTP 请求和 JSON 解析非常顺手天气应用的核心逻辑不复杂用 Python 能最快把业务逻辑跑通第二PyQt5 的控件和布局系统足够专业做出来的界面不会像 Tkinter 那样一股教学软件的味道第三PyInstaller 对 PyQt5 的打包支持已经很成熟踩坑成本可控。PyQt5 和 PySide6 之间我也犹豫过。PySide6 是 Qt 官方的 Python 绑定许可证是 LGPL 更宽松但说实话 PyQt5 的中文教程、社区问答存量更大遇到问题一搜就有答案。对新手来说资料比许可证更重要所以我选了 PyQt5。如果你要商用分发建议再评估一下 PySide6。1.3 三层架构与线程模型设计小项目也值得讲究结构。我把代码拆成了三层界面层MainWindow 负责窗口布局、控件展示、响应用户操作业务层WeatherService 封装天气数据获取逻辑屏蔽 API 细节数据层直接跟 HTTP API 打交道返回结构化字典界面层不直接写 requests 请求业务层不操作任何控件。这样做的直接好处是换天气数据源的时候只改业务层界面一点不用动想给应用加命令行版本时业务层也能直接复用。线程模型是另一个必须提前想清楚的点。网络请求是阻塞操作如果在主线程里调 requests.get窗口会直接卡死用户拖动窗口、点击按钮都没反应体验极其糟糕。正确的做法是把网络请求丢到 QThread 里执行完成后通过信号把数据传回主线程。这个问题十个人里有九个会踩我后面专门写一节讲。工程目录也顺手定好weather_app/ ├── main.py # 程序入口 ├── api.py # 天气服务封装 ├── ui/ │ ├── __init__.py │ ├── main_window.py # 主窗口 │ └── styles.qss # 样式表 ├── assets/ │ └── app.ico # 应用图标 └── requirements.txt2. 天气 API 对接与界面框架搭建2.1 天气数据源选型与 API 接入细节天气数据源是核心选不好后面全是坑。国内常用的有和风天气、心知天气国际有 OpenWeatherMap我建议首选和风天气。原因很简单免费档位够用、文档清晰、返回的 JSON 结构规整而且城市定位用的是标准行政区划编码对中文城市名支持非常好。和风天气的接入流程是两步先调用城市检索 API把城市名转成 LocationID再用这个 ID 查实时天气和 7 天预报。这跟直觉不太一样很多人一上来就想直接传北京两个字查天气结果发现查不到。为什么要这种设计因为标准 ID 能避免同名城市歧义比如朝阳区在北京和长春都有光靠名字根本分不清。城市检索接口大概是这样的GET https://geoapi.qweather.com/v2/city/lookup?location北京key你的KEY返回结果里取第一条记录的 id 字段比如北京是 101010100。然后拿这个 ID 分别请求实时和预报接口GET https://devapi.qweather.com/v7/weather/now?location101010100key你的KEY GET https://devapi.qweather.com/v7/weather/7d?location101010100key你的KEY注册流程不复杂去和风天气官网注册账号在控制台创建一个项目拿到 API Key个人免费版每天有调用次数限制具体额度以官网说明为准做个人工具绰绰有余。要注意 Key 是敏感信息不要硬编码后公开到 GitHub 上随便一搜就能被薅掉额度。我自己的做法是把 Key 放到项目根目录的 config.ini 里并加进 .gitignore。2.2 主窗口布局与控件组织主窗口我用 QMainWindow 做容器中心部件是一个 QWidget用 QVBoxLayout 纵向堆叠三个区域顶部搜索栏、中部实时天气、底部 7 天预报表格。顶部搜索栏是一个 QLineEdit QPushButton 的组合输入城市名点查询回车键也能触发。中部实时天气区用几个大号 QLabel 排成两行第一行显示温度用 36pt 的大字体突出显示第二行显示天气现象、体感温度、湿度、风力风向这些细节。底部预报区用 QTableWidget7 行 3 列分别是日期、天气现象、温度区间。布局这块有个容易忽视的点窗口缩放时控件要能自适应。如果直接把控件摆死在固定坐标换个屏幕分辨率就崩了。用布局管理器让控件跟随窗口伸缩这才是 Qt 推荐的做法。配色我没有走系统默认灰底而是写了一份简单的 QSS 样式表把窗口背景、文字颜色、按钮圆角都统一了一下QMainWindow { background-color: #f5f6f8; } QLabel#temperature { font-size: 36pt; font-weight: bold; color: #2b3a4a; } QPushButton { background-color: #3b82f6; color: white; border-radius: 4px; padding: 6px 16px; } QPushButton:hover { background-color: #2563eb; } QTableWidget { background-color: white; border: 1px solid #e2e5ea; gridline-color: #e2e5ea; }这套颜色方案直接用就行蓝白灰为主信息清楚又不刺眼。记住给关键控件设置 objectName比如温度标签叫 temperatureQSS 里才能用 # 选择器精准命中。2.3 数据解析与界面安全刷新API 返回的是 JSON解析本身不难难点在于怎么把数据安全地送到界面上。先看实时天气的核心字段now 对象里有 temp温度、feelsLike体感温度、text天气现象、humidity相对湿度、windDir风向、windScale风力等级daily 数组里每天有 fxDate、tempMax、tempMin、textDay。这些字段命名非常直观基本就是英文单词的组合不需要查文档也能猜个八九不离十。跨线程更新 UI 是 PyQt 新手最容易翻车的地方。QThread 里跑的是子线程法律上不允许直接操作主线程的控件轻则界面闪烁、数据不对重则直接崩溃。正解是定义 pyqtSignal子线程把数据打包成字典 emit 出来主线程通过槽函数接收再刷新控件。信号槽机制在你写网络请求、文件读写这种耗时操作时是标配相当于一个安全的数据快递通道。异常处理也不能偷懒。网络超时、城市名拼错、API 返回错误码这些都可能在运行时发生。我统一在子线程里捕获异常用一个 error_occurred 信号把错误信息传回主线程界面弹出提示或者显示兜底文案而不是悄无声息地失败。桌面应用和网页不一样没有 console 给用户看所有错误都需要在界面上有交代。3. 完整实现流程从代码到安装包3.1 环境准备与工程目录初始化开发环境我建议用 Python 3.10 或更高版本Windows 和 Ubuntu 桌面系统上都验证过没问题所以这套方案跨平台是可行的。先建虚拟环境再装依赖python -m venv venv venv\Scripts\activate # Windows source venv/bin/activate # Linux/macOS pip install pyqt5 requests pyinstallerrequirements.txt 里锁定主要依赖就行PyQt5 的版本号不需要锁太死小版本更新通常不影响这类小项目。有个环境上的经验如果是在 Ubuntu 22.04 这类 Linux 桌面系统上跑 PyQt5缺 libxcb 相关库会导致启动时报 xcb 相关错误需要装几个系统依赖包。Windows 上基本不会遇到这种问题这也是我最初选 Windows 做主力开发环境的原因。3.2 API 服务封装与天气查询实现业务层我写在一个 api.py 里把网络请求全部收拢到 WeatherService 类中。先看数据获取部分import requests class WeatherService: def __init__(self, api_key): self.api_key api_key self.geo_url https://geoapi.qweather.com/v2/city/lookup self.now_url https://devapi.qweather.com/v7/weather/now self.forecast_url https://devapi.qweather.com/v7/weather/7d def _get(self, url, params): params[key] self.api_key resp requests.get(url, paramsparams, timeout10) resp.raise_for_status() return resp.json() def get_location_key(self, city_name): data self._get(self.geo_url, {location: city_name}) if data.get(code) 200 and data.get(location): return data[location][0][id] raise ValueError(f定位失败: {city_name}) def get_weather(self, location_key): now self._get(self.now_url, {location: location_key})[now] daily self._get(self.forecast_url, {location: location_key})[daily] return {now: now, daily: daily} def fetch_by_city(self, city_name): location_key self.get_location_key(city_name) weather self.get_weather(location_key) weather[city] city_name return weatherfetch_by_city 是给界面层调用的唯一入口一个方法返回所有需要的天气数据。超时设了 10 秒避免了请求卡死导致线程悬挂requests 的 raise_for_status 会拦截 HTTP 层错误业务异常用 ValueError 抛出逻辑很清晰。这层封装好了之后UI 层就完全不感知 API 细节了。加一个简单的本地缓存也能大幅提升体验比如把上次查询成功的城市和结果存成 JSON 文件下次启动时先展示缓存再后台刷新。这一步能让应用秒开而不是每次启动都转圈等网络。3.3 PyQt5 界面代码与核心逻辑接线界面部分核心代码如下。我用一个后台线程执行网络请求用信号回传结果from PyQt5.QtCore import QThread, pyqtSignal from api import WeatherService class WeatherThread(QThread): result_ready pyqtSignal(dict) request_failed pyqtSignal(str) def __init__(self, service, city_name): super().__init__() self.service service self.city_name city_name def run(self): try: data self.service.fetch_by_city(self.city_name) self.result_ready.emit(data) except Exception as exc: self.request_failed.emit(str(exc))主窗口里搜索按钮的点击事件负责创建线程、连接信号、启动线程class MainWindow(QMainWindow): def __init__(self, api_key): super().__init__() self.service WeatherService(api_key) self.thread None self._init_ui() def on_search_clicked(self): city self.search_input.text().strip() if not city: return self.status_label.setText(查询中...) self.thread WeatherThread(self.service, city) self.thread.result_ready.connect(self.on_result) self.thread.request_failed.connect(self.on_error) self.thread.start() def on_result(self, data): now data[now] self.temp_label.setText(f{now[temp]}°C) self.desc_label.setText( f{now[text]} 体感{now[feelsLike]}°C 湿度{now[humidity]}%) self.wind_label.setText(f{now[windDir]} {now[windScale]}级) daily data[daily] for row in range(min(7, len(daily))): item daily[row] self.table.setItem(row, 0, QTableWidgetItem(item[fxDate])) self.table.setItem(row, 1, QTableWidgetItem(item[textDay])) self.table.setItem( row, 2, QTableWidgetItem(f{item[tempMin]}°C ~ {item[tempMax]}°C)) def on_error(self, message): self.status_label.setText(f查询失败{message})这里有几个细节值得说。第一每次点击搜索前如果上一次的线程还在跑要先 stop 和 wait否则两个线程同时操作界面会出现数据错乱。第二槽函数里做的事只是往表格和标签里填文本不涉及任何耗时操作所以放在主线程完全没问题。第三用户快速连续输入城市时最终的查询结果可能不是最后输入的城市可以在 emit 的回传数据里带上城市名主线程设置一个当前期望城市做比对不一致就丢弃。菜单和快捷键也别省。我加了 CtrlQ 退出、回车触发查询这些细节虽然不起眼但日常使用频繁对体验的提升非常实在。3.4 定时刷新、托盘驻留与开机自启天气预报不能只靠手动查询定时刷新是刚需。我用 QTimer 实现self.timer QTimer(self) self.timer.timeout.connect(self.on_search_clicked) self.timer.start(30 * 60 * 1000) # 30分钟刷新间隔我特意算过。个人免费档的调用配额通常以每天为单位每天 48 次刷新30 分钟一次除以配额余量很充足。如果你同时管理多个城市记得把城市数量乘进去再定间隔。太频繁的刷新不仅浪费配额还会被服务商限流返回来一堆 429 错误。托盘驻留用来解决窗口占地方的问题self.tray QSystemTrayIcon(self) self.tray.setIcon(QIcon(assets/app.ico)) self.tray.setToolTip(桌面天气) menu QMenu() menu.addAction(显示主窗口, self.show_normal) menu.addAction(立即刷新, self.on_search_clicked) menu.addAction(退出, QApplication.quit) self.tray.setContextMenu(menu) self.tray.show()注意一个细节托盘菜单里加了立即刷新而不是让用户先双击托盘图标再点按钮少一步操作就是多一分顺手。关闭主窗口时默认是隐藏到托盘而不是退出这要在 closeEvent 里拦截一下很多新手忽略这个一个关闭按钮把应用干掉了托盘成了摆设。开机自启最简单的做法是复制一个快捷方式到 Windows 启动文件夹不需要写注册表。启动文件夹路径是 shell:startup把带参数的应用快捷方式丢进去就行。3.5 PyInstaller 打包发布与细节校验打包命令一行搞定pyinstaller -F -w -i assets/app.ico main.py参数含义说一下-F 打成单文件-w 去掉控制台黑窗口-i 指定应用图标。打包产物在 dist 目录下双击就能跑。单文件模式有代价启动时 PyInstaller 要把所有依赖释放到临时目录首次启动会慢半拍到一秒换来的是分发方便——发给同事、拷到别的机器一个 exe 搞定。如果是长期自用且目录固定可以考虑 -D 目录模式启动更快代价是文件一大堆。打包后测试要讲方法。不能只在开发机跑一次就完事要找一台没装 Python 的干净机器跑一遍很多依赖缺失问题只有在干净环境里才暴露。常见故障是缺 C 运行库和缺平台插件目录。_internal 目录里的 pyqt5 插件没被打进去会导致窗口白屏、控件失效这时候要检查 PyInstaller 的 hook 是否正常工作。发布前还要检查版本号、窗口标题、托盘 tooltip 这些门面信息。我见过太多工具 exe 标题还是 basename窗口标题栏写着main看着就像半成品。这些小地方花十分钟处理产品的完成度立刻上一个台阶。4. 常见问题与排查技巧实录4.1 高频问题速查表开发过程中踩过的坑不少整理成一张表方便你对着查问题现象根本原因解决方法窗口卡死、拖动无响应网络请求放在主线程用 QThread 执行请求信号回传结果查询结果错乱、显示上一次城市多个线程同时更新界面每次查询前终止旧线程或对比期望城市API 报错 code 不是 200Key 无效、配额超限、城市名不标准检查 Key 正确性用规范城市名如北京市中文显示成方块字体或编码问题确保源码 UTF-8指定中文字体如 Microsoft YaHei打包后双击无反应依赖缺失或插件未打包看 PyInstaller 警告日志补 hook 或加 --collect-all PyQt5托盘图标不显示图标路径错误或格式不支持用 .ico 格式路径使用绝对路径长时间挂机后请求超时API 偶发网络问题子线程捕获超时异常自动延时重试一次4.2 几条值得写进笔记的排查心得请求失败的降级策略。桌面应用挂在后台跑网络抖动是常态。我的做法是失败时不打断用户静默保留上次成功显示的天气数据同时在状态栏标注更新失败显示的是 X 分钟前数据。用户感知到的是温和的提示而不是刺眼的报错弹窗。这比反复弹错误框体面得多。日志是最好的老师。刚开始调试时我全靠 print 打印打包成窗口程序后 print 全瞎了。后来给项目加了一个极简的文件日志把请求 URL、返回码、异常堆栈写到本地 weather.log排查问题的效率翻了好几倍。桌面应用没有浏览器控制台日志就是你的唯一线索建议从第一天就加上。图标和资源的路径坑。PyInstaller 打包后当前工作目录大概率不是 exe 所在目录直接用相对路径加载图标会失败。稳妥的做法是用 sys._MEIPASS 获取临时资源目录把资源路径拼成绝对路径再加载。没注意这个图标就会在开发环境正常、打包后神秘消失。高 DPI 显示模糊。现在笔记本缩放基本都是 150%PyQt5 应用默认在高分屏下会发虚。在入口文件加上 QApplication 的高 DPI 属性设置并调用 QtWidgets.QApplication.setHighDpiScaleFactorRoundingPolicy 处理缩放策略界面就能恢复清晰。Windows 上测试过效果立竿见影。结尾做这个桌面天气预报应用我个人最大的体会是小项目恰恰能把你对软件工程的理解压实。需求取舍、分层架构、线程模型、错误处理、打包分发每一个环节都是真实项目里躲不掉的问题只是在这个项目里它们以最小成本暴露了出来。踩过几次坑之后我现在做任何桌面小工具都会条件反射地把网络请求丢进子线程、把配置和代码分离、先加日志再写逻辑。最后再分享一个后续可以扩展的方向把应用做成桌面小组件直接贴在桌面上透明显示当前天气替代系统自带的小部件。原理不复杂PyQt 里设置窗口透明属性和置顶标志再定时刷新数据就行。这个功能我自己已经加上用了很长一段时间算是整个项目最有成就感的一次迭代。希望这篇记录能让你少走点弯路早点做出自己的第一个桌面工具。
返回列表