iOS MVVM教程:从MVC重构

1,154 阅读14分钟

翻译地址

模型-视图-视图模型(MVVM)是近年来在iOS开发社区中得到广泛应用的一种设计模式。它涉及一个新概念,称为视图模型。在iOS应用程序中,视图模型是视图控制器的伴生对象。

如上所述,MVVM模式由三个层组成:

  • 模型应用程序操作的应用程序数据。
  • 视点用户界面的可视元素。在IOS中,视图控制器与视图的概念密不可分。
  • 视图模型从视图输入更新模型,从模型输出更新视图。

MVVM比模型-视图-控制器,或MVC,这是监督办的实际做法:

  • 降低复杂度MVVM通过将许多业务逻辑移出视图控制器,使视图控制器变得更简单。
  • 表现力视图模型更好地表达了视图的业务逻辑。
  • 可测性视图模型比视图控制器更容易测试。最后,您无需担心视图实现,就可以测试业务逻辑。

在本教程中,您将通过将天气应用程序的体系结构从MVC转换为MVVM来重构天气应用程序。首先,将所有与天气和位置相关的逻辑从视图控制器移到视图模型中。然后,您将为视图模型编写单元测试,以了解如何轻松地将测试集成到新的视图模型中。

在本教程的末尾,您的应用程序应该允许您按名称选择任何位置,并查看该位置的天气摘要。

开始

从下载项目材料开始,使用下载材料按钮在本教程的顶部或底部。然后,打开开始项目。

该应用程序获取天气信息,并提供当前天气的摘要。

要使用Weatherbit API,您需要注册一个免费的API键。在您添加自己的Weatherbit API密钥之前,该应用程序将无法工作。去https://www.weatherbit.io/account/create注册你的钥匙。

获得API密钥后,返回到Xcode。

在……下面服务,开WeatherbitService.swift。然后替换每个关键用你的新钥匙。

建造和运行。

你应该看看弗吉尼亚州麦盖希斯维尔的天气和今天的日子。

介绍MVVM的角色和责任

在深入到重构之前,您必须了解视图模型和视图控制器在MVVM模式中所做的事情。

视图控制器只负责更改视图和将视图输入传递到视图模型。因此,您将从视图控制器中删除任何其他逻辑,并将其移动到视图模型。

相反,视图模型负责以下方面:

  • 模型输入 接受视图输入并更新模型。
  • 模型输出将模型输出传递给视图控制器。
  • 格式化::格式化模型数据,供视图控制器显示。

熟悉现有的应用程序结构

注本节是对应用程序结构的可选审查。如果您已经对mvc视图控制器感到满意,并且希望开始重构,您可以跳到使用Box进行数据绑定. 熟悉当前MVC设计中的应用程序。首先,打开ProjectNavigator,如下所示:

在……下面控制器,你会发现WeatherViewController.swift。这是视图控制器,您将重构以删除模型和服务类型的任何使用。

在……下面模型,您将发现两个不同的模型对象:WeatherbitDataLocation. WeatherbitData表示WeatherbitAPI返回的数据的结构。Location是苹果的位置数据的简化结构CLLocation服务返回。

服务含WeatherbitService.swiftLocationGeocoder.swift。顾名思义,WeatherbitService从Weatherbit API获取天气数据。LocationGeocoder将字符串转换为Location.

故事板含LaunchScreen和天气故事板。

公用事业和视图模型都是空的。您将在重构期间为这些组创建文件。

WeatherViewController

在重构时,您将主要关注WeatherViewController。理解WeatherViewController,首先检查它的私有属性。

// 1
private let geocoder = LocationGeocoder()
// 2
private let defaultAddress = "McGaheysville, VA"
// 3
private let dateFormatter: DateFormatter = {
  let dateFormatter = DateFormatter()
  dateFormatter.dateFormat = "EEEE, MMM d"
  return dateFormatter
}()
// 4
private let tempFormatter: NumberFormatter = {
  let tempFormatter = NumberFormatter()
  tempFormatter.numberStyle = .none
  return tempFormatter
}()
  1. geocoder采取String输入,例如华盛顿特区并将其转换为它发送给气象服务的纬度和经度。
  2. defaultAddress设置默认地址。
  3. DateFormatter格式化日期显示。
  4. 最后,NumberFormatter帮助将温度表示为整数值。

现在,看看viewDidLoad():

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)
  }
}

viewDidLoad()打电话geocoder转换defaultAddress变成Location。回调使用返回位置来填充cityLabel短信。然后,它就过去了locationfetchWeatherForLocation(_:).

最后一部分WeatherViewControllerfetchWeatherForLocation(_:).

func fetchWeatherForLocation(_ location: Location) {
  //1
  WeatherbitService.weatherDataForLocation(
    latitude: location.latitude,
    longitude: location.longitude) { [weak self] (weatherData, error) in
    //2
    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)"
  }
}

这种方法只做两件事:

  1. 调用气象服务并将位置的纬度和经度传递给它。
  2. 使用天气服务回调提供的天气数据更新视图。

现在您已经对现有的应用程序结构有了很好的了解,现在是开始重构的时候了。

使用Box进行数据绑定

在MVVM中,需要一种将视图模型输出绑定到视图的方法。为此,您需要一个实用程序,它提供了将视图绑定到视图模型的输出值的简单机制。有几种方法可以实现这样的绑定:

  • 键值观测或kVO*使用关键路径观察属性并在该属性更改时获取通知的机制。
  • 功能反应编程将事件和数据作为流处理的范例。苹果新的联合框架是其玻璃钢的方法。RxSWIFT和ReactiveSWIFT是两种流行的FRP框架。
  • 代表团*使用委托方法在值更改时传递通知。
  • 拳击*使用财产观察员通知观察者某个值已更改。

在本教程中,您将使用拳击。对于简单的应用程序,一个定制的装箱实现就足够了。

在……下面公用事业,创建一个新的swift档案。给它起个名字盒。然后,将以下代码添加到文件中:

final class Box<T> {
  //1
  typealias Listener = (T) -> Void
  var listener: Listener?
  //2
  var value: T {
    didSet {
      listener?(value)
    }
  }
  //3
  init(_ value: T) {
    self.value = value
  }
  //4
  func bind(listener: Listener?) {
    self.listener = listener
    listener?(value)
  }
}

下面是上面的代码所做的工作:

  1. 各Box可以有一个ListenerBox当值更改时通知。
  2. Box具有泛型类型值。这个didSet属性观察者检测到任何更改并通知Listener任何价值更新。
  3. 初始化集Box的初始值。
  4. Listener打电话bind(listener:)在……上面Box,它变成Listener并立即收到通知Box现值。

创建WeatherViewModel

现在您已经建立了在视图和视图模型之间进行数据绑定的机制,现在可以开始构建实际的视图模型了。在MVVM中,视图控制器不调用任何服务或操作任何模型类型。这一责任完全属于视图模型。

通过将与Geo编码器和Weatherbit服务相关的代码从WeatherViewController进入WeatherViewModel。然后,将视图绑定到视图模型属性WeatherViewController.

首先,在视图模型,创建一个新的swift特文件名WeatherViewModel。然后,添加以下代码:

// 1
import UIKit.UIImage
// 2
public class WeatherViewModel {
}

这是代码分解:

  1. 首先,为UIKit.UIImage。没有其他UIKit视图模型中需要允许类型。一般的经验法则是永远不要进口。UIKit在你看来模特们。
  2. 然后,设置WeatherViewModel的类修饰符public。为了便于测试,您可以公开它。

现在,打开WeatherViewController.swift。添加以下属性:

private let viewModel = WeatherViewModel()

在这里,您可以在控制器内初始化视图模型。

接下来,你要搬家WeatherViewControllerLocationGeocoder逻辑到WeatherViewModel。在完成以下所有步骤之前,应用程序不会再次编译:

第一次切割defaultAddress离开WeatherViewController然后粘贴到WeatherViewModel。然后,添加一个静态属性的修饰符。 接下来,切geocoder走出WeatherViewController并将其粘贴到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
    }
  }
}

此代码更改locationName.value到“装载…”在抓取之前geocoder。什么时候geocoder完成查找,您将更新位置名称并获取该位置的天气信息。

取代WeatherViewController.viewDidLoad()守则如下:

override func viewDidLoad() {
  viewModel.locationName.bind { [weak self] locationName in
    self?.cityLabel.text = locationName
  }
}

此代码绑定cityLabel.textviewModel.locationName.

接下来,在里面WeatherViewController.swift删除fetchWeatherForLocation(_:)

由于您仍然需要一种获取某个位置的天气数据的方法,所以添加一个重构的fetchWeatherForLocation(_:)在……里面WeatherViewModel.swift:

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

回调暂时不起任何作用,但您将在下一节中完成此方法。

最后,向WeatherViewModel:

init() {
  changeLocation(to: Self.defaultAddress)
}

视图模型首先将位置设置为默认地址。

哎呀!那是很多重构。您刚刚将所有服务和地理编码逻辑从视图控制器移到视图模型。注意视图控制器是如何显著缩小的,同时也变得更简单了。

若要查看操作中的更改,请更改defaultAddress你现在的位置。

建造和运行。

确保城市名称现在显示您的当前位置。但是天气和日期不对。该应用程序正在显示故事板中的示例信息。

下一个你会修好的。

MVVM中的数据格式化

在MVVM中,视图控制器只负责视图。视图模型总是负责格式化来自视图中的服务和模型类型的数据。

在下一次重构中,您将将数据格式移出WeatherViewController并进入WeatherViewModel。在进行此操作时,您将添加所有剩余的数据绑定,以便天气数据根据位置的变化进行更新。

从处理日期格式开始。首先,切dateFormatter从…WeatherViewController。将属性粘贴到WeatherViewModel.

下一个,在WeatherViewModel,添加以下内容locationName:

let date = Box(" ")

它最初是一个空字符串,当天气数据从Weatherbit API到达时更新。

现在,在里面添加以下内容WeatherViewModel.fetchWeatherForLocation(_:)就在API获取闭包结束之前:

self.date.value = self.dateFormatter.string(from: weatherData.date)

以上代码更新date每当天气数据到的时候。

最后,将以下代码粘贴到WeatherViewController.viewDidLoad():

viewModel.date.bind { [weak self] date in
  self?.dateLabel.text = date
}

建造和运行。

现在的日期反映了今天的日期,而不是11月13日的故事板。你在进步!

该完成重构了。按照这些最后步骤完成其余天气字段所需的数据绑定。

首先,切tempFormatter从…WeatherViewController。将属性粘贴到WeatherViewModel.

然后,为剩余的可绑定属性添加以下代码WeatherViewModel:

let icon: Box<UIImage?> = Box(nil)  //no image initially
let summary = Box(" ") 
let forecastSummary = Box(" ")

现在,将以下代码添加到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
}

在这里,您已经为图标图像、天气摘要和预测摘要创建了绑定。每当框内的值发生变化时,将自动通知视图控制器。

接下来,是时候更改这些Box物品。在……里面WeatherViewModel.swift,将下列代码添加到完成闭包的末尾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)"

此代码将不同的天气项格式化为视图来表示它们。

最后,将以下代码添加到changeLocation(to:)在API结束之前,获取闭包:

self.locationName.value = "Not found"
self.date.value = ""
self.icon.value = nil
self.summary.value = ""
self.forecastSummary.value = ""

如果地理代码调用没有返回位置,则此代码确保不显示天气数据。

建造和运行。

所有的天气信息现在都会更新defaultAddress。如果您已经使用了当前的位置,那么从窗口向外看并确认数据是正确的。]接下来,您将看到MVVM如何扩展应用程序的功能。

在MVVM中添加功能

到目前为止,您可以检查默认位置的天气。但如果你想知道其他地方的天气呢?您可以使用MVVM添加一个按钮来检查其他位置的天气。

您可能已经注意到左上角的位置符号➤。这个按钮还不能用。接下来,您将将其连接到一个提示输入新位置的警报,然后获取该新位置的天气。

第一,打开Weather.storyboard。然后,打开WeatherViewController.swift助理编辑。

下一步,控制拖动更改位置按钮到最后WeatherViewController。命名方法提示形式定位.

现在将以下代码添加到promptForLocation(_:):

//1
let alert = UIAlertController(
  title: "Choose location",
  message: nil,
  preferredStyle: .alert)
alert.addTextField()
//2
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)
//3
present(alert, animated: true)

以下是此方法的详细说明:

  1. 创建一个UIAlertController有一个文本字段。
  2. 添加一个动作按钮提交。该操作将新的位置字符串传递给viewModel.changeLocation(to:).
  3. 呈现alert.

建造和运行。

放一些不同的地方。你可以试试巴黎、法国或德克萨斯州的巴黎。你甚至可以加入一些无稽之谈,比如格格查看应用程序的响应情况。

花点时间思考一下,在视图控制器中添加这个新功能所需的代码是多么少。对视图模型的单个调用将触发更新该位置的天气数据的流程。聪明对吧?

接下来,您将了解如何使用MVVM创建单元测试。

用MVVM进行单元测试

MVVM的最大优点之一是它使创建自动化测试变得更加容易。

要用mvc测试视图控制器,必须使用UIKit实例化视图控制器。然后,您必须在视图层次结构中搜索以触发操作并验证结果。

使用MVVM,您可以编写更多的常规测试。您可能仍然需要等待一些异步事件,但是大多数事情都很容易触发和验证。

为了了解MVVM使测试视图模型更加简单,您将创建一个测试WeatherViewModel更改位置,然后确认locationName将更新绑定到预期位置。

首先,在MVVMFromMVCT组,创建一个新的单元测试用例类文件名WeatherViewModel测试.

您必须导入应用程序来发短信。就在下面import XCTest,增加以下内容:

@testable import Grados

现在,将以下方法添加到WeatherViewModelTests:

func testChangeLocationUpdatesLocationName() {
  // 1
  let expectation = self.expectation(
    description: "Find location using geocoder")
  // 2
  let viewModel = WeatherViewModel()
  // 3
  viewModel.locationName.bind {
    if $0.caseInsensitiveCompare("Richmond, VA") == .orderedSame {
      expectation.fulfill()
    }
  }
  // 4
  DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
    viewModel.changeLocation(to: "Richmond, VA")
  }
  // 5
  waitForExpectations(timeout: 8, handler: nil)
}

以下是对新测试的解释:

  1. 这个locationName绑定是异步的。用expectation若要等待异步事件,请执行以下操作。
  2. 创建一个viewModel去测试。
  3. 绑定到locationName并且只有当该值与预期结果匹配时,才能满足期望。忽略任何位置名称值,例如“装载…”或者默认地址。只有预期的结果才能满足测试期望。 通过更改位置开始测试。在进行更改之前等待几秒钟是很重要的,这样任何挂起的地44. 理编码活动都会首先完成。当应用程序启动时,它会触发一个geocoder查一下。

当它创建视图模型的测试实例时,它还会触发geocoder查一下。等待几秒钟后,这些其他查找就可以在触发测试查找之前完成。

苹果的文档明确警告CLLocation如果请求的速率太高,则会引发错误。

  1. 等待长达8秒的期望才能实现。只有当预期的结果在超时之前到达时,测试才会成功。

单击旁边的钻石testChangeLocationUpdatesLocationName()去做测试。当测试通过后,钻石将变成绿色的标记。

在这里,您可以按照下面的示例创建确认其他值的测试。WeatherViewModel。理想情况下,您可以插入一个模拟天气服务,以删除测试中对风化位.io的依赖。

回顾对MVVM的重构

干得好!在回顾这些更改时,您可以看到重构所带来的MVVM的一些好处:

  • 降低复杂度: WeatherViewController现在简单多了。
  • 专门性: WeatherViewController不再依赖于任何模型类型,只关注视图。
  • 分离: WeatherViewController只与WeatherViewModel通过发送输入,例如changeLocation(to:),或绑定到其输出。
  • 表现力: WeatherViewModel将业务逻辑与低级视图逻辑分离。
  • 可维护*向WeatherViewController.
  • 可测*WeatherViewModel相对容易测试。

但是,mvvm有一些折衷之处,您应该考虑:

  • **额外类型//::MVVM在应用程序的结构中引入了一个额外的视图模型类型。
  • 束缚机制*它需要一些数据绑定方法,在这种情况下,Box类型。
  • 样板:您需要一些额外的样板来实现MVVM。
  • 记忆当将视图模型引入混合时,您必须意识到内存管理和内存保留周期。

推荐👇:

如果你想一起进阶,不妨添加一下交流群1012951431

面试题资料或者相关学习资料都在群文件中 进群即可下载!