iT邦幫忙

2025 iThome 鐵人賽

DAY 27
0

昨天,我們成功地為天氣 App 打造了數據基礎。

今天,我們將從零開始,搭建顯示天氣資訊的 UI 畫面,並導入 MVVM 架構的大腦——ViewModel,將昨天建立的數據核心與使用者介面完美地連接起來,實現一個完整且職責分明的功能模組。

  • 困惑點:數據已經能從 API 獲取了,但我該如何設計一個能清晰展示多個天氣指標(溫度、天氣狀況、降雨機率等)的列表畫面?

  • AI 協作

    • 我的 Prompt我已經用 Codable 和 async/await 建立了一個網路服務層來獲取天氣數據。目前架構是MVVM。現在我要將這些數據顯示在 UITableView 上會有三個Cell。Cell中有五個Image View跟五個Label,其中一個Image View可以根據回傳回來的代碼改變。請簡單解釋我該怎麼做,並不需要給程式碼。

    AI 的回答:

    您好,這是一個很棒的架構設計。根據您目前的 MVVM 架構,我將說明如何將網路服務取得的天氣數據,有效地呈現在 UITableView 上。


    資料流概述

    整體流程可以簡化為以下幾個步驟:

    1. View Model 負責處理數據: ViewController 會呼叫 ViewModel 中的方法來發起網路請求。ViewModel 會使用您已經建立好的 async/await 網路服務層去取得天氣數據。
    2. 數據傳遞與綁定: 當網路請求完成,ViewModel 會將原始的網路數據(可能是 JSON)轉換成 UI 需要的格式,並將其儲存起來。ViewController 透過某種方式(例如:觀察者模式CombineSwiftUIObservableObject)來監聽 ViewModel 數據的變化。
    3. 更新 UI:ViewModel 的數據更新時,ViewController 會收到通知,並呼叫 tableView.reloadData() 或更精細的批次更新方法來刷新 UITableView
    4. Cell 的數據配置:UITableViewdataSource 方法 cellForRowAt 中,您會從 ViewModel 提供的數據源(例如一個陣列)取得對應的資料,然後將這些資料傳遞給自定義的 UITableViewCell
    5. Cell 內部處理顯示邏輯: UITableViewCell 會根據傳入的資料,來配置其內部的 UILabelUIImageView。特別是根據您提到的代碼,您可以在 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,您可以使用一個 switchif-else 語句,根據 weather.weatherCode 的值來設定 imageView.image = UIImage(named: "圖片名稱")。這能讓您將圖片選擇的邏輯封裝在 Cell 內部,保持 ViewController 的簡潔。

    這種架構可以有效分離關注點,讓您的數據處理(在 ViewModel 中)、UI 顯示(在 ViewController 中)、以及單個 Cell 的顯示邏輯(在 UITableViewCell 中)各自獨立,易於維護和擴展。

    • 根據 AI 的建議,我把詳細的實作步驟整理如下:
      1. 打造天氣資訊 Cell:從零建立 WeatherTableViewCell.swift.xib 檔案,並完成 UI 佈局。
      2. 設定天氣詳情頁:建立 AreaViewController,並在其中放置 UITableView 作為列表的容器。
      3. 建立 ViewModel 大腦:建立 AreaViewModel.swift,負責處理所有業務邏輯與狀態管理。
      4. 連接 View 與 ViewModel:在 AreaViewController 中,實現 ViewModel 的綁定,將數據流與 UI 徹底連接。

步驟一:打造天氣資訊 Cell (WeatherTableViewCell)

我們需要一個自定義的 Cell 來呈現每個時間區段的天氣資訊。

  1. Views 資料夾下建立一個 Cells 子資料夾,並在其中建立一個新的 Cocoa Touch Class,命名為 WeatherTableViewCell,記得勾選 Also create XIB file
  2. 打開 WeatherTableViewCell.xib,從元件庫中拖曳多個 UIImageViewUILabel,分別用來顯示時間、舒適度、天氣狀況、最低溫、最高溫和天氣圖示,其中天氣圖示先不指定,我們要用程式碼來判斷回傳的天氣代碼來改變天氣圖示的圖。
    天氣代碼可以參考這份文件

image

  1. 為所有元件設定好 Auto Layout 約束,確保版面整齊。
  2. 使用助理編輯器,將這些 UI 元件與 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 將作為顯示天氣列表的容器。

  1. Views 資料夾下建立一個 AreaView 子資料夾,並在其中建立一個 AreaViewController.swift.xib 檔案。
  2. 打開 AreaViewController.xib,拖曳一個 UITableView 到畫布上,並設定其 Auto Layout 約束,使其四邊都與安全區域對齊。
  3. UITableView 連結到 AreaViewController.swift,命名為 tbvWeather

步驟三:建立 ViewModel 大腦 (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)"
    }
}

步驟四:連接 View 與 ViewModel

最後,我們回到 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 的核心功能模組已經完成。明天,我們將打造一個互動式的地圖介面,讓使用者可以直觀地在地圖上選擇城市,並將這個主畫面與我們今天完成的天氣詳情頁完美地串連起來!


上一篇
Day 26 - 【天氣實戰 I】API 串接與數據模型:打造 App 的數據核心
系列文
iOS? AI-Yes!:用 Vide Coding 加速我的 Swift 學習曲線27
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言