先说明一下:做这个桌面天气应用,最初只是因为每天上班前都要刷三次手机天气,一会儿看气温、一会儿看降水概率、一会儿看风速,手机通知栏那条永远不够用。后来索性花两个晚上用 Python 写了一个桌面小组件,开机自启、托盘驻留、双击看 24 小时趋势,实测用了一个多月,才决定把开发过程完整记录下来。本文会按从技术选型、数据源接入、界面拆解到桌面端专属问题修复的顺序展开,所有代码都是实际跑过的版本。
1. 桌面天气应用的第一步:先想清楚技术栈再动手
我见过太多人一上来就写界面,写到一半发现某个库不支持系统托盘,或者打包出来的体积大得离谱,然后推倒重来。桌面应用和 Web 页面最大的区别在于,它要跟操作系统打交道:窗口生命周期、托盘图标、开机自启、全局快捷键、系统通知,这些能力在不同的技术栈里支持程度天差地别。
1.1 主流桌面技术路线对比:Electron、Tauri、PySide6
先列一张我在选型时用到的对比表,省得你再去逐个查文档:
| 技术栈 | 包体大小 | 内存占用 | 开发语言 | 托盘/自启支持 | 上手成本 |
|---|---|---|---|---|---|
| Electron | 150MB+ | 150-300MB | JavaScript | 成熟但需额外配置 | 中 |
| Tauri | 5-15MB | 50-100MB | Rust + Web前端 | 成熟 | 高(需懂 Rust) |
| PySide6 / PyQt6 | 80-150MB | 60-120MB | Python | 原生支持,代码少 | 低 |
| WPF / WinForms | 自带运行时 | 30-80MB | C# | 原生支持 | 中(仅限 Windows) |
| JavaFX | 50-80MB | 150MB+ | Java | 需第三方库 | 中 |
我最后选了 PySide6,原因有三:
- 天气应用本身逻辑不复杂,Python 写业务逻辑非常快,尤其处理 HTTP 请求和 JSON 解析,比 Rust 和 JS 都顺手。
- PySide6 对系统托盘(QSystemTrayIcon)、开机自启(QSettings 配合注册表/plist)、全局刷新定时器(QTimer)都有原生封装,不需要像 Electron 那样引一堆 node 模块。
- 图标资源和字体渲染这块,QSS 写起来和 CSS 几乎一样,做 UI 时心智负担小。
如果你对包体大小有硬性要求、且熟悉 Rust,Tauri 也是好选择;但如果你是第一次做桌面应用,想快点看到成果,PySide6 绝对是最平滑的路线。
1.2 为什么我不建议用浏览器页面套壳
很多人图省事,直接写一个 HTML 页面然后丢进 WebView。这个方案对天气应用来说有几个致命伤:
- 数据刷新时页面闪烁。WebView 加载远程 JS 会有白屏期,本地数据也无法无缝动画过渡。
- 桌面端特有的交互做不了。比如点击托盘图标显示/隐藏窗口、鼠标悬停显示温度、开机静默启动,浏览器那套 API 根本碰不到。
- 网络异常时体验极差。断网时 WebView 里只有一片空白,而原生窗口可以缓存最后一次成功拉到的数据并明确提示“上次更新时间”。
我实测过同一个天气页面在 Electron 和 PySide6 里的表现,断网场景下 Electron 页面直接白屏,而 PySide6 可以在本地 JSON 里读取缓存并正常渲染。这不只是体验问题,是实用性差异。
提示:技术选型的核心不是“哪个更流行”,而是“哪个能让你把精力花在业务逻辑上”。桌面天气应用这类小工具,选 PySide6 能把 80% 的时间投入功能本身,而不是折腾环境。
2. 天气数据不只是调一个接口:数据源、参数与响应设计
确认技术栈后,第一个实际问题就是:天气数据从哪来?
2.1 免费天气 API 的选型与坑点
我测试过和风天气、OpenWeatherMap、聚合数据三个平台,得出的结论如下:
| 平台 | 免费额度 | 返回格式 | 访问速度(国内) | 是否需要城市编码 |
|---|---|---|---|---|
| 和风天气 | 每天 1000 次 | JSON | 快 | 是(LocationID) |
| OpenWeatherMap | 每分钟 60 次 | JSON | 慢(国外服务器) | 否(经纬度) |
| 聚合数据 | 每天 100 次 | JSON | 快 | 否 |
如果不做城市搜索,只想根据 IP 定位,OpenWeatherMap 的经纬度方案确实省事,但国内访问速度不稳定,有丢包和超时问题。聚合数据需要实名认证申请 key,流程较长。我最常用的是和风天气,原因有几个:
- 免费版一天 1000 次调用,桌面应用按 30 分钟刷新一次算,24 小时只消耗 48 次,完全够用。
- 国内 CDN 节点,实测延迟基本在 100ms 以内。
- 返回字段非常细,包括体感温度、降水概率、日出日落,做 UI 时不用自己算。
和风天气需要先通过城市名称查 LocationID,这个接口是GET https://geoapi.qweather.com/v2/city/lookup,传location=成都就能拿到 CityID。
2.2 请求参数的取舍逻辑
天气业务里有一个必须想清楚的问题:你要展示的数据精度决定了请求参数的复杂度。
我只做了“当前天气 + 24 小时趋势”两个维度,所以只需要两个接口:
# 实时天气 GET https://devapi.qweather.com/v7/weather/now ?location=101270101 &key=YOUR_KEY # 24小时预报 GET https://devapi.qweather.com/v7/weather/24h ?location=101270101 &key=YOUR_KEY很多新手会问:为什么不直接调免费版的“逐天预报”接口一次拿 7 天数据?因为逐天预报的字段粒度太粗,看不到今天下午 3 点会不会下雨,而 24 小时预报是逐小时的,粒度更细,对“今天要不要带伞”这个场景更实用。
响应结构里,now接口的核心字段如下:
| 字段 | 含义 | 典型值 |
|---|---|---|
| temp | 当前温度(摄氏度) | 23 |
| feelsLike | 体感温度 | 25 |
| icon | 天气图标代码 | 101 |
| text | 天气现象描述 | 多云 |
| windDir | 风向 | 东南风 |
| windScale | 风力等级 | 3级 |
| humidity | 相对湿度 | 65% |
24h接口返回一个数组,每个元素包含fxTime、temp、icon、text、pop(降水概率)。我用pop这个字段做了一条关键逻辑:降水概率超过 60% 时,UI 上会直接用蓝色高亮标示提醒,这个在后面的界面设计里会详细说。
2.3 封装一个稳健的请求模块
接口调用的代码本身并不复杂,真正值得花时间的是“超时重试机制”和“响应码处理”。我的实现思路是:
import requests import json class WeatherAPI: def __init__(self, api_key): self.api_key = api_key self.session = requests.Session() self.session.headers.update({'X-QW-Api-Key': api_key}) def get_city_id(self, city_name: str) -> str: """根据城市名获取LocationID,带本地缓存""" cache_file = f"cache_{city_name}.json" # 先读本地缓存,避免每次都请求 try: with open(cache_file, "r", encoding="utf-8") as f: return json.load(f)["id"] except FileNotFoundError: pass url = "https://geoapi.qweather.com/v2/city/lookup" resp = self.session.get(url, params={"location": city_name}, timeout=5) data = resp.json() if data["code"] == "200": city_id = data["location"][0]["id"] with open(cache_file, "w", encoding="utf-8") as f: json.dump({"id": city_id}, f, ensure_ascii=False) return city_id raise RuntimeError(f"城市ID查询失败: {data['code']}") def get_weather(self, city_id: str): """并发请求实时天气和24小时预报""" now_url = "https://devapi.qweather.com/v7/weather/now" hourly_url = "https://devapi.qweather.com/v7/weather/24h" params = {"location": city_id} resp_now = self.session.get(now_url, params=params, timeout=5) resp_hourly = self.session.get(hourly_url, params=params, timeout=5) now_data = resp_now.json() hourly_data = resp_hourly.json() if now_data["code"] != "200": raise RuntimeError(f"实时天气接口返回异常: {now_data['code']}") if hourly_data["code"] != "200": raise RuntimeError(f"24小时预报接口返回异常: {hourly_data['code']}") return now_data["now"], hourly_data["hourly"]请注意这里两个细节:
- 设置了
timeout=5,避免网络异常时界面卡死。 - 城市 ID 做了本地缓存,否则每次启动都查一次城市 ID,白白消耗配额。
2.4 数据校验:别轻信任何第三方返回
天气接口偶发返回异常值,我遇到过temp字段返回--的情况。所以在写数据解析层时,必须加一层防御性校验:
def safe_int(value, default=0): try: return int(float(value)) except (ValueError, TypeError): return default def safe_float(value, default=0.0): try: return float(value) except (ValueError, TypeError): return default实测中,和风天气在极端天气下可能返回temp为"--"的情况,用上面的函数处理过后就不会导致界面崩溃。
3. 把零散的天气数据变成看得顺眼的界面:客户端 UI 拆解
天气应用的 UI 设计有一个很容易被忽视的原则:信息层级比界面漂亮更重要。用户打开应用后要在 1 秒内知道三件事——现在多少度、什么天气、出门需不需要带伞。
3.1 主窗口布局:三区块分割法
我把主窗口拆成三个区块,从上到下依次是:
- 当前概览区:大号温度数字 + 天气现象图标 + 体感温度/湿度/风向。
- 24 小时趋势区:横滑的温度折线 + 降水概率条。
- 刷新状态区:上次刷新时间 + 手动刷新按钮。
用 PySide6 实现时,我用QHBoxLayout和QVBoxLayout嵌套实现,核心代码骨架如下:
from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton from PySide6.QtCore import Qt class MainWindow(QWidget): def __init__(self): super().__init__() self.setWindowTitle("桌面天气") self.setFixedSize(420, 680) # 外部包裹布局 self.outer_layout = QVBoxLayout(self) self.outer_layout.setContentsMargins(20, 20, 20, 20) self.outer_layout.setSpacing(16) # 概览区 self.overview_widget = QWidget() self.overview_layout = QVBoxLayout(self.overview_widget) self.temp_label = QLabel("--°") self.temp_label.setAlignment(Qt.AlignmentFlag.AlignCenter) self.temp_label.setStyleSheet("font-size: 64px; font-weight: bold;") self.overview_layout.addWidget(self.temp_label) # 次要信息 self.desc_label = QLabel("多云") self.desc_label.setAlignment(Qt.AlignmentFlag.AlignCenter) self.desc_label.setStyleSheet("font-size: 18px; color: #555;") self.overview_layout.addWidget(self.desc_label) self.detail_label = QLabel("体感 25° | 湿度 65% | 东南风 3级") self.detail_label.setAlignment(Qt.AlignmentFlag.AlignCenter) self.detail_label.setStyleSheet("font-size: 13px; color: #888;") self.overview_layout.addWidget(self.detail_label) self.outer_layout.addWidget(self.overview_widget) # 24小时趋势区(后续自定义重绘) self.hourly_widget = HourlyForecastWidget() self.outer_layout.addWidget(self.hourly_widget, stretch=2) # 底部按钮 self.refresh_btn = QPushButton("刷新天气") self.refresh_btn.clicked.connect(self.refresh_weather) self.outer_layout.addWidget(self.refresh_btn)这里有个经验:setFixedSize看似死板,但对天气工具来说非常合适。窗口大小固定意味着布局不会因为拖动而错乱,QSS 里的像素级调整一次到位。
3.2 24 小时趋势图:用 QPainter 代替第三方图表库
天气趋势图如果用matplotlib或者pyqtgraph嵌入,会引入几十 MB 的依赖,而且刷新时图表重绘会有明显的卡顿。实际上 24 小时温度趋势完全可以用QPainter自己画,代码量不大,效果还更可控。
我实现的思路是:在自定义QWidget的paintEvent里绘制网格线、温度折线和降水概率柱状图。核心逻辑如下:
from PySide6.QtWidgets import QWidget from PySide6.QtGui import QPainter, QPen, QColor from PySide6.QtCore import Qt, QPointF class HourlyForecastWidget(QWidget): def __init__(self): super().__init__() self.hourly_data = [] def set_data(self, hourly_data): self.hourly_data = hourly_data self.update() def paintEvent(self, event): painter = QPainter(self) painter.setRenderHint(QPainter.RenderHint.Antialiasing) width = self.width() height = self.height() margin_left = 30 margin_right = 10 margin_top = 20 margin_bottom = 40 chart_width = width - margin_left - margin_right chart_height = height - margin_top - margin_bottom # 画浅色背景网格线 pen_grid = QPen(QColor("#e8e8e8"), 1) painter.setPen(pen_grid) for i in range(1, 5): y = margin_top + chart_height * i / 5 painter.drawLine(margin_left, int(y), width - margin_right, int(y)) if not self.hourly_data: return # 计算温度范围 temps = [item["temp"] for item in self.hourly_data] min_temp = min(temps) - 3 max_temp = max(temps) + 3 temp_range = max_temp - min_temp or 1 points = [] step = chart_width / (len(self.hourly_data) - 1) for idx, item in enumerate(self.hourly_data): x = margin_left + idx * step y = margin_top + (max_temp - item["temp"]) / temp_range * chart_height points.append(QPointF(x, y)) # 画温度折线 pen_line = QPen(QColor("#1e88e5"), 2) painter.setPen(pen_line) for i in range(len(points) - 1): painter.drawLine(points[i].toPoint(), points[i + 1].toPoint()) # 画每个小时的小圆点 painter.setBrush(QColor("#1e88e5")) for point in points: painter.drawEllipse(point, 3, 3) # 画降水概率柱状图(蓝色半透明) pen_bar = QPen(QColor(30, 136, 229, 0), 0) painter.setPen(pen_bar) painter.setBrush(QColor(30, 136, 229, 60)) bar_width = step * 0.4 for idx, item in enumerate(self.hourly_data): pop = int(item.get("pop", 0)) if pop > 0: bar_x = margin_left + idx * step - bar_width / 2 bar_h = chart_height * pop / 100 painter.drawRect( int(bar_x), int(margin_top + chart_height - bar_h), int(bar_width), int(bar_h), ) painter.end()这样画出来的 24 小时趋势图在交互层面已经足够清晰,整个控件刷新一次耗时不到 5ms,肉眼完全无感。最重要的是,所有数据都在本地计算,不依赖任何重型组件库。
注意:
QPainter.drawLine在绘制大量短线段时性能不错,但别在循环里频繁创建QPen和QBrush,它们应该在循环外提出来复用。早期版本我犯过这个错,24 条线段耗时 30ms,后来重构后降到 3ms。
3.3 字体与配色:天气应用的视觉层级
UI 的美观度很大程度取决于字号和间距,而不是复杂的装饰。我最终采用的配色方案是:
| 元素 | 颜色 | 字号 |
|---|---|---|
| 主温度 | #1a1a2e | 64px |
| 天气描述 | #555 | 18px |
| 次要指标 | #888 | 13px |
| 降水高亮 | #1e88e5 | 12px |
窗口背景用了非常浅的渐变灰#f7f8fc,避免纯白刺眼。因为桌面应用用户往往长时间挂在屏幕上,这个低对比度方案实测看一整天也不会累。
4. 桌面端专属的三块硬骨头:数据刷新、托盘与自启
Web 应用开发者转做桌面应用时,最容易掉的坑就是——你以为页面加载完就完事了,但桌面应用是一个常驻进程。它要处理定时刷新、后台运行、开机启动这些 Web 场景根本不存在的状态。
4.1 数据刷新机制:不要让用户手动按刷新按钮
最开始我做的是手动刷新,用了一天后就发现不行——用户打开应用看到的是上一次的数据,可能已经是几小时前的了,信息价值大打折扣。
正确方式是三层刷新策略:
- 启动即刷新:应用启动后立即拉取一次最新数据。
- 定时自动刷新:用
QTimer每 30 分钟自动拉取一次。 - 手动刷新兜底:用户点击按钮可立即刷新,刷新期间按钮变成“刷新中...”并禁用。
实现代码如下:
from PySide6.QtCore import QTimer class MainWindow(QWidget): def __init__(self): super().__init__() # 启动即刷新 QTimer.singleShot(0, self.refresh_weather) # 每30分钟自动刷新 self.timer = QTimer(self) self.timer.timeout.connect(self.refresh_weather) self.timer.start(30 * 60 * 1000) def refresh_weather(self): if hasattr(self, "_refreshing") and self._refreshing: return self._refreshing = True self.refresh_btn.setEnabled(False) self.refresh_btn.setText("刷新中...") # 这里会用 QThread 或进程池执行网络请求,避免阻塞UI # 详细逻辑见 4.3这里有一个关键设计:_refreshing标志位用来防止用户疯狂点击按钮导致重复请求,也防止上一次请求还没回来、定时器又触发了下一次请求。由于网络请求无法预估耗时,必须加这个互斥锁。
4.2 30 分钟刷新间隔是怎么确定的
有人觉得 30 分钟太频繁,有人觉得太慢。我实测对比过不同平台的天气数据更新时间:和风天气的分钟级降水预报每 10 分钟更新一次,24 小时预报每 6 小时更新一次。
如果刷新太频繁,比如 5 分钟一次,你拉到的数据大概率跟上一次完全一样,白白消耗 API 配额;如果刷新太慢,比如 2 小时一次,就会错过突然的天气变化。30 分钟是一个平衡点,既能捕捉到大部分天气变化,又不会造成资源浪费。
此外,我还加了一个小优化:程序检测到正在刷新时,如果上次成功刷新的时间距现在不足 2 分钟,则直接跳过本次刷新。这用来避免快速重启、网络抖动等异常场景下的重复请求。
4.3 网络请求放在子线程:避免界面卡死的必修课
PySide6 里直接在 UI 线程做网络请求是大忌,请求耗时 3 秒,界面就冻结 3 秒。何况天气 API 偶尔会超时重试,用户看到“白屏无响应”会直接关掉应用。
我用QThread+Signal实现异步请求:
from PySide6.QtCore import QThread, Signal class WeatherWorker(QThread): finished = Signal(dict) failed = Signal(str) def __init__(self, api: WeatherAPI, city_id: str): super().__init__() self.api = api self.city_id = city_id def run(self): try: now, hourly = self.api.get_weather(self.city_id) self.finished.emit({ "now": now, "hourly": hourly, "update_time": QDateTime.currentDateTime().toString("HH:mm:ss") }) except Exception as e: self.failed.emit(str(e))在主窗口里这样使用:
def refresh_weather(self): # 先清空上一次的线程引用,防止内存堆积 if hasattr(self, '_worker') and self._worker.isRunning(): return self._worker = WeatherWorker(self.api, self.city_id) self._worker.finished.connect(self.on_weather_updated) self._worker.failed.connect(self.on_weather_failed) self._worker.start() def on_weather_updated(self, data): self._refreshing = False self.refresh_btn.setEnabled(True) self.refresh_btn.setText("刷新天气") self.temp_label.setText(f"{data['now']['temp']}°") self.desc_label.setText(data['now']['text']) self.detail_label.setText( f"体感 {data['now']['feelsLike']}° | " f"湿度 {data['now']['humidity']}% | " f"{data['now']['windDir']} {data['now']['windScale']}级" ) self.hourly_widget.set_data(data["hourly"]) self.update_time_label.setText(f"更新于 {data['update_time']}") def on_weather_failed(self, error_msg): self._refreshing = False self.refresh_btn.setEnabled(True) self.refresh_btn.setText("刷新天气") self.update_time_label.setText(f"刷新失败:{error_msg}")注意在重写refresh_weather时,我们先检查_worker.isRunning(),如果上一次的请求还没结束,直接 return。这样可以避免用户连点时启动多个线程,导致界面状态混乱。
4.4 系统托盘:最小化到托盘而不是退出
桌面小工具的使用习惯是:用户希望它安安静静待在后台,想看一眼的时候就唤出来,而不是每次都从桌面图标重新启动。PySide6 对托盘的支持很成熟:
from PySide6.QtGui import QIcon, QAction from PySide6.QtWidgets import QSystemTrayIcon, QMenu class MainWindow(QWidget): def setup_tray(self): self.tray_icon = QSystemTrayIcon(self) self.tray_icon.setIcon(QIcon("resources/icon.png")) self.tray_icon.setToolTip("桌面天气") menu = QMenu() show_action = QAction("显示/隐藏", self) show_action.triggered.connect(self.toggle_window) quit_action = QAction("退出", self) quit_action.triggered.connect(QApplication.instance().quit) menu.addAction(show_action) menu.addAction(quit_action) self.tray_icon.setContextMenu(menu) self.tray_icon.activated.connect(self.on_tray_activated) self.tray_icon.show() def on_tray_activated(self, reason): if reason == QSystemTrayIcon.ActivationReason.DoubleClick: self.toggle_window() def toggle_window(self): if self.isVisible(): self.hide() else: self.showNormal() self.activateWindow() def closeEvent(self, event): """点击关闭按钮时最小化到托盘,而不是退出""" event.ignore() self.hide()这段代码里最关键的是closeEvent重写:用户点窗口右上角关闭按钮时,默认行为是退出程序,但我重写为隐藏窗口。这样应用能常驻后台,需要时再从托盘双击唤出。
提示:托盘图标在 Windows 上需要
ico格式,在 macOS 上建议用icns。如果用png在 Windows 托盘里会被强制加白底,看起来非常粗糙。我后来直接在代码里用QPainter把png转成带透明通道的ico才解决。
4.5 开机自启:这是桌面工具“能不能用起来”的分水岭
开机自启的实现方式依赖操作系统:
- Windows:写注册表
HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Run。 - macOS:写
~/Library/LaunchAgents下的 plist 文件。 - Linux:把桌面文件放
~/.config/autostart。
PySide6 里用QSettings就可以安全地写注册表:
from PySide6.QtCore import QSettings, QStandardPaths import sys def set_auto_start(enabled: bool): app_name = "DesktopWeather" if sys.platform == "win32": settings = QSettings( "HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Run", QSettings.Format.NativeFormat, ) if enabled: app_path = QApplication.applicationFilePath() settings.setValue(app_name, f'"{app_path}"') else: settings.remove(app_name) elif sys.platform == "darwin": # mac 的 plist 实现略长,这里只列核心意图 pass开机自启虽然代码量不大,但它决定了这个工具的核心体验:每天开机后自动拉取一次天气,你看一眼屏幕就知道今天穿什么。如果每次都要手动启动,你就根本不会长期用它。
5. 实测中的持久化与会话保持:断网、缓存与日志
桌面应用的韧性主要体现在对异常环境的容忍度上。我在真实使用中几乎每天都遇到断网、API 限流、服务器 5xx 等情况,如果不做持久化和缓存,应用会变得非常脆弱。
5.1 本地缓存:用 JSON 文件保存上次数据
天气数据本身是低敏感度的,但跨会话缓存能极大提升使用体验。我的策略是:
- 每次刷新成功后,把
now和hourly数据写入weather_cache.json。 - 启动时先尝试读取缓存并立即显示,然后再发网络请求更新。
- 如果网络请求失败,直接展示缓存数据,并在界面标注“上次更新于 xx:xx”。
核心代码如下:
import json import os from pathlib import Path CACHE_DIR = Path.home() / ".desktop_weather" CACHE_FILE = CACHE_DIR / "weather_cache.json" def save_cache(data: dict): CACHE_DIR.mkdir(exist_ok=True) with open(CACHE_FILE, "w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False) def load_cache(): if not CACHE_FILE.exists(): return None try: with open(CACHE_FILE, "r", encoding="utf-8") as f: return json.load(f) except (json.JSONDecodeError, IOError, ValueError): # 缓存文件损坏时直接删除并返回空 CACHE_FILE.unlink(missing_ok=True) return None这里有一个细节:缓存文件损坏时直接删除而不是尝试修复,因为天气数据下一轮刷新就会重新生成,没必要为了损坏的缓存做额外处理。
5.2 请求失败后的降级展示
用户下周如果断网打开应用,看到一片空白肯定直接卸载。所以我做了一个“降级展示”逻辑:
- 如果有缓存:展示缓存数据,顶部用橙色提示条标注“当前离线数据”,并显示上次更新时间。
- 如果没有缓存:展示占位 UI,提示“暂无天气数据”,并提供“重试”按钮。
这个提示条在 UI 层用一个QLabel实现,网络恢复后自动隐藏:
def on_weather_failed(self, error_msg): self._refreshing = False cached = load_cache() if cached: self.apply_weather_data(cached) # 先展示缓存 self.offline_label.setText("当前离线数据 | 点击重试") self.offline_label.show() else: self.temp_label.setText("--°") self.desc_label.setText("获取失败") self.detail_label.setText(error_msg)5.3 日志记录:遇到问题时不至于两眼一抹黑
桌面应用发布后,用户反馈问题时最痛苦的就是拿不到现场信息。我在关键路径埋了日志,包括:
- 每次请求的 URL、参数、耗时、响应码。
- 每次刷新动作是“自动刷新”还是“手动刷新”。
- 接口抛出的异常堆栈。
Python 的logging模块就够了,不需要引第三方库:
import logging logging.basicConfig( filename=str(Path.home() / ".desktop_weather" / "app.log"), level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s", ) def log_refresh(source: str, success: bool, detail: str = ""): status = "成功" if success else "失败" logging.info(f"[{source}] 刷新{status} {detail}")这个日志文件在排查“用户反馈不刷新”“定时器触发了但界面没更新”这类问题时,几乎是唯一线索。实测中曾有用户反馈双击托盘图标没反应,查日志发现是activated信号在部分 Windows 高分屏下触发了但窗口被隐藏,后来通过日志定位并修复。
5.4 定时器与线程的内存管理
PySide6 的定时器如果在线程中创建,会跟随线程销毁,这一点很多人不知道。我早期版本在WeatherWorker里建QTimer,结果每次刷新后线程退出,定时器也被销毁,根本不会触发第二次自动刷新。
正确做法是:QTimer必须在主线程的窗口对象里创建,并用self.timer.start()启动。线程只负责一次性的网络请求,不持有任何周期任务。
6. 从“能跑”到“好用”:打包分发前的自检清单
代码写完并不代表任务结束。桌面应用的打包分发自有一套流程,我在这里列一份实测过的清单,帮你少走弯路。
6.1 用 PyInstaller 打包成单文件
我最终用 PyInstaller 打包,命令如下:
pyinstaller --windowed --onefile --name DesktopWeather \ --icon resources/icon.ico \ --add-data "resources;resources" \ main.py几个关键参数的含义:
--windowed:打包成 GUI 应用,不显示命令行黑框。--onefile:打包成单个 exe 文件,方便分发。--add-data:把图标等资源文件打进包内。注意 Windows 用分号;分隔,macOS/Linux 用冒号:。
打包后 exe 体积大约 45MB,对 PySide6 应用来说属于正常水平。如果觉得大,可以试试--exclude-module排除用不到的 Qt 组件,比如QtWebEngine、QtMultimedia,能减掉十几 MB。
6.2 启动速度优化:懒加载图标和字体
PySide6 启动时如果加载大量图标资源,会让首屏变得很慢。我的优化方式是:
- 图标文件只在使用时加载,不提前全部载入。
- 系统字体不需要复制进包内,直接用系统默认字体。
- 窗口先显示,再异步拉取天气数据。
实测优化后,从双击图标到看到温度数字,耗时从 2.1 秒降到 0.8 秒。这个提升在“开机自启”场景下非常关键,因为用户可能只是瞥一眼屏幕。
6.3 分发前必须检查的 5 个问题
我踩过不少坑,总结出下面这份清单:
| 检查项 | 场景 | 处理方式 |
|---|---|---|
| 城市 ID 是否写死 | 用户换了城市 | 界面增加设置入口,存配置文件 |
| 系统缩放比例 | Windows 150% 缩放导致布局变形 | 使用布局时设setMinimumWidth |
| 首次运行杀毒拦截 | 未签名的 exe 容易被误报 | 用--onefile后加数字签名 |
| API Key 是否暴露 | 反编译能看到 | 作为桌面应用无解,接受这个风险 |
| 自动更新机制 | 修改 bug 后用户还在旧版 | 先做发布版本号,再考虑更新逻辑 |
其中 API Key 透传的问题,是桌面应用永远的痛。我能给的建议是:把 API Key 作为配置项放在用户主目录,而不是硬编码进源码,这样至少可以在 Key 被滥用时引导用户自行更换。
6.4 最后的实测体验
打包完成后我给自己机器装了一版,连续运行 72 小时,记录了几个数据:
- 内存占用稳定在 85MB 左右。
- 30 分钟自动刷新 48 次,全部成功。
- 断网 20 分钟后恢复,应用能自动从缓存降级切回在线数据。
- 托盘唤起延迟小于 100ms。
- 开机自启到首屏显示约 1.2 秒。
这个结果已经符合我对桌面天气工具的预期。如果你也打算做一个类似的应用,我建议不要一上来就追求花哨的动画效果和复杂的设置项,把四个基础环节做好——数据可靠、刷新及时、后台常驻、断网可用,工具的价值就已经体现出来了。