###### tags: `第14屆IT邦鐵人賽文章` # 【在 iOS 開發路上的大小事2-Day11】MVC vs MVVM!MVVM 是什麼?能吃嗎?(下) ## 免責聲明? 這篇是以我自己理解的 MVVM 架構來改寫的 所以哪邊有理解錯誤的地方,再麻煩跟我說,感謝~ ## 前情提要 在上一篇,已經有簡單介紹了 MVC 跟 MVVM 概念 [【在 iOS 開發路上的大小事2-Day】MVC vs MVVM!MVVM 是什麼?能吃嗎?(上)](https://hackmd.io/@leoho0722/rkaj8tXH9) 這篇就要來將以前寫的天氣 API App 改用 MVVM 架構來改寫 以前寫的[天氣 API App (點我前往)](https://github.com/leoho0722/OpenWeatherAPI) ## 開改囉,才怪 開改前要先大概知道有哪些東西吧~ Model:天氣 API Response 的資料結構 ViewModel:天氣 API Request、UI 呈現的資料來源 View:嗯...就是 UI ## 這次是真的要開改了 ### 首先是 Model 的部分 通常 API Response 都會是 JSON 格式,所以我們可以透過 Decodable 來解析 啊每個 Weather API Response 的格式都不一樣 所以就依照自己所用的天氣 API 來定義 Response 的 struct 就可以了~ 這邊我是選擇使用 Open Weather API 所以我們的 Model 會長這樣 ```swift 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 的方式回傳出去 ```swift 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) ``` ```swift 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 的 ```swift 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] } } ``` ```swift 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](https://github.com/leoho0722/OpenWeatherAPI_MVVM) ## 參考資料 > 1. https://youtu.be/7HKi96v4X2A