平时工作基本都泡在电脑前屏幕上常年开着编辑器、浏览器和聊天窗口。要看天气的时候不是摸手机就是切网页一来一回至少十几秒一天下来浪费不少注意力。后来我把一个迷你播放器挂在副屏角落突然意识到天气信息也根本不需要“主动去看”它应该自己挂在屏幕边缘瞄一眼就够。这个念头就是做桌面版天气预报应用的起点。这篇文章会把完整过程写下来从数据源选型、GUI 框架选择到多线程请求、定时刷新再到打包分发每一步都说明白。这不是一个“Hello World”级别的示例而是一个能长期挂在桌面角落、每天实际在用的工具。适合想拿 Python 练手、又不想做毫无用处的小程序的开发者也适合想用 PySide6 做真实桌面产品的朋友。整个过程不会涉及复杂的 C 或者前端工程化只要会一点 Python 就能跟上。1. 为什么做桌面天气应用场景痛点与方案选型1.1 手机天气和网页够用为什么还要一个桌面应用很多人第一反应是手机自带天气、网页也有一堆天气服务多做一个桌面应用不是重复造轮子吗这话对一半。如果只是“每天出门前看一眼”手机确实够了。但如果你一天坐在电脑前八个小时工作状态里频繁切换手机其实是很大的打断成本。桌面版天气应用的核心价值不是“能显示天气”而是“让天气信息一直待在你视线边缘”。我试过把网页标签固定在浏览器里但一开全屏代码就看不见了也试过系统自带小组件但样式和位置都不够灵活。自己做一个桌面版的好处是窗口大小自己定信息密度自己定刷新频率自己定甚至可以做到透明背景、置顶显示。另外从技术练手的角度天气应用是一个特别合适的全流程项目。它不只是一个 GUI 界面背后牵涉到 HTTP 请求、JSON 解析、异步刷新、配置管理、打包分发每一个环节都是桌面应用开发里的基本功。做完这一个你完全可以拿着同样的架构去改造成股票行情、系统监控、RSS 阅读器。1.2 技术选型Python PySide6 比 Electron 更适合这个场景选技术栈之前我列了一下候选方案包括 Electron、Tauri、C# WPF 和 Python GUI 方案挨个对比过方案语言安装包体积内存占用开发效率跨平台ElectronJS/TS80MB 起步很高高但依赖生态重好TauriRust Web5MB 左右中低需要懂 Rust 和前端好C# WPFC#小低高但主要限 Windows差Python PySide6Python40-60MB打包后中等很高语言简单好Electron 的优势是前端技术栈复用但为了一个天气小工具付出 150MB 以上的内存我个人觉得不值。Tauri 很轻但需要 Rust 工具链对一个以 Python 为主要语言的开发场景来说入门成本不低。最后我选了Python 3.11 PySide6。原因很朴素Python 生态里 requests 请求库、json 解析、QThread 多线程都很成熟PySide6 是 Qt for Python 的官方绑定控件丰富写界面像搭积木而且打包成 Windows 安装包也不难。这个选型思路也可以推广到其他工具类应用优先选择“你已经会用”的语言而不是“理论上最优”的框架。天气工具的核心是信息展示逻辑不是炫酷的动画PySide6 足够胜任。1.3 项目整体架构规划动手写代码之前我先把要做的功能拆成几个模块避免后面变成一团意大利面数据层WeatherService负责调用天气 API解析 JSON返回干净的 Python 对象。界面层MainWindow负责窗口布局、卡片展示、用户交互。调度层QThread Worker负责把耗时的网络请求放到后台线程避免界面卡死。配置层config管理 API Key、默认城市、刷新间隔等参数。数据层和界面层严格分开这是这个项目最值得坚持的一点。后续你想换一个天气数据源或者把界面从 Qt 换成命令行输出都只需要改一个模块不会牵一发而动全身。2. 数据源是基础天气 API 怎么选、怎么接入2.1 免费天气 API 的选型对比桌面应用的数据源直接决定了应用的可靠程度。这一步我花的时间比写界面还多因为市面上的天气接口五花八门免费额度和数据完整性差异很大。我最终筛选出三个在国内可以直接访问、注册门槛低的方案服务商免费额度数据完整度城市解析备注和风天气 QWeather免费订阅基础版够用高含分钟级降水、预警、生活指数LocationID 精确匹配文档清晰推荐高德开放平台每日有免费配额实况和短期预报足够高德 adcode适合轻量场景心知天气免费版数据较基础中城市拼音响应快但字段少我最后选的是和风天气 QWeather。主要原因有三个第一它的免费订阅每天可以调用几千次个人桌面应用根本用不完第二它的数据是结构化 JSON字段定义规范实况、预报、预警、生活指数都拆成了独立接口非常适合做功能扩展第三它提供经纬度、LocationID、城市名三种查询方式用城市名做模糊搜索体验很好。2.2 申请 API Key 和城市 ID 的完整流程和风天气的接入流程并不复杂但有几个容易踩的细节值得说清楚。第一步去 QWeather 开发平台注册账号创建一个项目选择“免费订阅”拿到 API Key。这个 Key 是一串字符串相当于你调用接口的钥匙不能泄露。考虑到桌面应用分发给别人使用时API Key 会直接出现在配置文件里建议在创建项目时把额度限制调低防止被别人盗刷。第二步需要理解 LocationID 的概念。和风天气的接口不喜欢直接接收中文城市名推荐做法是先用“城市查询接口”把中文城市名翻译成 LocationID再拿 ID 去查天气。北京三环的 LocationID 跟“北京市”不是一个概念它是一个 9 位数字例如北京的 LocationID 是 101010100。这个数字可以在城市查询接口返回结果里拿到。城市查询接口这样用curl https://devapi.qweather.com/v7/geo/city-lookup?location北京keyYOUR_API_KEY返回的 JSON 大致长这个样子{ code: 200, location: [ { name: 北京, id: 101010100, adm1: 北京市, adm2: 北京市, lat: 39.9042, lon: 116.4074 } ] }需要注意这个接口返回的location数组可能包含多个匹配项比如搜“朝阳区”会返回好几个不同城市的朝阳区。写代码的时候不能直接取第一个结果最好是结合adm1省级行政区字段做二次确认。2.3 实况天气接口和返回字段解读拿到 LocationID 之后就可以请求实况天气了curl https://devapi.qweather.com/v7/weather/now?location101010100keyYOUR_API_KEY返回的核心字段我整理成表格方便对照字段含义示例obsTime数据观测时间2025-04-06T12:3008:00temp温度摄氏度24feelsLike体感温度26icon天气图标编号101text天气现象描述多云windDir风向东南风windScale风力等级2级humidity相对湿度41precip降水量0.0pressure大气压强百帕1013vis能见度千米25icon字段是一个很有意思的设计它用数字表示天气状况比如 100 是晴101 是多云104 是阴305 是小雨。界面层可以根据这个数字拼接出对应图标不用每次解析天气现象文字。我在后面的章节会写如何用它对应自定义图标。3. 先把界面搭出来从空窗口到能看的数据面板3.1 主窗口的信息架构一屏内有主次天气应用的信息量可以很大但桌面角落的窗口空间有限所以界面设计必须先想清楚优先级。我的做法是分四层第一层视觉焦点当前位置、当前温度、天气现象字号最大。第二层补充信息体感温度、湿度、风力、风向、气压。第三层趋势信息未来 24 小时温度曲线和未来 7 天列表。第四层状态提示底部搜索框、最后一次刷新时间、更新按钮。窗口默认尺寸控制在 420x720 左右既能在副屏角落放着又不至于遮挡太多工作区。这个尺寸不是拍脑袋定的我调过好几轮最后发现 420 宽度刚好可以放下“天气现象 温度 简况”一行内容720 高度则能在一屏里露出 7 天预报的前 4 天给用户留出滚动预期。3.2 用 PySide6 搭建可扩展的 MainWindowPySide6 的界面可以用 Qt Designer 拖拽完成但我个人更喜欢直接用代码构建因为文本形式的布局代码更方便 Git 管理也更容易做动态更新。下面这段代码是主窗口的骨架我把窗口结构抽象成几个容器顶部搜索区、当前天气卡片、24 小时趋势区、7 天预报列表区from PySide6.QtWidgets import ( QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QPushButton, QScrollArea, QFrame ) from PySide6.QtCore import Qt class MainWindow(QMainWindow): def __init__(self): super().__init__() self.setWindowTitle(桌面天气) self.setFixedSize(420, 720) central_widget QWidget() self.setCentralWidget(central_widget) root_layout QVBoxLayout(central_widget) root_layout.setContentsMargins(16, 16, 16, 16) root_layout.setSpacing(12) # 顶部搜索区 search_layout QHBoxLayout() self.city_input QLineEdit() self.city_input.setPlaceholderText(输入城市名例如北京) self.search_btn QPushButton(查询) search_layout.addWidget(self.city_input) search_layout.addWidget(self.search_btn) root_layout.addLayout(search_layout) # 当前天气卡片 self.now_card self._create_now_card() root_layout.addWidget(self.now_card) # 24小时趋势区 self.hourly_label QLabel(24小时趋势) root_layout.addWidget(self.hourly_label) # 7天预报列表用滚动区域包裹 self.forecast_widget QWidget() self.forecast_layout QVBoxLayout(self.forecast_widget) scroll QScrollArea() scroll.setWidgetResizable(True) scroll.setWidget(self.forecast_widget) root_layout.addWidget(scroll) # 底部刷新时间栏 self.update_info QLabel(尚未刷新) self.update_info.setAlignment(Qt.AlignRight) root_layout.addWidget(self.update_info) def _create_now_card(self): card QFrame() card.setObjectName(nowCard) layout QVBoxLayout(card) self.city_label QLabel(北京) self.city_label.setObjectName(cityLabel) self.temp_label QLabel(--°C) self.temp_label.setObjectName(tempLabel) self.text_label QLabel(--) self.text_label.setObjectName(textLabel) self.detail_label QLabel(湿度 -- / 风力 -- / 气压 --) self.detail_label.setObjectName(detailLabel) layout.addWidget(self.city_label) layout.addWidget(self.temp_label) layout.addWidget(self.text_label) layout.addWidget(self.detail_label) return card为什么这么设计有几个细节是从实际使用体验里换来的用setFixedSize锁定窗口大小而不是让用户手动拉伸。因为天气预报窗口一旦拉大拉小布局比例很容易失衡固定尺寸能保持卡片信息的一致性。7 天预报不直接堆在主窗口里而是放到QScrollArea里。这样即使未来扩展到 15 天预报也不用改窗口整体结构。所有UI更新操作都通过成员变量来引用比如self.city_label、self.temp_label而不是在回调里临时创建控件这样后续更新数据时只需要改文本属性不用重建布局。3.3 QSS 样式表让默认控件摆脱“科研软件”气质PySide6 默认的控件样式比较朴素但不用慌Qt 支持 QSS语法和 CSS 几乎一样可以直接对控件类型、对象名、属性做样式定制。我建议把样式放到独立的style.qss文件里而不是写死在代码中。这样后续换主题、调颜色只需要改 CSS 文件重新加载即可。下面是我用的一套深色主题片段既护眼又不刺眼QWidget { background-color: #1f2430; color: #e6e8ee; font-family: Microsoft YaHei, PingFang SC, sans-serif; font-size: 14px; } QLineEdit { background-color: #2a2f3a; border: 1px solid #3a4150; border-radius: 8px; padding: 8px 12px; selection-background-color: #4a90e2; } QPushButton { background-color: #4a90e2; color: #ffffff; border: none; border-radius: 8px; padding: 8px 16px; font-weight: 600; } QPushButton:hover { background-color: #5da4f5; } QFrame#nowCard { background-color: #2a2f3a; border-radius: 12px; padding: 16px; } QLabel#tempLabel { font-size: 64px; font-weight: 700; color: #ffffff; } QLabel#cityLabel { font-size: 18px; font-weight: 600; color: #aeb4c0; }在程序启动时加载 QSS 只需要一行app QApplication(sys.argv) with open(style.qss, r, encodingutf-8) as f: app.setStyleSheet(f.read())一个小技巧对象名objectName是 QSS 选择器的核心锚点。我需要让哪些卡片特殊化就在创建控件后立刻调用setObjectName()然后在 QSS 里用QFrame#nowCard这类 ID 选择器精准命中避免用.findChild挨个去翻控件。3.4 城市搜索框与回车事件搜索框默认情况下需要点击“查询”按钮才会触发搜索但键盘党的习惯是按回车。我压一下回车事件绑定的方式from PySide6.QtWidgets import QApplication from PySide6.QtCore import Signal class MainWindow(QMainWindow): search_submitted Signal(str) # 向外抛出城市名 def __init__(self): super().__init__() # 上面已创建 city_input... self.search_btn.clicked.connect(self._on_search) self.city_input.returnPressed.connect(self._on_search) def _on_search(self): city self.city_input.text().strip() if city: self.search_submitted.emit(city)通过自定义信号search_submitted把用户输入抛出去这样主窗口不需要关心数据请求是怎么做的界面层与数据层在这里完成解耦。后续如果有测试需求可以直接在主线程里模拟发出这个信号。4. 数据请求与解析让界面真正“活”起来4.1 封装一个独立的 WeatherService 数据层界面的壳搭好了接下来要解决数据问题。第一版我图省事直接在按钮回调里写requests.get()结果一刷新窗口就会“无响应”因为网络请求把 UI 线程卡住了。所以数据层一定要抽出来并且请求必须放在后台线程。先看一下WeatherService的完整封装import requests class WeatherService: def __init__(self, api_key: str): self.api_key api_key self.base_url https://devapi.qweather.com/v7 def city_lookup(self, location: str) - str | None: url f{self.base_url}/geo/city-lookup params {location: location, key: self.api_key} resp requests.get(url, paramsparams, timeout5) data resp.json() if data.get(code) ! 200: return None locations data.get(location, []) if not locations: return None return locations[0][id] def weather_now(self, location_id: str) - dict: url f{self.base_url}/weather/now params {location: location_id, key: self.api_key} resp requests.get(url, paramsparams, timeout5) data resp.json() if data.get(code) ! 200: raise RuntimeError(data.get(message, 天气接口返回异常)) return data[now] def weather_7d(self, location_id: str) - list[dict]: url f{self.base_url}/weather/7d params {location: location_id, key: self.api_key} resp requests.get(url, paramsparams, timeout5) data resp.json() if data.get(code) ! 200: raise RuntimeError(data.get(message, 天气接口返回异常)) return data[daily]这里有几个经验点timeout5是必须的。不设超时时间一旦网络异常线程会一直卡在requests.get()上无法恢复。city_lookup里取了locations[0]实际版我会建议结合adm1字段做筛选避免同名城市误匹配。接口返回code字段专门表示业务状态200才代表成功。就算 HTTP 状态码是 200业务code也可能是404、401必须判断。4.2 多线程刷新QThread Worker 的两种实现PySide6 里做多线程有几种方案QThread子类、QObject移入QThread、QtConcurrent。最直观的是继承QThread并重写run()方法但 Qt 官方更推荐“worker object moveToThread”的方式因为线程生命周期和对象生命周期更可控。我自己实际项目里两种都写过小工具用 QThread 子类就够了代码更短更容易理解。下面是一个具体的 Workerfrom PySide6.QtCore import QThread, Signal class WeatherWorker(QThread): succeeded Signal(str, dict) # 城市名, 实况数据 forecast_ready Signal(str, list) # 城市名, 7天数据 failed Signal(str) # 错误信息 def __init__(self, service: WeatherService, city_name: str, parentNone): super().__init__(parent) self.service service self.city_name city_name def run(self): try: location_id self.service.city_lookup(self.city_name) if not location_id: self.failed.emit(f未找到城市{self.city_name}) return now_data self.service.weather_now(location_id) forecast_list self.service.weather_7d(location_id) self.succeeded.emit(self.city_name, now_data) self.forecast_ready.emit(self.city_name, forecast_list) except Exception as e: self.failed.emit(f网络连接异常{e})在主窗口里这样调用def _on_search(self): city self.city_input.text().strip() if not city: return # 如果上一次请求还没完成先停下来避免同时发多个请求 if self.worker and self.worker.isRunning(): self.worker.quit() self.worker.wait(1000) self.update_info.setText(f正在查询 {city} ...) self.worker WeatherWorker(self.service, city, self) self.worker.succeeded.connect(self._update_now) self.worker.forecast_ready.connect(self._update_forecast) self.worker.failed.connect(self._show_error) self.worker.start()为什么不能在 UI 线程直接请求因为网络请求是阻塞型操作如果放在主线程主线程没法处理窗口重绘和鼠标事件界面就会“假死”。用户哪怕等三秒都会觉得软件坏了放后台线程之后界面可以继续显示“正在查询中”的状态提示体验完全不同。这里需要说明一点按钮点击后如果快速点了多次会有多个 Worker 同时运行。我的处理是在创建新 Worker 之前先检查旧 Worker 状态并且请求它停下来。虽然天气接口调用成本很低但不必要的并发请求既容易触发频率限制也容易让数据回包的顺序错乱。4.3 信号槽绑定与界面刷新回填Worker 跑完以后通过信号把数据传回主线程。我曾经犯过一个错误在run()方法里直接操作 UI 控件然后程序运行时不时崩溃。原因是子线程不能直接访问主线程创建的控件Qt 定时器与事件循环也是线程相关的。正确做法是把数据用信号传回主线程让主线程的槽函数执行 UI 更新。下面是很典型的槽函数def _update_now(self, city, now_data): self.city_label.setText(city) self.temp_label.setText(f{now_data[temp]}°C) self.text_label.setText(now_data[text]) detail_text ( f体感 {now_data[feelsLike]}°C / f湿度 {now_data[humidity]}% / f{now_data[windDir]} {now_data[windScale]}级 / f气压 {now_data[pressure]} hPa ) self.detail_label.setText(detail_text) self.update_info.setText(f更新于 {now_data[obsTime]})更新 7 天预报列表时要小心一个 Qt 布局陷阱重复调用self.forecast_layout.addWidget()不会自动清空上一个城市的数据需要先把旧的行移除干净。正确做法是遍历布局取出每一项并删除def _clear_layout(self, layout): while layout.count(): item layout.takeAt(0) widget item.widget() if widget: widget.deleteLater()不清理的话切换城市后旧数据会残留在界面上而且控件内存会一直累积窗口越用越卡。这个小坑在开发初期坑了我很久特地拿出来说一下。5. 锦上添花的功能24 小时趋势、7 日预报与预警5.1 多接口组合单次查询变成一套完整信息流天气应用只显示“现在是 24 度、多云”是不够的用户真正关心的是“今天下午会不会下雨”“明天降温多少度”。这就要把和风天气的多组接口组合起来用。我实际用到的接口包括接口作用主要返回字段/weather/now实况天气temp, feelsLike, humidity, windDir/weather/7d7天预报tempMax, tempMin, textDay, textNight/weather/24h24小时逐小时预报temp, text, windScale, precip/indices/1d生活指数穿衣、紫外线、运动等类别描述/warning/now气象预警预警类型、等级、标题、文本其中weather/24h接口返回的逐小时数组非常适合作成简单的温度折线趋势图。由于 PySide6 自带QtCharts模块可以直接画一个QLineSeries但增加一个模块会明显增大打包体积所以我最终选择用自定义绘制的方式重写一个窗口部件的paintEvent用QPainter画折线和圆点。这样既轻量又完全可控。5.2 用 QPainter 画 24 小时温度折线QPainter是 Qt 里的绘图引擎用起来像在画布上画图。绘制折线的基本逻辑是把温度值映射到画布纵坐标。把时间序列映射到横坐标。用QPainter.drawLine把相邻点连接起来。在关键点绘制小圆点和温度值文本。简化后的绘制代码如下from PySide6.QtWidgets import QWidget from PySide6.QtGui import QPainter, QPen, QColor from PySide6.QtCore import Qt, QPointF class TempTrendWidget(QWidget): def __init__(self): super().__init__() self.data [] # 元素格式: {hour: 14, temp: 26} def set_data(self, hourly_list): self.data hourly_list[:24] self.update() def paintEvent(self, event): painter QPainter(self) painter.setRenderHint(QPainter.Antialiasing) if not self.data: painter.drawText(self.rect(), Qt.AlignCenter, 暂无数据) return width, height self.width(), self.height() padding 30 temps [item[temp] for item in self.data] min_t, max_t min(temps), max(temps) span max(max_t - min_t, 1) points [] total len(self.data) for i, item in enumerate(self.data): x padding (width - padding * 2) * i / max(total - 1, 1) y height - padding - (item[temp] - min_t) / span * (height - padding * 2) points.append(QPointF(x, y)) # 画折线 pen QPen(QColor(#4a90e2), 2) painter.setPen(pen) for i in range(len(points) - 1): painter.drawLine(points[i], points[i 1]) # 画数据点 for point in points: painter.setBrush(QColor(#4a90e2)) painter.drawEllipse(point, 4, 4)这段代码用到两个关键经验setRenderHint(QPainter.Antialiasing)必须开启否则折线边缘会出现锯齿显得很粗糙。温度跨度很小的时候比如全天都是 24-27 度直接用原始温度计算纵坐标会导致折线非常“抖”。我在代码里人为抬高了span让最小跨度是 1避免除零也让曲线变化更明显。5.3 7 天预报列表和天气图标映射7 天预报我用一个纵向卡片列表展示每个卡片包含日期、白天天气图标、白天温度范围、夜间天气现象。这个结构在界面上会重复 7 次所以很适合写一个create_day_card(day_data)函数来生成控件。天气图标直接显示数字没有意义我在项目里放了一个icons目录用和风天气的icon编号拼接文件路径import os def get_icon_path(icon_code: str) - str: icon_file ficons/{icon_code}.png if os.path.exists(icon_file): return icon_file return icons/999.png # 未知天气的兜底图标整个过程并不复杂但有一个隐藏问题打包成 exe 之后icons目录的相对路径会失效。这个坑在下一章节详细说这里先留个引子。5.4 天气预警的处理策略和风天气的预警接口返回灾害性天气信息比如暴雨、大风、冰雹等。预警信息的展示需要谨慎不能像温度那样只显示一行字必须突出严重等级。我的做法是在窗口最顶部增加一个红色背景的横条如果当前城市有生效中的预警就显示“预警类型 等级”点击后弹出完整预警文本对话框。接口请求curl https://devapi.qweather.com/v7/warning/now?location101010100keyYOUR_API_KEYwarning字段中的每条记录包含title、typeName、severityColor等字段。severityColor的值可能是Red、Orange、Yellow、Blue对应不同等级的预警。这非常适合制作醒目的提示条def _update_warning(self, warning_list): if not warning_list: self.warning_bar.hide() return w warning_list[0] color_map { 红色: #d64545, 橙色: #e8933a, 黄色: #e8c93a, 蓝色: #5aa7e8, } self.warning_bar.setStyleSheet( fbackground-color: {color_map.get(w[severityColor], #d64545)}; fcolor: #ffffff; border-radius: 6px; padding: 8px; ) self.warning_bar.setText(f⚠ {w[title]} {w[text]}) self.warning_bar.show()6. 排错经验我踩过的坑和打包优化6.1 城市名匹配的细节同一座城市写法五花八门搜索框接收用户输入看起来很简单实际坑不少。用户可能输入“北京”、“北京市”、“beijing”、“Beijing”、“bj”都可能指同一个地方。如果只做精确匹配输入体验会很糟糕。我的处理方案是先调用city_lookup然后把返回结果里的名称、省份字段拼成候选列表再次弹出选择或者直接按匹配规则取最优。为了不引入复杂的模糊匹配算法最简单稳妥的方式是让城市查询接口自己处理因为它自带拼音和别名匹配能力。如果返回的location数组中第一项不是你想要的可以优先匹配adm1与你当前所在城市一致的项极大减少同名城市干扰。6.2 每小时定时刷新让数据自己保持新鲜天气数据是会过时的桌面应用需要自动刷新。我用QTimer设置 30 分钟刷新一次from PySide6.QtCore import QTimer class MainWindow(QMainWindow): def __init__(self): # 其他初始化... self.timer QTimer(self) self.timer.timeout.connect(self._refresh_current) self.timer.start(30 * 60 * 1000) # 毫秒30分钟这里要不要做更智能的“按日出日落刷新”我觉得没必要桌面天气应用不是气象系统30 分钟的粒度完全够用。你要注意的反而是在定时器触发的函数里加一个“当前是否有请求正在进行”的保护防止用户手动点击和自动刷新撞在一起连续发两个请求。6.3 PyInstaller 打包资源路径和体积问题打包是桌面应用最后一步也是常见问题重灾区。我用PyInstaller打包命令是pyinstaller --name WeatherApp --windowed --add-data style.qss;. --add-data icons;icons main.py这里最容易踩的坑是资源路径。在开发环境里open(style.qss)能正常工作但打包后工作目录变成了临时解压目录相对路径会失效。我的解决方式是写一个通用的资源路径函数import sys from pathlib import Path def resource_path(relative_path: str) - Path: base_path Path(getattr(sys, _MEIPASS, Path.cwd())) return base_path / relative_path然后所有读取资源文件的代码都换成with open(resource_path(style.qss), r, encodingutf-8) as f: app.setStyleSheet(f.read())sys._MEIPASS是 PyInstaller 在打包后自动设置的临时资源目录开发环境没有这个属性时回退到当前目录一举两得。打包体积方面PySide6 应用编译后通常在 40MB 以上这是 Qt 运行库的体积决定的。想压缩体积可以尝试--exclude-module排除用不到的 Qt 模块但收益有限。我的建议是没必要为了省体积去折腾 Tauri 重写40MB 的桌面天气应用完全在可接受范围内。6.4 程序在后台运行系统托盘和开机自启桌面角落工具类应用最好的形态不是始终显示一个窗口而是缩进系统托盘。PySide6 有现成的QSystemTrayIcon。我从窗口右上角关闭改成“关闭按钮最小化到托盘”之后这个应用才算真正融入了我的工作流。托盘菜单代码from PySide6.QtWidgets import QSystemTrayIcon, QMenu class MainWindow(QMainWindow): def create_tray_icon(self): self.tray_icon QSystemTrayIcon(self) self.tray_icon.setToolTip(桌面天气) menu QMenu() show_action menu.addAction(显示主窗口) refresh_action menu.addAction(刷新天气) quit_action menu.addAction(退出) menu.addSeparator() show_action.triggered.connect(self.show_main_window) refresh_action.triggered.connect(self._refresh_current) quit_action.triggered.connect(QApplication.instance().quit) self.tray_icon.setContextMenu(menu) self.tray_icon.activated.connect(self._on_tray_activated) self.tray_icon.show()还有开机自启Windows 上最直接的方式是往注册表启动项写一条命令。如果你嫌注册表操作太工程化也可以只生成“启动文件夹快捷方式”。这一步视需求而定不做也不影响正常使用。6.5 我踩过的其他坑汇总整理一下开发过程中印象比较深的几个问题方便大家避开API Key 泄露如果你准备把编译出来的程序发给朋友API Key 就暴露了。建议在 QWeather 后台把免费订阅的调用量限额设置得低一些即使被爬开也只能刷少量请求。温度数字不是 int天气接口返回的temp在 JSON 里是字符串24不是数字 24。做温度最值和绘图计算前先转成 int否则会出现字符串拼接的诡异 bug。UI 线程和子线程的错乱任何 UI 操作都必须是主线程。当你在子线程里收到数据后不要直接更新控件要使用信号槽机制这是 Qt 的铁律。切换城市时的旧数据残留无论用户切多快界面显示的数据都必须与当前城市一一对应。我增加了一个简单的“请求序号”机制每次发请求时自增编号回包时只有最新编号才会更新界面这样能彻底避免旧请求晚到导致的数据错位。QSS 不生效在 QSS 中类名选择器要区分大小写对象名引用用#。常见问题是写了Qframe#nowCard导致不生效正确的是QFrame#nowCard。把这个应用做完以后我的实际使用感受是桌面天气应用的价值不是替代手机天气而是让信息“常驻”在视线边缘。我不需要解锁手机不需要打开网页天气状况、未来趋势、预警信息都在副屏角落随时可见。每天开机它跟着启动缩在托盘里默默刷新遇到极端天气时弹出一条红条。整个过程零操作。如果你想继续扩展下一步的方向可以是做一个透明的桌面悬浮小球用颜色代表天气状况或者接入多个城市做对比视图又或者加一个简单的温度阈值提醒当温度变化超过设定范围时发出系统通知。这个项目的技术结构足够支撑这些扩展数据层和界面层的解耦让加新功能变得很干净。