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。
-
注册并登录 OpenWeatherMap(https://home.openweathermap.org/users/sign_up)。
-
在"API keys"中创建或拷贝你的 API Key(将其放在环境变量或配置文件中)。
-
我们会使用两个接口:
-
当前天气:
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.RequestException、json.decoder.JSONDecodeError。 -
对 API 返回的
cod与message做校验,提示用户(如"城市未找到")。 -
超时设置(如
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 源码)
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 来实现更完整的托盘功能。