一、引言
本文把一个MVC的工程通过重构来使其转变为MVVM的架构,并且在其中学习到MVVM的子组件以及其优势。
二、MVVM简介
模型-视图-视图模型 (MVVM) 是近年来在 iOS 开发社区中受到关注的一种设计模式。它涉及一个称为视图模型的新概念。 在 iOS 应用中,视图模型(ViewModel)与视图控制器紧密相关。
MVVM架构
像上图展示的那样,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,并进行替换。
App运行界面
App的文件结构
在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好测试多了)
网友评论