用 Python 做一个天气预报桌面小程序(附源码 + 打包与部署指导)

1. 目标与效果展示

  • 功能:输入城市名 → 点击查询 → 显示当前天气(温度、湿度、风速、天气描述)和未来 3 天的简要预报(日期、最高/最低温、天气图标)。

  • 运行平台:Windows / macOS / Linux(使用 PyInstaller 可打包成平台可执行文件)

  • 用户界面:简单明了,支持网络错误提示、输入校验与加载状态(避免界面卡死)。


2. 技术栈与准备工作

  • 语言:Python 3.8+

  • GUI:Tkinter(标准库,跨平台)

  • 网络请求:requests

  • 解析/数据处理:json、datetime

  • 图片处理(可选):Pillow(PIL)

  • 打包:PyInstaller

安装依赖:

复制代码
pip install requests pillow pyinstaller

3. 项目结构(建议)

复制代码
weather_app/
├── README.md
├── main.py               # 程序主入口(Tkinter)
├── weather_api.py        # 与天气 API 交互 (请求、解析)
├── utils.py              # 工具函数(时间格式、缓存等)
├── icons/                # 存放天气图标(可选)
├── tests/
│   └── test_api_parse.py
└── requirements.txt

4. 第一步:申请天气 API(使用 OpenWeatherMap)

我们示例中使用 OpenWeatherMap。

  1. 注册并登录 OpenWeatherMap(https://home.openweathermap.org/users/sign_up)。

  2. 在"API keys"中创建或拷贝你的 API Key(将其放在环境变量或配置文件中)。

  3. 我们会使用两个接口:

    • 当前天气:https://api.openweathermap.org/data/2.5/weather?q={city}&appid={API_KEY}&units=metric

    • 5天/3小时预报(用于取未来几天):https://api.openweathermap.org/data/2.5/forecast?q={city}&appid={API_KEY}&units=metric

注意:使用 units=metric 可直接得到摄氏度(℃),避免手动转换。


5. 第二步:实现核心功能(API 请求、解析)

将 API 请求逻辑封装在 weather_api.py 中,包含:

  • 请求当前天气与 forecast

  • 解析为我们需要的简洁结构(字典或 dataclass)

  • 基本缓存(短时间内重复查询同一城市,避免频繁请求)

下面是示例实现(完整源码会在后面提供):

复制代码
# weather_api.py (摘录)
import requests, time
BASE_WEATHER = "https://api.openweathermap.org/data/2.5/weather"
BASE_FORECAST = "https://api.openweathermap.org/data/2.5/forecast"

class WeatherClient:
    def __init__(self, api_key):
        self.api_key = api_key
        self._cache = {}  # 简单缓存: {city: (timestamp, data)}

    def get_current(self, city):
        params = {"q": city, "appid": self.api_key, "units": "metric"}
        r = requests.get(BASE_WEATHER, params=params, timeout=8)
        r.raise_for_status()
        return r.json()

    def get_forecast(self, city):
        params = {"q": city, "appid": self.api_key, "units": "metric"}
        r = requests.get(BASE_FORECAST, params=params, timeout=8)
        r.raise_for_status()
        return r.json()

    def fetch(self, city):
        # 封装:返回 current + 未来三天摘要
        cur = self.get_current(city)
        f = self.get_forecast(city)
        # 解析略...
        return parsed_data

解析策略(建议):

  • 当前天气:直接读 main.temp, main.humidity, wind.speed, weather[0].description

  • 未来几天:forecast 按时间点(3小时粒度)返回,按日期分组,计算每天最高/最低温,选择一天内比较频繁的天气描述作为该日天气。


6. 第三步:实现 GUI(Tkinter) --- 完整源码

下面给出一个能够直接运行的完整 main.py(含注释)。把 YOUR_API_KEY 替换为你自己的 API Key,或将其放到环境变量 OPENWEATHER_API_KEY

复制代码
# main.py
import tkinter as tk
from tkinter import ttk, messagebox
import threading
import requests
import os
from datetime import datetime, timedelta

API_KEY = os.environ.get("OPENWEATHER_API_KEY") or "YOUR_API_KEY"

BASE_WEATHER = "https://api.openweathermap.org/data/2.5/weather"
BASE_FORECAST = "https://api.openweathermap.org/data/2.5/forecast"

def safe_request(url, params):
    try:
        r = requests.get(url, params=params, timeout=8)
        r.raise_for_status()
        return r.json()
    except requests.RequestException as e:
        return {"error": str(e)}

def parse_current(data):
    if "error" in data:
        return None
    if data.get("cod") != 200 and data.get("cod") != "200":
        return None
    main = data.get("main", {})
    wind = data.get("wind", {})
    weather = data.get("weather", [{}])[0]
    return {
        "temp": main.get("temp"),
        "feels_like": main.get("feels_like"),
        "humidity": main.get("humidity"),
        "wind_speed": wind.get("speed"),
        "desc": weather.get("description"),
        "icon": weather.get("icon"),
        "city": data.get("name")
    }

def parse_forecast(data, days=3):
    if "error" in data or data.get("cod") != "200":
        return None
    # group by date
    by_date = {}
    for item in data.get("list", []):
        dt_txt = item.get("dt_txt")  # "2023-10-14 12:00:00"
        date_str = dt_txt.split(" ")[0]
        main = item.get("main", {})
        weather = item.get("weather", [{}])[0]
        entry = {
            "temp": main.get("temp"),
            "temp_min": main.get("temp_min"),
            "temp_max": main.get("temp_max"),
            "desc": weather.get("description"),
            "icon": weather.get("icon"),
            "dt_txt": dt_txt
        }
        by_date.setdefault(date_str, []).append(entry)

    # collect next `days` entries starting from tomorrow
    result = []
    today = datetime.utcnow().date()
    for i in range(1, days+1):
        d = (today + timedelta(days=i)).isoformat()
        if d in by_date:
            entries = by_date[d]
            temps = [e["temp"] for e in entries if e["temp"] is not None]
            if temps:
                tmin = min(e.get("temp_min", e["temp"]) for e in entries)
                tmax = max(e.get("temp_max", e["temp"]) for e in entries)
                # pick most common description
                desc = max(set([e["desc"] for e in entries]), key=[e["desc"] for e in entries].count)
                icon = entries[0]["icon"]
                result.append({"date": d, "min": tmin, "max": tmax, "desc": desc, "icon": icon})
    return result

class WeatherApp:
    def __init__(self, root):
        self.root = root
        root.title("天气预报小程序")
        root.geometry("480x420")
        root.resizable(False, False)

        frm = ttk.Frame(root, padding=12)
        frm.pack(fill=tk.BOTH, expand=True)

        top = ttk.Frame(frm)
        top.pack(fill=tk.X, pady=6)

        ttk.Label(top, text="城市:").pack(side=tk.LEFT)
        self.city_var = tk.StringVar(value="Seoul")
        self.city_entry = ttk.Entry(top, textvariable=self.city_var, width=20)
        self.city_entry.pack(side=tk.LEFT, padx=6)

        self.search_btn = ttk.Button(top, text="查询", command=self.on_search)
        self.search_btn.pack(side=tk.LEFT, padx=6)

        self.status_label = ttk.Label(frm, text="输入城市名后点击查询。", foreground="gray")
        self.status_label.pack(fill=tk.X, pady=6)

        # Current weather card
        self.cur_card = ttk.LabelFrame(frm, text="当前天气", padding=10)
        self.cur_card.pack(fill=tk.X, pady=6)

        self.cur_text = tk.StringVar(value="尚未查询")
        ttk.Label(self.cur_card, textvariable=self.cur_text, anchor="w", justify=tk.LEFT).pack(fill=tk.X)

        # Forecast list
        self.fore_card = ttk.LabelFrame(frm, text="未来三天", padding=10)
        self.fore_card.pack(fill=tk.BOTH, expand=True, pady=6)
        self.fore_list = tk.Listbox(self.fore_card, height=6)
        self.fore_list.pack(fill=tk.BOTH, expand=True)

    def on_search(self):
        city = self.city_var.get().strip()
        if not city:
            messagebox.showwarning("输入错误", "请输入城市名称。")
            return
        # start a thread to avoid UI freeze
        self.search_btn.config(state=tk.DISABLED)
        self.status_label.config(text="查询中...", foreground="blue")
        threading.Thread(target=self.fetch_weather, args=(city,), daemon=True).start()

    def fetch_weather(self, city):
        params = {"q": city, "appid": API_KEY, "units": "metric"}
        cur = safe_request(BASE_WEATHER, params)
        if "error" in cur:
            self.root.after(0, lambda: self.show_error(cur["error"]))
            return
        parsed_cur = parse_current(cur)
        if not parsed_cur:
            msg = cur.get("message", "无效的城市或 API 返回错误。")
            self.root.after(0, lambda: self.show_error(msg))
            return

        f = safe_request(BASE_FORECAST, params)
        parsed_fore = parse_forecast(f, days=3) if f and "error" not in f else None

        # update UI in main thread
        self.root.after(0, lambda: self.update_ui(parsed_cur, parsed_fore))

    def show_error(self, msg):
        self.status_label.config(text=f"查询失败:{msg}", foreground="red")
        messagebox.showerror("查询失败", f"发生错误:{msg}")
        self.search_btn.config(state=tk.NORMAL)

    def update_ui(self, cur, fore):
        s = (f"城市:{cur.get('city')}\n"
             f"天气:{cur.get('desc')}\n"
             f"温度:{cur.get('temp')} ℃(体感 {cur.get('feels_like')} ℃)\n"
             f"湿度:{cur.get('humidity')}%\n"
             f"风速:{cur.get('wind_speed')} m/s")
        self.cur_text.set(s)
        self.fore_list.delete(0, tk.END)
        if fore:
            for d in fore:
                line = f"{d['date']}  {d['desc']}  最高 {d['max']}℃  最低 {d['min']}℃"
                self.fore_list.insert(tk.END, line)
        else:
            self.fore_list.insert(tk.END, "无法获取预报信息。")
        self.status_label.config(text="查询完成。", foreground="green")
        self.search_btn.config(state=tk.NORMAL)

if __name__ == "__main__":
    root = tk.Tk()
    app = WeatherApp(root)
    root.mainloop()

说明与要点:

  • 使用 threading.Thread 把网络请求放到后台,避免 UI 卡死。

  • safe_request 捕获网络异常并返回统一结构,便于 UI 层判断。

  • parse_forecast 把 3 小时粒度的 forecast 聚合为每天的最高/最低温。

  • 你可以把 API_KEY 放在环境变量 OPENWEATHER_API_KEY 中,代码会优先读取。


7. 第四步:美化与图标处理(可选)

  • OpenWeatherMap 返回 icon 字段(如 01d),可通过 http://openweathermap.org/img/wn/{icon}@2x.png 获取对应图标。

  • 若想在 Tkinter 中显示图标,需要 Pillow

    复制代码
    from PIL import Image, ImageTk
    import io
    resp = requests.get(icon_url)
    img = Image.open(io.BytesIO(resp.content)).resize((64,64))
    tkimg = ImageTk.PhotoImage(img)
    label = tk.Label(..., image=tkimg)
    label.image = tkimg  # 保持引用
  • 注意:频繁下载图标会增加请求,建议先缓存图标到本地 icons/ 目录。


8. 第五步:打包为可执行文件(PyInstaller)

将程序交给不安装 Python 的用户使用时,可以用 PyInstaller 打包。

复制代码
# 进入项目目录
pyinstaller --onefile --windowed main.py
  • --onefile:生成单文件可执行文件(体积较大)

  • --windowed:在 Windows 上不弹出命令行窗口(GUI 用)

  • 生成文件位于 dist/main(Windows 为 main.exe

若有额外资源(如 icons)或依赖文件,建议用 --add-data 指定:

复制代码
pyinstaller --onefile --windowed --add-data "icons;icons" main.py

(在 Linux/macOS,分号 ; 改为冒号 :


9. 单元测试与异常处理

写一些基础测试,确保解析逻辑在 API 返回变动时有保护。例如 tests/test_api_parse.py

复制代码
import unittest
from weather_api import parse_current, parse_forecast

class TestParse(unittest.TestCase):
    def test_parse_current(self):
        sample = { "cod":200, "name":"Seoul", "main": {"temp":10, "feels_like":9, "humidity":80}, "wind":{"speed":3}, "weather":[{"description":"clear sky", "icon":"01d"}] }
        p = parse_current(sample)
        self.assertEqual(p['city'], 'Seoul')
        self.assertIn('temp', p)

    def test_parse_forecast(self):
        # 用小样本构造 list 项
        pass

if __name__ == "__main__":
    unittest.main()

运行测试:

复制代码
python -m unittest discover tests

异常处理要点:

  • 捕获 requests.RequestExceptionjson.decoder.JSONDecodeError

  • 对 API 返回的 codmessage 做校验,提示用户(如"城市未找到")。

  • 超时设置(如 timeout=8)避免长时间挂起。


10. 完整源码

weather_api.py

复制代码
# weather_api.py
import requests
from datetime import datetime, timedelta

BASE_WEATHER = "https://api.openweathermap.org/data/2.5/weather"
BASE_FORECAST = "https://api.openweathermap.org/data/2.5/forecast"

class WeatherClient:
    def __init__(self, api_key):
        self.api_key = api_key

    def _req(self, url, params):
        params.update({"appid": self.api_key, "units": "metric"})
        r = requests.get(url, params=params, timeout=8)
        r.raise_for_status()
        return r.json()

    def current_weather(self, city):
        try:
            return self._req(BASE_WEATHER, {"q": city})
        except Exception as e:
            return {"error": str(e)}

    def forecast(self, city):
        try:
            return self._req(BASE_FORECAST, {"q": city})
        except Exception as e:
            return {"error": str(e)}

# 你可以把解析函数放这里或 main.py 中

main.py

(见上面完整的 main.py 源码)

requirements.txt、

requests

pillow


11 结语与常见问题(FAQ)

Q1:API Key 不想暴露,怎么做?

A:不要把 Key 写死在代码中。把 Key 放环境变量(OPENWEATHER_API_KEY)或配置文件(不上传到 Git)中。打包时,使用运行时从外部读取(例如在程序首次运行时让用户输入并保存到本地文件)。

Q2:查询失败显示 city not found,怎么办?

A:请检查城市名拼写,也可以传 city,country_code(如 London,uk)来提高准确率。

Q3:如何把程序放到系统托盘常驻?

A:Tkinter 本身不支持系统托盘,需要结合第三方库(如 pystray)或使用 PyQt 来实现更完整的托盘功能。

相关推荐
小小王app小程序开发1 小时前
盲盒抽赏小程序爬塔玩法分析:技术实现 + 留存破局,打造长效抽赏生态
小程序
ftpeak2 小时前
《Rust+Slint:跨平台GUI应用》第八章 窗体
开发语言·ui·rust·slint
森语林溪2 小时前
大数据环境搭建从零开始(十七):JDK 17 安装与配置完整指南
java·大数据·开发语言·centos·vmware·软件需求·虚拟机
“负拾捌”2 小时前
LangChain提示词模版 PromptTemplate
python·langchain·prompt
合作小小程序员小小店2 小时前
web安全开发,在线%服务器日志入侵检测%系统安全开发,基于Python,flaskWeb,正则表达式检测,mysql数据库
服务器·python·安全·web安全·flask·安全威胁分析·安全架构
dreams_dream2 小时前
Django序列化器
后端·python·django
懷淰メ2 小时前
python3GUI--短视频社交软件 By:Django+PyQt5(前后端分离项目)
后端·python·django·音视频·pyqt·抖音·前后端
lsx2024062 小时前
HTML 音频(Audio)详解
开发语言
woshihonghonga2 小时前
【动手学深度学习】
开发语言·python