iOS开发—MVVM 教程:从 MVC 重构

ModelModelModelModel是一种设计模式(MV应用程序视图)在iOS应用程序视图获取了广泛的关注。它涉及到一个广泛的关注点。伴侣对象。

01.png

如上所示,MVVM 模式由多层组成:

  • 模型:应用程序运行的应用程序数据。
  • View:用户界面的视觉元素。在 iOS 中,视图控制器与视图的概念是分不开的。
  • ViewModel:从视图输入更新模型并从模型输出更新视图。

MVVM 提供了一些优于Model-View-ControllerMVC的优势,这是 iOS 中事实上的方法:

  • 降低复杂性:MVVM 通过将大量业务逻辑移出视图控制器使视图控制器更简单。
  • 表达性:视图模型更好地表达视图的业务逻辑。
  • 可测试性:视图模型比视图控制器更容易测试。您最终可以测试业务逻辑,而不必担心视图实现。

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

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

入门

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

该应用程序从 weatherbit.io 获取最新天气信息,并提供当前天气摘要。

要使用 Wea​​therbit API,您需要注册一个免费的 API 密钥。在您添加自己的 Weatherbit API 密钥之前,该应用程序将无法运行。前往www.weatherbit.io/account/cre…注册您的密钥。

获得 API 密钥后,返回 Xcode。

Services下,打开WeatherbitService.swift。然后用您的新密钥替换apiKey的值。

02.png

构建并运行。

03.png

您应该看到 McGaheysville, VA 的天气和今天的日期。

介绍 MVVM 角色和职责

在深入重构之前,您必须了解视图模型和视图控制器在 MVVM 模式中的作用。

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

相比之下,视图模型负责以下工作:

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

熟悉现有的应用程序结构

注意:本节是对应用程序结构的可选审查。如果您已经熟悉 MVC 视图控制器并想开始重构,您可以跳到使用 Box 进行数据绑定

熟悉当前 MVC 设计中的应用程序。首先,打开项目导航器,如下所示:

04.png

Controllers下,您会找到WeatherViewController.swift。这是您将重构以删除对模型和服务类型的任何使用的视图控制器。

Models下,您会发现两个不同的模型对象:WeatherbitDataLocationWeatherbitData是一个结构,表示 Weatherbit API 返回的数据。是 Apple服务返回Location的位置数据的简化结构。CLLocation

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

Storyboards包含LaunchScreenWeather故事板。

实用程序视图模型都是空的。您将在重构期间为这些组创建文件。

天气视图控制器

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

// 1
私人让 geocoder =  LocationGeocoder ()
 // 2
私人 让defaultAddress =  "McGaheysville, VA" 
// 3
私人让 dateFormatter: DateFormatter  = {
  让 dateFormatter =  DateFormatter ()
  dateFormatter.dateFormat =  "EEEE, MMM d"
  返回日期程序格式
}()
// 4
个人让 tempFormatter: NumberFormatter  = {
  让 tempFormatter =  NumberFormatter ()
  tempFormatter.numberStyle = .none
  返回 tempFormatter
}()
复制代码
  1. geocoder接受String诸如华盛顿特区之类的输入并将其转换为发送给气象服务的纬度和经度。
  2. defaultAddress设置默认地址。
  3. DateFormatter格式化日期显示。
  4. 最后,NumberFormatter有助于将温度呈现为整数值。

现在,看看viewDidLoad()

覆盖func  viewDidLoad () {
  geocoder.geocode(addressString: defaultAddress) { [弱 自我] 位置在
    警卫
      让自我=自我,
      让 location = locations.first
      别说{
        返回
      }
    self .cityLabel.text = location.name
     self .fetchWeatherForLocation(位置)
  }
}
复制代码

viewDidLoad()调用geocoder转换defaultAddressLocation. 回调使用返回位置来填充cityLabel的文本。然后,它location传入fetchWeatherForLocation(_:).

的最后一部分WeatherViewController fetchWeatherForLocation(_:)

func  fetchWeatherForLocation ( _  location : Location ) {
   //1 
  WeatherbitService .weatherDataForLocation(
    纬度:位置.纬度,
    经度:location.longitude) { [ weak  self ] (weatherData, error) in 
    //2
    警卫
      让自我=自我,
      天气数据=天气数据
      别说{
        返回
      }
    self .dateLabel.text = 
      self .dateFormatter.string(来自:weatherData.date)
     self .currentIcon.image =  UIImage(命名:weatherData .iconName)
    让 temp =  self .tempFormatter.string(
      来自:weatherData.currentTemp 作为NSNumber ) ?? “”
     self .currentSummaryLabel.text = 
      " \(weatherData.description) - \(temp) ℉" 
    self .forecastSummary.text =  " \n摘要:\(weatherData.description) "
  }
}
复制代码

这个方法只做两件事:

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

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

使用 Box 进行数据绑定

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

  • Key-Value Observing 或 KVO:一种使用键路径来观察属性并在该属性更改时获取通知的机制。
  • 功能反应式编程或 FRP:将事件和数据作为流处理的范例。Apple 新的 Combine 框架是其处理 FRP 的方法。RxSwift 和 ReactiveSwift 是 FRP 的两个流行框架。
  • 委托:使用委托方法在值更改时传递通知。
  • 装箱:使用属性观察者通知观察者值已更改。

在本教程中,您将使用拳击。对于简单的应用程序,装箱的自定义实现将绰绰有余。

Utilities下,创建一个新的Swift文件。将其命名为Box。然后,将以下代码添加到文件中:

最终类Box < T >
   //1 
  typealias  Listener  = ( T ) -> Void 
  var listener:监听器?
   //2个
  参数值:T {
    设置{
      听吗?(价值)
    }
  }
  //3
  初始化(_值:T) {
    自我价值=价值
  }
  //4 
  func 绑定(监听器:监听器) {
     self .listener =监听器
    听吗?(价值)
  }
}
复制代码

以下是上面代码的作用:

  1. 每个都Box可以有一个Listener通知Box值何时更改。
  2. Box具有泛型类型值。属性观察器didSet检测任何更改并通知Listener任何值更新。
  3. 初始化器设置Box的初始值。
  4. 当 aListener调用bind(listener:)Box,它会Listener立即收到有关Box' 的当前值的通知。

创建 WeatherViewModel

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

您将通过将与地理编码器和 Weatherbit 服务相关的代码WeatherViewController从 WeatherViewModel. 然后,将视图绑定到WeatherViewController.

首先,在View Models下,创建一个名为WeatherViewModel的新**Swift文件。然后,添加以下代码:

// 1
导入UIKit . UIImage 
// 2
公共类WeatherViewModel {
}
复制代码

下面是代码分解:

  1. 首先,为UIKit.UIImageUIKit视图模型中不需要允许其他类型。一般的经验法则是永远不要UIKit在您的视图模型中导入。
  2. 然后,将WeatherViewModel的类修饰符设置为public。您将其公开,以便对其进行测试。

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

让 viewModel =  WeatherView (天气模型)
复制代码

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

接下来,您将WeatherViewController' 的LocationGeocoder逻辑移动到WeatherViewModel. 在您完成以下所有步骤之前,该应用程序不会再次编译:

  1. 先剪defaultAddress下来WeatherViewController粘贴进去WeatherViewModel。然后,为属性添加一个静态修饰符。
  2. 接下来,剪下geocoder并将WeatherViewController其粘贴到WeatherViewModel.

WeatherViewModel中,添加一个新属性:

let locationName =  Box ( "加载中..." )
复制代码

上面的代码将使应用程序在启动时显示*“正在加载...”*,直到获取位置。

接下来,将以下方法添加到WeatherViewModel

func  changeLocation ( to  newLocation : String ) {
  locationName.value =  "加载中..." 
  geocoder.geocode(addressString: newLocation) { [ weak  self ] 位置在
    守卫让自我=自我否则{返回}
    如果让位置=位置。第一个{
       self .locationName.value = location.name
       self .fetchWeatherForLocation(位置)
      返回
    }
  }
}
复制代码

此代码在通过 获取之前更改locationName.value为*“正在加载...”*geocoder。完成geocoder查找后,您将更新位置名称并获取该位置的天气信息。

替换WeatherViewController.viewDidLoad()为以下代码:

覆盖func  viewDidLoad () {
  viewModel.locationName.bind { [ weak  self ] locationName in 
    self ? .cityLabel.text =位置名称
  }
}
复制代码

此代码绑定cityLabel.textviewModel.locationName.

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

由于您仍然需要一种方法来获取某个位置的天气数据,因此fetchWeatherForLocation(_:)WeatherViewModel.swift中添加一个重构:

个人func  fetchWeatherForLocation(_ 位置:位置){
   WeatherbitService .weatherDataForLocation (
    纬度:位置.纬度,
    经度:location.longitude) { [ weak  self ] (weatherData, error) in
      警卫
        让自我=自我,
        天气数据=天气数据
        别说{
          返回
        }
  }
}
复制代码

您现在什么都不做,但将在下一节中完成这个方法。

最后,添加一个初始化器WeatherViewModel

在里面(){
  更改位置(到:Self .defaultAddress)
}
复制代码

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

呸那是很复杂的。您将所有服务和地理编码器从逻辑视图到视图模型。请注意视图是如何移动的,同时也可以显示所有的视图。

要查看您当前的更改值,defaultAddress为您当前的更改位置。

构建并运行。

05.png

查看城市名称现在您当前的位置。但是板子显示的日期和日期不来自故事。该应用程序正在显示正确的示例信息。

你会解决这个问题。

在 MVVM 中数据

在 MV 中视图和视图类型的数据以呈现在视图中的视图和视图类型只负责。

在下一次绑定时,您将在更新时,将您输入的数据格式以数据格式出出并WeatherViewController移至它WeatherViewModel时,您将添加所有剩余的数据,以便在位置更改时使用。

首先解决日期格式问题。首先,dateFormatterWeatherViewController。将属性贴到WeatherViewModel

,在中WeatherViewModel,添加以下内容locationName

让日期=框(“”)
复制代码

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

WeatherViewModel.fetchWeatherForLocation(_:)现在,在 API fetch 闭包结束之前添加以下内容:

self .date.value =  self .dateFormatter.string(来自:weatherData.date)
复制代码

date每当天气数据到达的时候, 上面的代码就会更新。

将以下代码附加到最后WeatherViewController.viewDidLoad()

viewModel.date.bind { [弱 自我]自我中的日期
  ?.dateLabel.text =日期
}
复制代码

构建并运行。

06.png

现在的日期实际上是今天的日期,而不是故事板中的 11 月 13 日。你在进步!

是时候完成重构了。按照这些最终的步骤完成其余的字段需要的数据绑定。

首先,tempFormatterWeatherViewController.将属性粘贴到WeatherViewModel

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

let icon: Box < UIImage ?> =  Box ( nil )   //最初没有图像
let summary =  Box ( " " ) 
 let forecastSummary =  Box ( " " )
复制代码

现在,将以下代码添加到末尾WeatherViewController.viewDidLoad()

viewModel.icon.bind { [ weak  self ] image in 
  self ? .currentIcon.image =图像
}
    
viewModel.summary.bind { [弱自我 ]自我
  总结?.currentSummaryLabel.text =摘要
}
    
viewModel.forecastSummary.bind { [弱自我 ]自我
  预测?.forecastSummary.text =预测
}
复制代码

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

接下来,是时候实际更改这些Box对象中的值了。在WeatherViewModel.swift中,将以下代码添加到完成闭包的末尾fetchWeatherForLocation(_:)

self .icon.value =  UIImage (named: weatherData.iconName)
 let temp =  self .tempFormatter
  .string(来自:weatherData.currentTemp as  NSNumber)?? "" 
self .summary.value =  " \(weatherData.description) - \(temp) ℉" 
self .forecastSummary.value =  " \n摘要:\(weatherData.description) "
复制代码

此代码格式化不同的天气项目以供视图显示。

changeLocation(to:)最后,在 API fetch 闭包的末尾和之前添加以下代码:

self .locationName.value =  "未找到" 
self .date.value =  "" 
self .icon.value =  nil 
self .summary.value =  "" 
self .forecastSummary.value =  ""
复制代码

如果地理编码调用未返回任何位置,此代码可确保不显示天气数据。

构建并运行。

07.png

现在,您的所有天气信息都会更新defaultAddress。如果您使用过当前位置,请查看窗外并确认数据正确。:] 接下来,您将看到 MVVM 如何扩展应用程序的功能。

在 MVVM 中添加功能

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

您可能已经注意到左上角的位置符号 ➤。这是一个不起作用的按钮。接下来,您将把它与提示新位置的警报挂钩,然后获取该新位置的天气。

首先,打开Weather.storyboard。然后,在助手编辑器中打开WeatherViewController.swift 。

接下来,按住 Control 并拖动Change Location ButtonWeatherViewController. 将方法命名为promptForLocation

08.gif

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

//1
让警报=  UIAlertController (
  title: "选择位置" ,
  消息:无,
  首选样式:.alert)
alert.addTextField()
//2
让submitAction =  UIAlertAction (
  标题:“提交”,
  样式:.default) { [ unowned alert, weak  self ] _  in 
    guard  let newLocation = alert.textFields ? .first ? .text else {返回}
    自我?.viewModel.changeLocation(到:newLocation)
}
alert.addAction(submitAction)
//3
存在(警报,动画:真)
复制代码

以下是此方法的细分:

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

构建并运行。

09.gif

您甚至可以尝试在法国巴黎的一些程序或您的巴黎寻找一些不同的位置。

花点时间想一下在视图中添加这个新功能需要多少代码。对视图模型的一次调用会触发更新位置的天气数据的流程。聪明,对吧?

,您将如何使用 MV 创建单元创建学习测试。

使用 MVVM 进行单元测试

MVVM 的一大优势是它使创建自动化测试非常容易。

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

使用 MVVM,您可以更经常地编写测试。您可能还需要等待一些事件,但大多数情况很容易触发和验证。

MVVM 使测试图模型需要很容易WeatherViewModel确认创建一个测试的位置,来更改您的绑定位置,然后locationName查看更新到您的位置。

首先,在MVVMFromMVCTests单元下,创建一个名为WeatherViewModelTests的新**测试用例类文件。

您必须导入应用程序测试。紧接在下面进行import XCTest添加以下内容:

@testable导入Grados
复制代码

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

func  testChangeLocationUpdatesLocationName () {
   // 1
  让期望=  self .expectation(
    描述:“使用地理编码器查找位置”)
  // 2
  让 viewModel =  WeatherViewModel ()
   // 3
  viewModel.locationName.bind {
    如果 $0 .caseInsensitiveCompare(“弗吉尼亚州里士满”)== .orderedSame {
      期望.fulfill()
    }
  }
  // 4 
  DispatchQueue .main.asyncAfter(deadline: .now() +  2 ) {
    viewModel.changeLocation(到:“弗吉尼亚州里士满”)
  }
  // 5 
  waitForExpectations(目标:8,处理程序:无)
}
复制代码

下面是对新测试的解释:

  1. locationName异步异步的。使用expectation异步异步事件。

  2. 创建一个实例viewModel进行测试。

  3. locationName如果结果与预期名称结果匹配,则并满足预期。 “显示任何预期值,*正在显示...”*或默认地址。只有结果的结果满足测试加载的预期。

  4. 通过更改才能在更改之前等待启动geocoder几个测试。

    当它查看实例时,它也会触发geocoder样式。等待创建的其他测试搜索在触发测试之前完成。

    Apple 的文档明确警告说,CLLocation请求率太高,如果可能会错误表示。

  5. 最多只有几秒的结果,在到达之前 8 时,才会测试成功。

点击旁边的菱形运行形testChangeLocationUpdatesLocationName()测试。测试通过后,菱形将变为复选标记。

10.gif

从这里,您可以按照此示例创建测试来确认WeatherViewModel。理想情况下,将注入一个模拟天气服务来消除对 weatherbit.io 的依赖以进行测试。

重构到 MVVM

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

  • 简化复杂性WeatherViewController现在简单简单。
  • 专业化WeatherViewController不再依赖于任何模型类型,只关注视图。
  • 示例发送输入(例如发送输入(例如发送输入),或通过发送到其输出)与发送到。WeatherViewController``WeatherViewModel``changeLocation(to:)
  • 表达性:WeatherViewModel业务逻辑与低级看法不同。
  • 可维护性:通过对WeatherViewController
  • 可测试WeatherViewModel相对容易测试。

您应该考虑对 MVVM 进行一些停留:

  • 其他类型:MVVM 为应用程序的结构引入了其他的视图模型类型。
  • 绑定机制:它需要一些数据绑定的手段,在这种情况下是Box类型。
  • 样板:你需要一些额外的样板来实现 MVVM。
  • 内存:在将视图模型引入组合时,您必须注意内存管理和内存保留周期。

本教程的最终项目资料下载地址

链接:pan.baidu.com/s/1wxC7LIcG…

提取码:n3k7

这里也推荐一些面试相关的内容!

猜你喜欢

转载自juejin.im/post/7092276264714633223