一、引言
本文把一個MVC的工程通過重構來使其轉變爲MVVM的架構,並且在其中學習到MVVM的子組件以及其優勢。
二、MVVM簡介
模型-視圖-視圖模型 (MVVM) 是近年來在 iOS 開發社區中受到關注的一種設計模式。它涉及一個稱爲視圖模型的新概念。 在 iOS 應用中,視圖模型(ViewModel)與視圖控制器緊密相關。
像上圖展示的那樣,MVVM模式包含3個層次:
- Model:App所需要的數據;與MVC相比,此層沒有發生變化
- View:用戶界面視覺元素, 在iOS中,視圖控制器與視圖的概念是分不開的;與MVC相比,此層包含MVC層中的ViewController
- ViewModel:負責處理Model與View層之間的交互;與MVC相比,此層大部分內容是從MVC模式中的Controller層剝離出來的內容;ViewModel的職責包括
- Model 的輸入:處理VIew的輸入並且更新Model
- Model的輸出:把Model的輸出傳遞給ViewController
- 格式化:把Model的數據以供ViewController顯示
與MVC模式相比,MVVM具有以下優勢:
- 降低複雜性:MVVM通過把大量業務邏輯從View Controller遷出而是它變得簡單
- 富有表現力:ViewModel能夠更好的表達了View的業務邏輯
- 增強可測性:視圖模型比視圖控制器更容易測試,無需擔心視圖實現即可測試業務邏輯
在MVVM模式中,ViewControler具有較大的變化:
- 在MVC模式中ViewController居於核心位置,其負責驅動整個模式的運轉,承擔的是控制器的工作
- 在MVVM中ViewController的重要性大幅減低,其轉變爲View的Contoller(控制器)——只用於控制VIew、把View的輸入傳遞給View Model
三、從MVC重構爲MVVM
3.1 熟悉原工程
請下載示例工程並打開Begin文件夾中的工程。此App從weatherbit.io獲取最新的信息並展現一些基礎天氣信息。爲了從weatherbit.io獲取天氣信息,需要在 https://www.weatherbit.io/account/create進行註冊。註冊後會獲得一個Key,並進行替換。
在Controllers文件夾的WeatherViewController.swift文件是我們本次重構的重點。Utilities、View Models文件夾目前是空的,我們通過重構來把此文件夾填滿。
// WeatherViewController.swift文件的私有屬性
// geocoder 接受一個字符串輸入,例如華盛頓特區,並將其轉換爲緯度和經度,然後發送給氣象服務。
private let geocoder = LocationGeocoder()
// 默認的區域
private let defaultAddress = "McGaheysville, VA"
// 格式化日期用於顯示
private let dateFormatter: DateFormatter = {
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "EEEE, MMM d"
return dateFormatter
}()
// 格式化溫度
private let tempFormatter: NumberFormatter = {
let tempFormatter = NumberFormatter()
tempFormatter.numberStyle = .none
return tempFormatter
}()
override func viewDidLoad() {
// 通過默認地址來獲取對應的經緯度
geocoder.geocode(addressString: defaultAddress) { [weak self] locations in
guard let self = self, let location = locations.first else {
return
}
// 獲得經緯度所制定的名字
self.cityLabel.text = location.name
// 使用經緯度來獲取數據
self.fetchWeatherForLocation(location)
}
}
func fetchWeatherForLocation(_ location: Location) {
// 調用WeatherbitService服務,並且把經緯度傳遞給它
WeatherbitService.weatherDataForLocation(
latitude: location.latitude,
longitude: location.longitude) { [weak self] (weatherData, error) in
//2 更新View
guard let self = self, let weatherData = weatherData else {
return
}
self.dateLabel.text = self.dateFormatter.string(from: weatherData.date)
self.currentIcon.image = UIImage(named: weatherData.iconName)
let temp = self.tempFormatter.string(from: weatherData.currentTemp as NSNumber) ?? "")
self.currentSummaryLabel.text = "\(weatherData.description) - \(temp)℉"
self.forecastSummary.text = "\nSummary: \(weatherData.description)"
}
}
3.2 重構
3.2.1 使用Box類型來實現數據綁定
在 MVVM中,需要一種將視圖模型輸出綁定到視圖的方法。 爲此,需要使用方式來實現此種功能:提供一種簡單的機制來將視圖綁定到視圖模型的輸出值。 有幾種方法可以進行此類綁定:
- KVO:一種使用鍵路徑觀察屬性並在該屬性更改時獲取通知的機制。
- Functional Reactive Programming(FRP):將事件和數據作爲流處理的範式。 Apple 新的Combine框架是一種FRP方法; RxSwift 和 ReactiveSwift 是 FRP 的另外兩個流行框架。
- Delegation:使用代理方法在Model值發生變化時進行通知
- Boxing:使用屬性觀察的方式在值發生變化時通知觀察者
// 在Utilities文件夾創建Box.swift文件
final class Box<T> {
// 定義一個通知方法的類型
typealias Listener = (T) -> Void
var listener: Listener?
// 在值發生變化時通知觀察者
var value: T {
didSet {
listener?(value)
}
}
// 使用值初始化此Box對象
init(_ value: T) {
self.value = value
}
// 在值和觀察者之間建立綁定並通知觀察者
func bind(listener: Listener?) {
self.listener = listener
listener?(value)
}
}
3.2.2 創建WeatherViewModel
現在已經建立了在視圖和視圖模型之間進行數據綁定的機制,可以開始構建的視圖模型。 在MVVM中,視圖控制器不調用任何服務或操作任何模型類型,該責任完全由視圖模型承擔。
通過把與地理編碼器和 Weatherbit 服務相關的代碼從 WeatherViewController 移動到 WeatherViewModel 來開始重構。 然後,將視圖綁定到 WeatherViewController 中的視圖模型屬性。
首先,在View Models文件夾創建WeatherViewModel.swift文件。
// 普通情況下不得在ViewModel裏引用UIKit,此處需要使用圖片,所以僅引入UIKit中的UIImage類型
import UIKit.UIImage
// 把此類型設置爲public是爲了能夠進行單元測試
public class WeatherViewModel {
}
第2步,修改WeatherViewController.swift。
// 在WeatherViewController.swift文件中添加一下屬性
private let viewModel = WeatherViewModel()
第3步,把LocationGeocoder的相關邏輯遷移到WeatherViewModel.swift。
// 把defaultAddress遷移到WeatherViewModel
// 把geocoder遷移到WeatherViewModel
// 在WeatherViewModel添加一個新屬性
let locationName = Box("Loading...")
// 在WeatherViewModel中添加以下函數
func changeLocation(to newLocation: String) {
locationName.value = "Loading..."
geocoder.geocode(addressString: newLocation) { [weak self] locations in
guard let self = self else { return }
if let location = locations.first {
self.locationName.value = location.name
self.fetchWeatherForLocation(location)
return
}
}
}
第4步,修改WeatherViewController.swift。
// 把原來的viewDidLoad方法使用以下代碼進行替換
override func viewDidLoad() {
viewModel.locationName.bind { [weak self] locationName in
self?.cityLabel.text = locationName
}
}
第5步,修改WeatherViewModel.swift。
// 添加初始化方法
init() {
changeLocation(to: Self.defaultAddress)
}
// 添加獲取數據的方法(目前方法爲空)
private func fetchWeatherForLocation(_ location: Location) {
WeatherbitService.weatherDataForLocation(
latitude: location.latitude,
longitude: location.longitude) {
[weak self] (weatherData, error) in
guard let self = self, let weatherData = weatherData else {
return
}
}
}
3.2.3 格式化數據
在 MVVM 中,視圖模型始終負責格式化來自服務和模型類型的數據以呈現在視圖中。
// 第1步:把dateFormatter遷移到ViewModel中
// 第2步:在ViewModel中添加新的綁定
let date = Box(" ")
// 第3步:在ViewModel中WeatherViewModel.fetchWeatherForLocation(_:)函數的閉包的尾部添加以下代碼
self.date.value = self.dateFormatter.string(from: weatherData.date)
// 第4步:在WeatherViewController.viewDidLoad()的尾部添加以下綁定
viewModel.date.bind { [weak self] date in
self?.dateLabel.text = date
}
// 第5步:把tempFormatter從WeatherViewController遷移到WeatherViewModel
// 第6步:在WeatherViewModel添加以下綁定
let icon: Box<UIImage?> = Box(nil) //no image initially
let summary = Box(" ")
let forecastSummary = Box(" ")
// 第7步:在WeatherViewController.viewDidLoad()添加綁定代碼
viewModel.icon.bind { [weak self] image in
self?.currentIcon.image = image
}
viewModel.summary.bind { [weak self] summary in
self?.currentSummaryLabel.text = summary
}
viewModel.forecastSummary.bind { [weak self] forecast in
self?.forecastSummary.text = forecast
}
// 第8步:更新Box的值(WeatherViewModel.fetchWeatherForLocation(_:))
self.icon.value = UIImage(named: weatherData.iconName)
let temp = self.tempFormatter
.string(from: weatherData.currentTemp as NSNumber) ?? ""
self.summary.value = "\(weatherData.description) - \(temp)℉"
self.forecastSummary.value = "\nSummary: \(weatherData.description)"
// 第9步:更新WeatherViewModel.changeLocation(to:)的代碼
self.locationName.value = "Not found"
self.date.value = ""
self.icon.value = nil
self.summary.value = ""
self.forecastSummary.value = ""
3.3 添加功能
目前,此App只能展示固定位置的天氣信息,在本小節,我們將通過添加功能的方式來實現——切換不同地區都可以顯示其對應天氣的功能。
在此圖的左上角箭頭位置是一個按鈕,添加其點擊事件,並添加如下代碼:
// 創建一個alert
let alert = UIAlertController(
title: "Choose location",
message: nil,
preferredStyle: .alert)
alert.addTextField()
// 添加一個alertaction
let submitAction = UIAlertAction(
title: "Submit",
style: .default) { [unowned alert, weak self] _ in
// 更新位置,從而觸發天氣的更新
guard let newLocation = alert.textFields?.first?.text else { return }
self?.viewModel.changeLocation(to: newLocation)
}
alert.addAction(submitAction)
// 展示alert
present(alert, animated: true)
四、MVVM總結
通過以上的簡單實例,我們已經通過把MVC的模型轉換爲MVVM模型。從中我們可以看到MVVM具有以下優勢:
- 降低複雜性(減輕胖controller)的功能
- 拆分業務邏輯:把不同的代碼遷移到不同的文件中(把業務邏輯遷移到ViewModel中、淨化ViewController)
- 可維護(代碼簡單了、沒有那麼大模塊了)
- 可測試(ViewModel比ViewController好測試多了)