昨天,我們成功地為天氣 App 打造了數據基礎。
今天,我們將從零開始,搭建顯示天氣資訊的 UI 畫面,並導入 MVVM 架構的大腦——ViewModel
,將昨天建立的數據核心與使用者介面完美地連接起來,實現一個完整且職責分明的功能模組。
困惑點:數據已經能從 API 獲取了,但我該如何設計一個能清晰展示多個天氣指標(溫度、天氣狀況、降雨機率等)的列表畫面?
AI 協作
我已經用 Codable 和 async/await 建立了一個網路服務層來獲取天氣數據。目前架構是MVVM。現在我要將這些數據顯示在 UITableView 上會有三個Cell。Cell中有五個Image View跟五個Label,其中一個Image View可以根據回傳回來的代碼改變。請簡單解釋我該怎麼做,並不需要給程式碼。
AI 的回答:
您好,這是一個很棒的架構設計。根據您目前的 MVVM 架構,我將說明如何將網路服務取得的天氣數據,有效地呈現在
UITableView
上。
資料流概述
整體流程可以簡化為以下幾個步驟:
- View Model 負責處理數據:
ViewController
會呼叫ViewModel
中的方法來發起網路請求。ViewModel
會使用您已經建立好的async/await
網路服務層去取得天氣數據。- 數據傳遞與綁定: 當網路請求完成,
ViewModel
會將原始的網路數據(可能是 JSON)轉換成 UI 需要的格式,並將其儲存起來。ViewController
透過某種方式(例如:觀察者模式、Combine
或SwiftUI
的ObservableObject
)來監聽ViewModel
數據的變化。- 更新 UI: 當
ViewModel
的數據更新時,ViewController
會收到通知,並呼叫tableView.reloadData()
或更精細的批次更新方法來刷新UITableView
。- Cell 的數據配置: 在
UITableView
的dataSource
方法cellForRowAt
中,您會從ViewModel
提供的數據源(例如一個陣列)取得對應的資料,然後將這些資料傳遞給自定義的UITableViewCell
。- Cell 內部處理顯示邏輯:
UITableViewCell
會根據傳入的資料,來配置其內部的UILabel
和UIImageView
。特別是根據您提到的代碼,您可以在 Cell 內部寫一個方法,根據這個代碼值來選擇並設定對應的圖片。
具體實作細節
1. View Model 的職責
- 創建一個天氣數據的模型(
WeatherModel
),包含所有您需要的屬性,例如溫度、濕度、天氣描述、天氣代碼等。- 在
ViewModel
中,宣告一個陣列來儲存這些WeatherModel
物件,例如var weatherData: [WeatherModel] = []
。- 提供一個公用方法,例如
fetchWeatherData()
,在這個方法中呼叫您的網路服務,並在成功後將返回的數據解析並轉換成[WeatherModel]
陣列,然後更新weatherData
。2. View Controller 的職責
- 在
ViewController
中,初始化您的ViewModel
。- 在
viewDidLoad()
中,呼叫viewModel.fetchWeatherData()
。- 實現
UITableViewDataSource
協定。在numberOfRowsInSection
中,回傳viewModel.weatherData.count
。- 在
cellForRowAt
中,透過let weather = viewModel.weatherData[indexPath.row]
來取得對應的數據,然後將weather
物件傳給您的自定義UITableViewCell
。3. 自定義 UITableViewCell 的職責
- 在 Cell 中宣告五個
UIImageView
和五個UILabel
。- 建立一個配置方法,例如
configure(with weather: WeatherModel)
。- 在這個方法中,根據
weather
物件中的屬性來設定對應的label.text
。- 針對需要根據代碼變化的
UIImageView
,您可以使用一個switch
或if-else
語句,根據weather.weatherCode
的值來設定imageView.image = UIImage(named: "圖片名稱")
。這能讓您將圖片選擇的邏輯封裝在 Cell 內部,保持ViewController
的簡潔。這種架構可以有效分離關注點,讓您的數據處理(在
ViewModel
中)、UI 顯示(在ViewController
中)、以及單個 Cell 的顯示邏輯(在UITableViewCell
中)各自獨立,易於維護和擴展。
WeatherTableViewCell
的 .swift
和 .xib
檔案,並完成 UI 佈局。AreaViewController
,並在其中放置 UITableView
作為列表的容器。AreaViewModel.swift
,負責處理所有業務邏輯與狀態管理。AreaViewController
中,實現 ViewModel
的綁定,將數據流與 UI 徹底連接。WeatherTableViewCell
)我們需要一個自定義的 Cell 來呈現每個時間區段的天氣資訊。
Views
資料夾下建立一個 Cells
子資料夾,並在其中建立一個新的 Cocoa Touch Class,命名為 WeatherTableViewCell
,記得勾選 Also create XIB file
。WeatherTableViewCell.xib
,從元件庫中拖曳多個 UIImageView
和 UILabel
,分別用來顯示時間、舒適度、天氣狀況、最低溫、最高溫和天氣圖示,其中天氣圖示先不指定,我們要用程式碼來判斷回傳的天氣代碼來改變天氣圖示的圖。WeatherTableViewCell.swift
連結成 IBOutlet
。檔案:Views/Cells/WeatherTableViewCell.swift
import UIKit
class WeatherTableViewCell: UITableViewCell {
@IBOutlet weak var lbTime: UILabel!
@IBOutlet weak var lbCI: UILabel!
@IBOutlet weak var lbWx: UILabel!
@IBOutlet weak var lbMinT: UILabel!
@IBOutlet weak var lbMaxT: UILabel!
@IBOutlet weak var imgvWx: UIImageView!
override func awakeFromNib() {
super.awakeFromNib()
}
override func setSelected(_ selected: Bool, animated: Bool) {
super.setSelected(selected, animated: animated)
}
}
AreaViewController
)這個 ViewController
將作為顯示天氣列表的容器。
Views
資料夾下建立一個 AreaView
子資料夾,並在其中建立一個 AreaViewController
的 .swift
和 .xib
檔案。AreaViewController.xib
,拖曳一個 UITableView
到畫布上,並設定其 Auto Layout 約束,使其四邊都與安全區域對齊。UITableView
連結到 AreaViewController.swift
,命名為 tbvWeather
。AreaViewModel
)檔案:ViewModels/AreaViewModel.swift
import Foundation
import UIKit
// MARK: - View State Enum
enum ViewState {
case loading
case success
case failure(String)
}
// MARK: - Weather Cell ViewModel
struct WeatherCellViewModel {
let time: String
let comfort: String
let weatherCondition: String
let minTemperature: String
let maxTemperature: String
let weatherIconName: String
}
// MARK: - Area ViewModel
class AreaViewModel {
// MARK: - Properties
private let weatherService = WeatherService.shared
var viewState: ((ViewState) -> Void)?
private(set) var cellViewModels: [WeatherCellViewModel] = []
// 天氣代碼與 SF Symbol 圖示名稱的對應字典
private let weatherCodeMap: [String: String] = [
// MARK: - 晴天類 (Clear)
"1": "sun.max.circle", // 晴天
// MARK: - 多雲類 (Cloudy)
"2": "cloud.sun.circle", // 晴時多雲
"3": "cloud.sun.circle", // 多雲時晴
"4": "cloud.circle", // 多雲
"5": "cloud.circle", // 多雲時陰
"6": "cloud.circle", // 陰時多雲
"7": "cloud.circle", // 陰天
// MARK: - 下雨類 (Rain)
"8": "cloud.drizzle.circle", // 多雲短暫雨/陣雨 (小雨)
"9": "cloud.drizzle.circle", // 多雲時陰短暫雨/陣雨
"10": "cloud.drizzle.circle", // 陰時多雲短暫雨/陣雨
"11": "cloud.rain.circle", // 雨天/陰短暫雨 (雨)
"12": "cloud.rain.circle", // 多雲時陰有雨/陣雨
"13": "cloud.rain.circle", // 陰時多雲有雨/陣雨
"14": "cloud.heavyrain.circle", // 陰有雨/陣雨 (大雨)
"19": "cloud.rain.circle", // 晴午後多雲局部雨
"20": "cloud.rain.circle", // 多雲午後局部雨
"29": "cloud.rain.circle", // 多雲局部雨
"30": "cloud.rain.circle", // 多雲時陰局部雨/陰局部雨
"38": "cloud.rain.circle", // 短暫陣雨/雨有霧
"39": "cloud.heavyrain.circle", // 有雨/陣雨有霧 (大雨)
// MARK: - 雷雨類 (Thunderstorm)
"15": "cloud.bolt.rain.circle", // 多雲短暫陣雨或雷雨
"16": "cloud.bolt.rain.circle", // 多雲時陰短暫陣雨或雷雨
"17": "cloud.bolt.rain.circle", // 陰時多雲有雷陣雨
"18": "cloud.bolt.rain.circle", // 陰有陣雨或雷雨
"21": "cloud.bolt.rain.circle", // 晴午後多雲雷陣雨
"22": "cloud.bolt.rain.circle", // 多雲午後局部雷陣雨
"33": "cloud.bolt.rain.circle", // 多雲局部陣雨或雷雨
"34": "cloud.bolt.rain.circle", // 多雲時陰局部陣雨或雷雨
"35": "cloud.bolt.rain.circle", // 多雲有陣雨或雷雨有霧
"36": "cloud.bolt.rain.circle", // 多雲時陰有陣雨或雷雨有霧
"41": "cloud.bolt.rain.circle", // 短暫陣雨或雷雨有霧
// MARK: - 下雪類 (Snow)
"23": "cloud.snow.circle", // 雨或雪
"42": "wind.snow", // 暴風雪
// MARK: - 起霧類 (Fog)
"24": "cloud.fog.circle", // 晴有霧
"25": "cloud.fog.circle", // 晴時多雲有霧
"26": "cloud.fog.circle", // 多雲時晴有霧
"27": "cloud.fog.circle", // 多雲有霧
"28": "cloud.fog.circle", // 陰有霧
"31": "cloud.fog.circle", // 多雲有霧有局部雨
"32": "cloud.fog.circle", // 多雲時陰有霧有局部雨
"37": "cloud.fog.circle" // 局部雨或雪有霧
]
// MARK: - Function
func fetchWeather(for area: String) {
viewState?(.loading)
Task {
do {
let weatherData = try await weatherService.fetchWeatherData(for: area)
await MainActor.run {
self.processWeatherData(weatherData, for: area)
self.viewState?(.success)
}
} catch {
await MainActor.run {
let errorMessage = self.convertErrorToUserMessage(error)
self.viewState?(.failure(errorMessage))
}
}
}
}
private func processWeatherData(_ weatherData: WeatherData, for area: String) {
guard let locationData = weatherData.records.location.first(where: { $0.locationName == area }) else {
cellViewModels = []
return
}
guard let wxElement = locationData.weatherElement.first(where: { $0.elementName == "Wx" }),
let popElement = locationData.weatherElement.first(where: { $0.elementName == "PoP" }),
let maxTElement = locationData.weatherElement.first(where: { $0.elementName == "MaxT" }),
let minTElement = locationData.weatherElement.first(where: { $0.elementName == "MinT" }),
let ciElement = locationData.weatherElement.first(where: { $0.elementName == "CI" }) else {
cellViewModels = []
return
}
let maxCount = min(wxElement.time.count, popElement.time.count, maxTElement.time.count, minTElement.time.count, ciElement.time.count)
cellViewModels = (0..<maxCount).map { index in
let wxTimeData = wxElement.time[index]
let popTimeData = popElement.time[index]
let maxTTimeData = maxTElement.time[index]
let minTTimeData = minTElement.time[index]
let ciTimeData = ciElement.time[index]
let weatherName = wxTimeData.parameter.parameterName
let weatherCode = wxTimeData.parameter.parameterValue ?? ""
let popValue = popTimeData.parameter.parameterName
let maxTemp = maxTTimeData.parameter.parameterName
let minTemp = minTTimeData.parameter.parameterName
let comfort = ciTimeData.parameter.parameterName
let startTime = formatTime(wxTimeData.startTime)
let endTime = formatTime(wxTimeData.endTime)
let timeString = "\(startTime) - \(endTime)"
let weatherCondition = "\(weatherName) (降雨機率 \(popValue)%)"
let iconName = getWeatherIconName(fromCode: weatherCode)
return WeatherCellViewModel(
time: timeString,
comfort: comfort,
weatherCondition: weatherCondition,
minTemperature: "\(minTemp)°C",
maxTemperature: "\(maxTemp)°C",
weatherIconName: iconName
)
}
}
private func getWeatherIconName(fromCode code: String) -> String {
return weatherCodeMap[code] ?? "questionmark.circle"
}
private func formatTime(_ timeString: String) -> String {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
if let date = formatter.date(from: timeString) {
formatter.dateFormat = "MM/dd HH:mm"
return formatter.string(from: date)
}
return timeString
}
private func convertErrorToUserMessage(_ error: Error) -> String {
if let networkError = error as? NetworkError {
return networkError.localizedDescription
}
return "發生未知錯誤:\(error.localizedDescription)"
}
}
最後,我們回到 AreaViewController
,將它與 ViewModel
連接起來。
檔案:Views/AreaView/AreaViewController.swift
import UIKit
class AreaViewController: UIViewController {
// MARK: - IBOutlets
@IBOutlet weak var tbvWeather: UITableView!
// MARK: - Properties
var selectedArea: String?
private let viewModel = AreaViewModel()
private let activityIndicator = UIActivityIndicatorView(style: .large)
// MARK: - Lifecycle
override func viewDidLoad() {
super.viewDidLoad()
setupUI()
bindViewModel()
if let area = selectedArea {
viewModel.fetchWeather(for: area)
}
}
// MARK: - UI Setup
func setupUI() {
view.backgroundColor = .systemBackground
tbvWeather.dataSource = self
tbvWeather.delegate = self
let nib = UINib(nibName: "WeatherTableViewCell", bundle: nil)
tbvWeather.register(nib, forCellReuseIdentifier: "WeatherCell")
navigationItem.rightBarButtonItem = UIBarButtonItem(title: "完成", style: .done, target: self, action: #selector(dismissVC))
// 設定 Activity Indicator
activityIndicator.center = view.center
activityIndicator.color = .systemBlue
view.addSubview(activityIndicator)
}
// MARK: - ViewModel Binding
private func bindViewModel() {
viewModel.viewState = { [weak self] state in
DispatchQueue.main.async {
switch state {
case .loading:
self?.activityIndicator.startAnimating()
self?.tbvWeather.isHidden = true
self?.title = "載入中..."
case .success:
self?.activityIndicator.stopAnimating()
self?.tbvWeather.isHidden = false
self?.title = self?.selectedArea
self?.tbvWeather.reloadData()
case .failure(let errorMessage):
self?.activityIndicator.stopAnimating()
self?.tbvWeather.isHidden = true
self?.title = "載入失敗"
self?.showErrorAlert(message: errorMessage)
}
}
}
}
// MARK: - Actions
@objc func dismissVC() {
self.dismiss(animated: true, completion: nil)
}
// MARK: - Helper Methods
private func showErrorAlert(message: String) {
let alert = UIAlertController(title: "錯誤", message: message, preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "確定", style: .default))
present(alert, animated: true)
}
}
// MARK: - UITableView DataSource & Delegate
extension AreaViewController: UITableViewDataSource, UITableViewDelegate {
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return viewModel.cellViewModels.count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "WeatherCell", for: indexPath) as! WeatherTableViewCell
let cellViewModel = viewModel.cellViewModels[indexPath.row]
// 使用 ViewModel 中已格式化的資料
cell.lbTime.text = cellViewModel.time
cell.lbCI.text = cellViewModel.comfort
cell.lbWx.text = cellViewModel.weatherCondition
cell.lbMinT.text = cellViewModel.minTemperature
cell.lbMaxT.text = cellViewModel.maxTemperature
cell.imgvWx.image = UIImage(systemName: cellViewModel.weatherIconName)
cell.imgvWx.tintColor = .systemBlue
return cell
}
}
今天,我們打造了顯示天氣資訊的 UI 介面,ViewController
現在只負責單純的顯示工作,所有複雜的業務邏輯和狀態管理,都交給了 ViewModel
處理。
App 的核心功能模組已經完成。明天,我們將打造一個互動式的地圖介面,讓使用者可以直觀地在地圖上選擇城市,並將這個主畫面與我們今天完成的天氣詳情頁完美地串連起來!