大家好,我是程序员柏风,AI 最全的 Vibecoding 学习地图 http://coderoadmap.cn 作者。
综合练习:天气 App
用本 track 所学完成 天气 App:SwiftUI 界面 + MVVM + async API + 本地缓存。对照 /roadmaps/swift-ui 全路径验收。
学习目标
- 完成可运行的天气应用(搜索城市、展示温度与状况)
- 实践 MVVM 分层与 Repository 抽象
- 用 async/await 加载数据,@AppStorage 记住最近城市
- 规划下一步:接入真实 API 或上架 TestFlight
核心概念
项目结构
WeatherApp/
├── App/
│ └── WeatherAppApp.swift
├── Models/
│ └── Weather.swift
├── ViewModels/
│ └── WeatherViewModel.swift
├── Views/
│ ├── WeatherHomeView.swift
│ └── WeatherDetailCard.swift
├── Services/
│ ├── WeatherRepository.swift
│ └── MockWeatherAPI.swift
└── Tests/
└── WeatherViewModelTests.swift
Model 与 ViewModel
struct Weather: Identifiable, Codable, Equatable {
let id = UUID()
let city: String
let temp: Double
let condition: String
let humidity: Int
}
protocol WeatherRepositoryProtocol {
func fetch(city: String) async throws -> Weather
}
final class MockWeatherRepository: WeatherRepositoryProtocol {
func fetch(city: String) async throws -> Weather {
try await Task.sleep(nanoseconds: 400_000_000)
return Weather(city: city, temp: 21.0, condition: "多云", humidity: 65)
}
}
@MainActor
final class WeatherViewModel: ObservableObject {
@Published var weather: Weather?
@Published var cityInput = ""
@Published var isLoading = false
@Published var errorMessage: String?
private let repository: WeatherRepositoryProtocol
init(repository: WeatherRepositoryProtocol = MockWeatherRepository()) {
self.repository = repository
}
func search() async {
let city = cityInput.trimmingCharacters(in: .whitespaces)
guard !city.isEmpty else { return }
isLoading = true
errorMessage = nil
do {
weather = try await repository.fetch(city: city)
} catch {
errorMessage = "无法获取 \(city) 的天气"
}
isLoading = false
}
}
SwiftUI 主界面
struct WeatherHomeView: View {
@StateObject private var viewModel = WeatherViewModel()
@AppStorage("lastCity") private var lastCity = "北京"
var body: some View {
NavigationStack {
VStack(spacing: 24) {
HStack {
TextField("输入城市", text: $viewModel.cityInput)
.textFieldStyle(.roundedBorder)
Button("查询") {
Task { await viewModel.search() }
}
.disabled(viewModel.isLoading)
}
if viewModel.isLoading {
ProgressView("查询中…")
} else if let w = viewModel.weather {
WeatherDetailCard(weather: w)
} else {
ContentUnavailableView(
"暂无数据",
systemImage: "cloud.sun",
description: Text("输入城市名查询天气")
)
}
}
.padding()
.navigationTitle("天气")
.onAppear { viewModel.cityInput = lastCity }
.onChange(of: viewModel.weather?.city) { _, newCity in
if let newCity { lastCity = newCity }
}
.alert("错误", isPresented: .constant(viewModel.errorMessage != nil)) {
Button("确定") { viewModel.errorMessage = nil }
} message: {
Text(viewModel.errorMessage ?? "")
}
}
}
}
struct WeatherDetailCard: View {
let weather: Weather
var body: some View {
VStack(spacing: 12) {
Text(weather.city).font(.title.bold())
Text("\(weather.temp, specifier: "%.1f")°C")
.font(.system(size: 48, weight: .thin))
Text(weather.condition).foregroundStyle(.secondary)
Label("湿度 \(weather.humidity)%", systemImage: "humidity")
}
.frame(maxWidth: .infinity)
.padding()
.background(.ultraThinMaterial)
.clipShape(RoundedRectangle(cornerRadius: 16))
}
}
功能清单
| 功能 | 要求 |
|---|---|
| 城市搜索 | TextField + 查询按钮 |
| 天气展示 | 温度、状况、湿度卡片 |
| 加载态 | ProgressView |
| 错误处理 | alert 提示 |
| 记住城市 | @AppStorage lastCity |
| 架构 | MVVM + Repository Protocol |
| 测试 | ViewModel 至少 2 个单元测试 |
与 React/SwiftUI 对照
| 本练习 | React 等价 | SwiftUI 实现 |
|---|---|---|
| 搜索框 | controlled input | TextField + @Published |
| 加载态 | {loading && <Spinner />} | if isLoading { ProgressView } |
| 数据卡片 | 条件组件 | WeatherDetailCard |
| 持久化城市 | localStorage | @AppStorage |
| 异步查询 | fetch + useEffect | async ViewModel + .task |
| Mock API | MSW / mock fn | MockWeatherRepository |
选做:接入 OpenWeatherMap 等真实 API,Repository 内用 URLSession + Codable 替换 Mock。
练习清单
- 完成上述天气 App,模拟器可运行
- 编写 ViewModel 成功/失败单元测试
- 在 /roadmaps/swift-ui 标记已学节点
- 可选:替换 Mock 为真实 REST API
小结
SwiftUI track 闭环:语法 → 视图 → 状态 → 导航 → 并发 → 持久化 → MVVM → 天气 App。 下一步可深入 Combine、Widget 或 Apple 官方 Sample Code。见 swift-ui-essentials。