综合练习
天气 App(SwiftUI + async API)。
40 分钟
综合练习:天气 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))
}
}