iT邦幫忙

2022 iThome 鐵人賽

DAY 11
0
Mobile Development

在 iOS 開發路上的大小事2系列 第 11

【在 iOS 開發路上的大小事2-Day11】MVC vs MVVM!MVVM 是什麼?能吃嗎?(下)

  • 分享至 

  • xImage
  •  

免責聲明?

這篇是以我自己理解的 MVVM 架構來改寫的
所以哪邊有理解錯誤的地方,再麻煩跟我說,感謝~

前情提要

在上一篇,已經有簡單介紹了 MVC 跟 MVVM 概念
【在 iOS 開發路上的大小事2-Day】MVC vs MVVM!MVVM 是什麼?能吃嗎?(上)

這篇就要來將以前寫的天氣 API App 改用 MVVM 架構來改寫

以前寫的天氣 API App (點我前往)

開改囉,才怪

開改前要先大概知道有哪些東西吧~

Model:天氣 API Response 的資料結構

ViewModel:天氣 API Request、UI 呈現的資料來源

View:嗯...就是 UI

這次是真的要開改了

首先是 Model 的部分

通常 API Response 都會是 JSON 格式,所以我們可以透過 Decodable 來解析

啊每個 Weather API Response 的格式都不一樣
所以就依照自己所用的天氣 API 來定義 Response 的 struct 就可以了~
這邊我是選擇使用 Open Weather API

所以我們的 Model 會長這樣

struct WeatherData: Decodable {
    var name: String
    var id: Int
    var dt: TimeInterval
    var coord: Coord
    var main: Main
    var weather: [Weather]
}

struct Coord: Decodable {
    var lon: Double // 經度
    var lat: Double // 緯度
}

struct Main: Decodable {
    var temp: Double
    var temp_min: Double
    var temp_max: Double
    var humidity: Int
}

struct Weather: Decodable {
    var icon: String
    var main: String
    var description: String
}

接著是 ViewModel 的部分

ViewModel 這邊我分成兩隻檔案
一個是與 UI 溝通的、一個是負責 API Service 的

負責 API Service 的

當 API Response 回傳後,會以 WeatherData 這個在 Model 定義的 struct 來進行 JSON Decode
成功 Decode 完之後,再透過 @escaping Closure 的方式回傳出去

class WeatherAPIService: NSObject {
    static let shared = WeatherAPIService()
    
    // MARK: 取得天氣資料
    
    func getWeatherData(city: String, finish: @escaping ((WeatherData) -> Void)) {
        let address = "https://api.openweathermap.org/data/2.5/weather?"
        let apikey = "xxxxxxxxxx你自己的 API KEYxxxxxxxxxx"
        if let url = URL(string: address + "q=\(city)" + "&appid=" + apikey) {
            URLSession.shared.dataTask(with: url) { (data, response, error) in
                if let error = error {
                    print("Error: \(error.localizedDescription)")
                } else if let response = response as? HTTPURLResponse, let data = data {
                    print("Status Code: \(response.statusCode)")
                    let decoder = JSONDecoder()
                    guard let weatherData = try? decoder.decode(WeatherData.self, from: data) else { return }
                    print("============== Weather Data ==============")
                    print(weatherData)
                    print("============== Weather Data ==============")
                    finish(weatherData) // 將 API Response 的資料結構 (Model) 也就是 WeatherData,透過 Closure 回傳給 ViewModel
                }
            }.resume()
        } else {
            print("無效的 URL")
        }
    }
}

與 UI 溝通的

/* 重點說明 */

// 用來當作 UIPickerView 的資料來源
cityList

// 來當發出 API Request 時,要將使用者所選城市轉換成對應的 URL 字串
cityListURL

// 用來建立 View 與 ViewModel 之間的溝通橋樑 (也就是 Data Binding 的部分)
mainViewControllerViewModelDelegate

// 用來發出 API Request,以及處理成功收到 API Response 後要做的事
func fetchWeatherData(city: String)
class MainViewControllerViewModel {
    
    let cityList = ["Taipei", "New Taipei", "Taoyuan", "Taichung", "Tainan", "Kaohsiung", "New York"]
    
    let cityListURL = ["Taipei", "New%20Taipei", "Taoyuan", "Taichung", "Tainan", "Kaohsiung", "New%20York"]
    
    var mainViewControllerViewModelDelegate: MainViewControllerViewModelDelegate?
    
    // 將 URL 支援的格式轉為常見的名稱
    func cityNameURLFormatter(city: String) -> String {
        switch city {
        case "Taipei": return "Taipei"
        case "New%20Taipei": return "New Taipei"
        case "Taoyuan": return "Taoyuan"
        case "Taichung": return "Taichung"
        case "Tainan": return "Tainan"
        case "Kaohsiung": return "Kaohsiung"
        case "New%20York": return "New York"
        default: return "No city has been selected"
        }
    }
    
    // 呼叫 ViewModel 的 WeatherAPIService 來執行 API 查詢
    func fetchWeatherData(city: String) {
        WeatherAPIService.shared.getWeatherData(city: city) { weatherData in
            self.mainViewControllerViewModelDelegate?.didFetchWeatherData(data: weatherData)
        }
    }
    
}

// MARK: - MainViewControllerViewModelDelegate
protocol MainViewControllerViewModelDelegate {
    func didFetchWeatherData(data: WeatherData) // API Response 回傳後要做的事情
}

最後是 View 的部分

View 這邊,我分成兩隻檔案
一個是用來顯示 Alert 的、一個是 UI 畫面

用來顯示 Alert 的

import UIKit

class Alert {
    func yesAlert(title: String?, message: String?, confirmTitle: String?, vc: UIViewController, completionHandler: (() -> Void)?) {
        let alertVC = UIAlertController(title: title, message: message, preferredStyle: .alert)
        let confirmAction = UIAlertAction(title: confirmTitle, style: .default) { action in
            completionHandler?()
        }
        alertVC.addAction(confirmAction)
        vc.present(alertVC, animated: true, completion: nil)
    }
}

UI 畫面

/* 重點說明 */

// 建立 MainViewControllerViewModel 的實例
mainViewControllerViewModel

// 將 ViewModel 裡面的 Delegate 委任給自己 (也就是 MainViewController)
// 讓自己去代理 ViewModel 的 Protocol
mainViewControllerViewModel.mainViewControllerViewModelDelegate = self

// 使用者選擇完城市,並按下「Start Search」按鈕時
// 呼叫 ViewModel 裡面的 func fetchWeatherData()
// 來進行天氣 API Request
mainViewControllerViewModel.fetchWeatherData(city: selectCityName!)

// 當 API Response 成功回傳後,透過 ViewModel 裡的 Delegate 來觸發 func didFetchWeatherData()
// 在 MainViewController 繼承 MainViewControllerViewModelDelegate
// 並實作 func didFetchWeatherData(),將查詢完的結果呈現在 UI 上
extension MainViewController: MainViewControllerViewModelDelegate {
    func didFetchWeatherData(data: WeatherData) {
        ...
    }
}

// UIPickerView 的資料來源從 ViewModel 裡的 cityList 取得
extension MainViewController: UIPickerViewDataSource {
    ...
    func pickerView(_ pickerView: UIPickerView, numberOfRowsInComponent component: Int) -> Int {
        return mainViewControllerViewModel.cityList.count
    }
}

// UIPickerView 內每一列的標題,跟選擇完的資料都從 ViewModel 中取得
extension MainViewController: UIPickerViewDelegate {
    func pickerView(_ pickerView: UIPickerView, didSelectRow row: Int, inComponent component: Int) {
        selectCityName = mainViewControllerViewModel.cityListURL[row]
        cityLabel.text = mainViewControllerViewModel.cityNameURLFormatter(city: selectCityName!)
    }
    
    func pickerView(_ pickerView: UIPickerView, titleForRow row: Int, forComponent component: Int) -> String? {
        return mainViewControllerViewModel.cityList[row]
    }
}
import UIKit

class MainViewController: UIViewController {
    
    @IBOutlet weak var cityLabel: UILabel!
    @IBOutlet weak var showPickerButton: UIButton!
    @IBOutlet weak var searchButton: UIButton!
    @IBOutlet weak var cityPickerView: UIPickerView!
    @IBOutlet weak var cityPickerViewBottomConstraint: NSLayoutConstraint!

    var mainViewControllerViewModel =  MainViewControllerViewModel()
    
    var isShowPicker: Bool = false
    
    var selectCityName: String?
    
    override func viewDidLoad() {
        super.viewDidLoad()

        cityPickerView.delegate = self
        
        cityPickerView.dataSource = self
        
        mainViewControllerViewModel.mainViewControllerViewModelDelegate = self
        
        cityLabel.text = "Please select the city you want to query"
        
        showPickerView(isShowPickerView: false) // 隱藏 PickerView
        
    }
    
    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        showPickerView(isShowPickerView: false) // 點空白處,關閉 PickerView
    }

    @IBAction func showPickerBtnClicked(_ sender: UIButton) {
        isShowPicker = !isShowPicker
        showPickerView(isShowPickerView: isShowPicker) // 顯示 PickerView
    }
    
    @IBAction func startSearchBtnClicked(_ sender: UIButton) {
        guard selectCityName != nil else {
            Alert().yesAlert(title: "Please select the city you want to query first", message: nil, confirmTitle: "Close", vc: self, completionHandler: nil)
            return
        }
        
        showPickerView(isShowPickerView: false)
        
        mainViewControllerViewModel.fetchWeatherData(city: selectCityName!)
    }
    
    func showPickerView(isShowPickerView: Bool) {
        if (isShowPickerView) {
            cityPickerViewBottomConstraint.constant = 0
            isShowPicker = !isShowPickerView
        } else {
            cityPickerViewBottomConstraint.constant = 300
        }
    }
    
}

extension MainViewController: MainViewControllerViewModelDelegate {
    
    func didFetchWeatherData(data: WeatherData) {
        DispatchQueue.main.async {
            let cityName = self.mainViewControllerViewModel.cityNameURLFormatter(city: self.selectCityName!)
            let lon = data.coord.lon
            let lat = data.coord.lat
            let temp = Int(data.main.temp / 10)
            let humidity = data.main.humidity
            let results = "City:\(cityName)\nLongitude:\(lon)\nLatitude:\(lat)\nTemperature:\(temp)°C\nHumidity:\(humidity)%"
            
            Alert().yesAlert(title: "Weather Results", message: results, confirmTitle: "Close", vc: self, completionHandler: nil)
        }
    }
    
}

extension MainViewController: UIPickerViewDataSource {
    
    func numberOfComponents(in pickerView: UIPickerView) -> Int {
        return 1
    }
    
    func pickerView(_ pickerView: UIPickerView, numberOfRowsInComponent component: Int) -> Int {
        return mainViewControllerViewModel.cityList.count
    }

}

extension MainViewController: UIPickerViewDelegate {
    
    func pickerView(_ pickerView: UIPickerView, didSelectRow row: Int, inComponent component: Int) {
        selectCityName = mainViewControllerViewModel.cityListURL[row]
        cityLabel.text = mainViewControllerViewModel.cityNameURLFormatter(city: selectCityName!)
    }
    
    func pickerView(_ pickerView: UIPickerView, titleForRow row: Int, forComponent component: Int) -> String? {
        return mainViewControllerViewModel.cityList[row]
    }
    
}

本篇的參考範例程式碼:GitHub

參考資料

  1. https://youtu.be/7HKi96v4X2A

上一篇
【在 iOS 開發路上的大小事2-Day10】MVC vs MVVM!MVVM 是什麼?能吃嗎?(上)
下一篇
【在 iOS 開發路上的大小事2-Day12】PhotoKit 好像很好玩 (1)
系列文
在 iOS 開發路上的大小事230
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言