###### 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