在几分钟内通过RxSwift将MVC iOS应用重构为MVVM

MVC是Apple在开发iOS应用程序时向开发人员推荐的应用程序体系结构。 它提供了视图,模型和控制器之间的清晰分隔。 控制器位于中间,充当视图和模型之间的粘合剂。 从模型到视图的几乎所有逻辑数据转换都被扔到控制器内部。

随着功能和需求的增长,视图控制器在逻辑,状态管理,数据转换等所有方面变得非常庞大,它将紧密耦合在一起,并且很难作为一个整体进行测试。 尽管并非总是如此,但是如果我们认真地正确管理它。 有关更好的MVC,请参见Dave De Long博客文章。

更好的MVC,第2部分:修复封装
关于“修复”模型视图控制器的系列文章的第2部分:更好的MVC,第1部分:问题,更好的MVC,第2部分:修复… davedelong.com

MVVM简介

模型视图视图模型是一种应用程序体系结构,最初由Microsoft于2005年通过其.NET框架首次发明,以构建事件驱动的用户界面。

MVVM作为一种体系结构提供了许多主要职责,例如:

  1. 作为提供应用程序状态表示形式的接口。
  2. 作为从模型显示的数据转换的管道,将显示到视图中。

使用MVVM,所有从模型进行的数据转换(例如,格式化日期要在UILabel显示为文本)将由视图模型实现,并作为属性公开给控制器。 View Controller的职责是配置属性,将视图模型中的属性绑定到视图中,并将所有操作从视图发送回视图模型。 这样,应用程序状态将始终在视图和视图模型之间保持同步。

有几个重要的规则适用:

  1. 模型归视图模型所有,并且对视图模型一无所知。
  2. View Model由View Controller拥有,它对Controller一无所知
  3. 控制器拥有视图模型,并且对该模型一无所知。

MVVM提供了更好的业务逻辑封装和来自模型的数据转换,并且作为一个单元进行测试也非常容易。 并非所有的View Model都需要提供对其属性的绑定,它仍然可以是一个轻量级的对象,可以用来通过数据转换配置视图,正如我们稍后在构建应用程序时所看到的。

有几个绑定选项可用,例如使用键值观察和闭包。 在本文中,我们将使用RxSwift库提供可观察到的反应性序列来构建我们的MVVM应用程序。

我们将建立什么

在本文中,我们将把使用MVC作为其应用程序体系结构的当前iOS应用程序重构为MVVM。 这是一个电影信息应用程序,它使用具有以下主要功能的电影数据库(TMDb)API:

  1. 按最新趋势,最受欢迎和最近的时间获取电影列表。
  2. 使用搜索栏搜索电影。

我们将看到MVVM体系结构将如何帮助我们构建更轻便的视图控制器和一些各自负责的视图模型。

请注册并从TMDb获取您的API密钥。

https://www.themoviedb.org/documentation/api

您可以在下面的GitHub存储库中克隆入门项目:

alfianlosari / MovieInfoStarterProject
电影信息MVVM启动项目。 通过在… github.com 上创建一个帐户为alfianlosari / MovieInfoStarterProject开发做出贡献

入门项目

入门项目使用情节提要来配置其视图控制器。 偷看Main.storyboard文件以查看配置。

该应用程序的初始视图控制器是一个带有两个子控件的Tab Bar控制器,每个子控件都嵌入到导航控制器中。 以下是对孩子的快速介绍:

  1. MovieListViewController 。 根据几个过滤器显示电影列表,例如用户可以选择的正在播放,受欢迎和即将上映的电影。
  2. MovieSearchViewController 。 显示搜索栏供用户键入和搜索他们要搜索的电影。

两个视图控制器都使用TableView来显示MovieList,该电影列表配置为在tableView(_:cellForRowAt:)表视图数据源方法中显示数据的MovieCell。

该应用程序在Movie.swift文件中具有模型对象。 文件中有几种模型已经实现了Codable协议,可以直接用于解码来自TMDb API的响应。

该应用程序还具有实现MovieService协议的MovieStore对象。 MovieStore对象使用TMDb API对指定的enpdoint执行url会话请求,将结果解码为响应对象,然后调用成功的处理程序闭包以传递响应。

确保打开MovieStore.swift文件并将您自己的API密钥粘贴到apiKey内部的apiKey常量中,然后生成并运行该项目。 玩一会儿以了解应用功能。 接下来,我们将从电影单元的配置开始逐步进行重构过程以显示数据。

MovieViewViewModel

第一次重构将非常简单,请看一下MovieListViewControllerMovieSearchViewController tableView(_:cellForRowAt:)方法。 我们可以看到,在将所有数据转换(尤其是发布日期和评分文本)设置在电影中之前,都在其中进行了数据转换
单元用户界面元素,视图控制器不应处理此数据转换。

  func tableView( _ tableView:UITableView,cellForRowAt indexPath:IndexPath)-> UITableViewCell { 
cell = tableView.dequeueReusableCell(withIdentifier:“ MovieCell”,for:indexPath) ! 电影单元
电影=电影[indexPath.row]
  cell.titleLabel.text = movie.title 
cell.releaseDateLabel.text = dateFormatter.string(来自:movie.releaseDate)
cell.overviewLabel.text = movie.overview
cell.posterImageView.kf.setImage(with:movie.posterURL)
  评级= Int(movie.voteAverage) 
let ratingText =(0 .. <rating).reduce(“”){(acc, _ )->字符串输入
return acc +“⭐️
}
cell.ratingLabel.text = ratingText
返回单元
}

首先,为视图模型创建一个名为MovieViewViewModel的新文件。 它将执行以下几项操作:

  1. 在初始化程序内接受一个Movie对象,并将其存储在private属性中。
  2. 公开titleoverviewposter urlformatted release date文本和formatted rating文本的属性。
  3. 使用DateFormatter处理发布日期到文本的数据转换。
  4. 将等级编号的数据转换处理为⭐️文本。

图表顶部的线是已发布的observables序列,然后在中间应用过滤器transformation ,最后observer/subscribe将仅接收通过转换的值。 还有更多操作符,例如combinezipmergethrottle ,可以在管道中用作转换。 请确保查看RxSwift文档以了解更多信息。

http://reactivex.io/documentation/operators.html

为了正确使用RxSwift,我们需要了解和理解以下几个关键术语:

  1. Observable :这是一系列数据,我们可以应用转换,然后观察/订阅。
  2. Subject :这是一系列数据,例如可观察的数据,但我们也可以发布下一个值给主题
  3. Driver :与可观察的Driver相同,但是在这种情况下,可以保证将其安排为在主线程上运行。
  4. BehaviorRelay :这是一个专门的主题,我们可以像正常变量一样使用它来设置和获取值。

这就是有关使用RxSwift进行响应式编程的快速介绍的全部内容,让我们开始下一个MovieListViewController重构!

MovieListViewController当前MVC状态

让我们看一下当前的MovieListViewController实现。 当前,它有几个职责:

  1. 它将Movies数组存储为实例属性,这将用于存储电影和驱动表格视图数据源。 每当分配新值时,它都使用didSet属性观察器重新加载表视图。
  2. endpoint属性属性反映了在分段控件中当前选择的细分。 目标操作selector将添加到分段控件中,该控件将基于使用扩展功能的分段控件的选定索引分配属性。 它还使用didSet属性观察器根据选定的端点获取电影。
  3. MovieService将用于基于该点使用TMDb API提取电影。 在fetchMovies方法中调用它。 在此方法内部,在调用API之前,将活动指示器视图设置为开始动画,并且还将信息标签视图设置为隐藏。 在closure success handler ,根据响应为movies属性分配了新值。 在closure error handler ,信息标签文本将分配有错误描述的值,并设置为在UI中显示。 在两个处理程序中,活动指示器视图动画都将停止。
  4. tableView(_:cellForRowAt:)方法中,单元格出列并使用indexPath行检索电影之后。 MovieViewViewModel随影片实例化,并传递到configure(viewModel:)单元格UI的单元格configure(viewModel:)方法。

从获取电影,存储和跟踪所有属性的state managementMovieListViewController需要执行许多任务。 所有这些逻辑和状态使View Controller tightly coupled并且难以在encapsulation进行测试。 因此,让我们减轻View Controller的所有负担,然后转到下一个重构,即创建MovieListViewViewModel

重构为MovieListViewViewModel

让我们使用MovieListViewViewModel作为MovieListViewViewModel名为我们的视图模型创建新文件。 接下来,让我们导入RxSwift和RxCocoa框架,然后声明该类。

 进口基金会 
导入RxSwift
进口RxCocoa
   MovieListViewViewModel { 
  私有 let movieService:MovieService 
私人 disposeBag = DisposeBag()
  私人 let _movies = BehaviorRelay (值:[]) 
私人 let _isFetching = BehaviorRelay (值: false
私人 let _error = BehaviorRelay (值: nil
  var isFetching:Driver  { 
返回 _isFetching.asDriver()
}
  var movie:Driver  { 
返回 _movies.asDriver()
}
  var错误:Driver  { 
返回 _error.asDriver()
}
  var hasError:Bool { 
返回 _error.value!=
}
  var numberOfMovies:Int { 
返回 _movies.value.count
}
}

让我们将所有声明分解为以下几点:

  1. MovieService属性将用于从API提取电影。 将通过initalizer使用依赖项注入来分配此属性。
  2. DisposeBag是RxSwift的特殊对象,当对象被释放时,将用于自动管理可观察对象订阅的释放。
  3. _movies_isFetching_error属性使用BehaviorRelay因此可以用来发布新值,也可以观察到。 这些属性被声明为私有。
  4. moviesisFetchingerror属性是计算得出的变量,它们从每个各自的私有属性返回Driver 。 可观察到的Driver总是计划在UI Thread上运行。 这些属性将由视图控制器用于观察值并将视图绑定到。
  5. numberOfMovies返回存储在_movies属性中的电影总数,该属性存储电影数组。

接下来,让我们声明该View模型的初始化程序和方法。

   MovieListViewViewModel { 
  ... 

初始化 (端点:Driver ,movieService:MovieService){
自我 .movi​​eService = movieService
终点
.drive(onNext:{[弱自我 ](端点)
自我 ?.fetchMovies(端点:端点)
} .disposed(作者:disposeBag)
}
  func viewModelForMovie(索引为Int)-> MovieViewViewModel?  { 
防护指标<_movies.value.count 其他 {
返回
}
返回 MovieViewViewModel(电影:_movies.value [index])
}
  func fetchMovies(endpoint:Endpoint){ 
自我 ._movies.accept([])
自我 ._isFetching.accept( true
自我 ._error.accept( nil
  movieService.fetchMovies(来自:端点,参数: nil ,successHandler:{[弱自我 ](响应)  
自我 ?._ isFetching.accept( false
自我 ?._ movies.accept(response.results)
}){[弱自我 ](错误)
自我 ?._ isFetching.accept( false
自我 ?._ error.accept(error.localizedDescription)
}
}
}

让我们将其分解为几个要点:

  1. 初始化程序接受Endpoint类型的Driver和MovieService作为参数。 然后将movieService分配给实例属性。 然后,我们订阅Driver ,每当分配了下一个值时,我们都会调用传递端点的fetchMovies
  2. fetchMovies方法将调用TMDb API,并使用fetchMovies方法将所有值发布到指定的BehaviorRelay属性,例如_movies_isFetching_error 。 所有状态(例如fetchingerrorsuccess提取)均由视图模型管理。
  3. viewModelForMovie(at index:)是一个辅助方法,它将以指定的索引返回MovieViewViewModel 。 这将在视图控制器的表视图tableView(_:cellForRowAt:)方法中使用。

接下来,我们需要更新MovieListViewController以使用刚创建的视图模型。

  ... 
导入RxSwift
进口RxCocoa
   MovieListViewController:UIViewController { 
  ... 
var movieListViewViewModel:MovieListViewViewModel!
disposeBag = DisposeBag()
  覆盖 func viewDidLoad(){ 
超级 .viewDidLoad()
movieListViewViewModel = MovieListViewViewModel(端点:segmentedControl.rx.selectedSegmentIndex
.map {Endpoint(index:$ 0)?? .nowPlaying}
.asDriver(onErrorJustReturn:.nowPlaying)
,movieService:MovieStore.shared)
  movieListViewViewModel.movi​​es.drive(onNext:{[unown self ]( _in 
自我 .tableView.reloadData()
} .disposed(作者:disposeBag)
  movieListViewViewModel 
.isFetching
.drive(activityIndi​​catorView.rx.isAnimating)
.disposed(作者:disposeBag)
  movieListViewViewModel 
。错误
.drive(onNext:{[unown self ](error) in
自我 .infoLabel.isHidden =! 自我 .movi​​eListViewViewModel.hasError
自我 .infoLabel.text =错误
} .disposed(作者:disposeBag)

setupTableView()
}
....
}

在这里,我们已经更新了几件事:

  1. 我们将RxSwift和RxCocoa框架导入到文件中。
  2. 我们声明2个新实例属性, MovieListViewViewModeldisposeBag
  3. viewDidLoad ,我们通过驱动程序初始化MovieListViewViewModel 。 驱动程序本身在UISegmentedControl selectedSegmentIndex属性上使用RxCocoa扩展。 这样,无论何时更新选定的段索引,我们都可以观察到新值。 在这里,我们还使用map运算符,使用带有索引的可选初始化程序将索引转换为Endpoint枚举。 我们还将MovieService传递给初始化程序,您也可以传递返回存根的模拟对象,而不是出于测试目的而调用TMDb API。
  4. 接下来,我们从视图模型观察movies属性。 每当更新值时,我们只需调用表视图重载数据来更新列表。
  5. 接下来,我们在isFetching属性上使用绑定到活动指示器视图RxCocoa isAnimating属性。 因此,只要isFetching值为true,它将自动为活动指示器设置动画。
  6. 最后,我们观察到error属性,因此无论何时更新它并存在,我们都将使用infoLabel属性来显示错误,否则我们将隐藏infoLabel

最后,让我们更新表视图数据源实现以使用我们的新视图模型。

  扩展 MovieListViewController:UITableViewDataSource,UITableViewDelegate { 

func tableView( _ tableView:UITableView,numberOfRowsInSection部分:Int)-> Int {
返回 movieListViewViewModel.numberOfMovies
}
  func tableView( _ tableView:UITableView,cellForRowAt indexPath:IndexPath)-> UITableViewCell { 
   cell = tableView.dequeueReusableCell(withIdentifier:“ MovieCell”,for:indexPath)  ! 电影单元 
如果 viewModel = movieListViewViewModel.viewModelForMovie(at:indexPath.row){
cell.configure(viewModel:viewModel)
}
返回单元
}
}

在这里,我们只需要更新numberOfRowsInSection方法即可返回MovieListViewViewModel numberOfMovies属性。 在tableView(_:cellForRowAt:) ,我们可以使用MovieListViewViewModel viewModelForMovieAtIndex:返回相应indexPath row上的indexPath row

MovieListViewController重构使用MVVM就是这样。 我们还可以删除实际上不再使用的所有属性和方法。 它们已被移入MovieListViewViewModel以进行更好的封装!

MovieSearchViewController当前MVC状态

让我们看一下当前的MovieSearchViewController实现。 它与MovieListViewController有许多相似之处,例如:

  1. 它存储Movies数组作为实例属性。
  2. MovieService可从TMDb API获取数据
  3. tableView(_:cellForRowAt:)方法可根据indexPath row返回MovieViewView模型。
  4. UI元素,例如table viewactivity indicatorinfo label

为了不重复这些内容,您可以参考本文的MovieListViewController当前MVC状态部分中的详细信息。

相反,我们将关注差异,它们是:

  1. 不是使用分段控件来过滤电影的结果,而是使用UISearchBar根据用户键入的关键字查询电影。
  2. 与其在指定的端点处调用MovieService fetchMoviessearchMovie通过将查询作为参数来调用MovieService searchMovie
  3. MovieSearchViewController将被分配为Search Bar的委托,每当用户点击回车时,它将调用searchMovie方法并传递搜索栏文本。 当用户点击“取消”时,它将清除列表中的电影,并在信息标签上设置一个占位符以邀请用户进行搜索。

这就是当前状态的全部内容,让我们继续通过创建MovieSearchViewViewModel进行重构。

重构为MovieSearchViewViewModel

让我们使用MovieSearchViewViewModel作为文件MovieSearchViewViewModel我们的视图模型创建新文件。 接下来,让我们导入RxSwift和RxCocoa框架,然后声明该类。

 进口基金会 
导入RxSwift
进口RxCocoa
   MovieSearchViewViewModel { 
  私有 let movieService:MovieService 
私人 disposeBag = DisposeBag()
  私人 let _movies = BehaviorRelay (值:[]) 
私人 let _isFetching = BehaviorRelay (值: false
私人 let _info = BehaviorRelay (值: nil
  var isFetching:Driver  { 
返回 _isFetching.asDriver()
}
  var movie:Driver  { 
返回 _movies.asDriver()
}
  var info:Driver  { 
返回 _info.asDriver()
}
  var hasInfo:Bool { 
返回 _info.value!=
}
  var numberOfMovies:Int { 
返回 _movies.value.count
}
}

MovieListViewViewModel相比,这些属性几乎完全相同,唯一的区别是我们用info属性替换了error属性。 在搜索中,当搜索栏文本为空时,我们还将显示占位符文本。 您可以在本文的“将影片重构为MovieListViewViewModel”部分中看到有关这些属性的更多详细信息。

接下来,让我们声明与MovieListViewViewModel相比完全不同的初始化器和方法。

 进口基金会 
导入RxSwift
进口RxCocoa
   MovieSearchViewViewModel { 
  初始化 (查询:Driver ,movieService:MovieService){ 
自我 .movi​​eService = movieService
询问
.throttle(1.0)
.distinctUntilChanged()
.drive(onNext:{[弱自我 ](queryString)
自我 ?.searchMovie(query:queryString)
如果 queryString.isEmpty {
自我 ?._ movies.accept([])
自我 ?._ info.accept(“开始搜索您喜欢的电影”)
}
} .disposed(作者:disposeBag)
}
  func viewModelForMovie(索引为Int)-> MovieViewViewModel?  { 
防护指标<_movies.value.count 其他 {
返回
}
返回 MovieViewViewModel(电影:_movies.value [index])
}
  私人 函数 searchMovie(query:String?){ 
保护 查询=查询,!query.isEmpty 其他 {
返回
}
自我 ._movies.accept([])
自我 ._isFetching.accept( true
自我 ._info.accept( nil
movieService.searchMovie(query:query,params: nil ,successHandler:{[weak self ](response) in
自我 ?._ isFetching.accept( false
如果 response.totalResults == 0 {
自我 ?._ info.accept(“ \(查询)没有结果”)
}
自我 ?._ movies.accept(Array(response.results.prefix(5)))
}){[弱自我 ](错误)
自我 ?._ isFetching.accept( false
自我 ?._ info.accept(error.localizedDescription)
}
}
}

让我们将其分解为几个要点:

  1. 初始化程序接受类型为String Driver以及MovieService作为参数。 然后将movieService分配给实例属性。 订阅driver ,我们还将应用throttle操作符,这将在指定的时间段内限制流,在这种情况下,我们将其设置为1秒。 我们避免了用户对TMDb API键入的每个关键字的过多请求。 distinctUntilChanged运算符阻止应用相同的关键字。 每当收到下一个查询时,我们都会调用传递该查询的searchMovies 。 同样,我们检查查询长度是否为空,将占位符文本添加到信息主题。
  2. searchMovie方法将调用TMDb API,并使用accept方法将所有值发布到指定的BehaviorRelay属性,例如_movies_isFetching_error 。 所有状态(例如fetchingerrorsuccess提取)均由视图模型管理。
  3. viewModelForMovie(at index:)是一个辅助方法,它将以指定的索引返回MovieViewViewModel 。 这将在视图控制器的tableView(_:cellForRowAt:)方法中使用。

接下来,我们需要更新MovieSearchViewController以使用刚创建的视图模型。

  导入 UIKit 
进口 RxCocoa
导入 RxSwift
   MovieSearchViewController:UIViewController { 
  @IBOutlet  var tableView:UITableView! 
@IBOutlet var infoLabel:UILabel!
@IBOutlet var activityIndi​​catorView:UIActivityIndi​​catorView!
  var movieSearchViewViewModel:MovieSearchViewViewModel! 
disposeBag = DisposeBag()
  覆盖 func viewDidLoad(){ 
超级 .viewDidLoad()
setupNavigationBar()
searchBar = self .navigationItem.searchController!.searchBar
  movieSearchViewViewModel = MovieSearchViewViewModel(查询:searchBar.rx.text.orEmpty.asDriver(),movieService:MovieStore.shared) 
  movieSearchViewViewModel 
.movi​​es.drive(onNext:{[unown self ]( _in
自我 .tableView.reloadData()
} .disposed(作者:disposeBag)
  movieSearchViewViewModel 
.isFetching
.drive(activityIndi​​catorView.rx.isAnimating)
.disposed(作者:disposeBag)
  movieSearchViewViewModel.info.drive(onNext:{[unown self ](info) in 
自我 .infoLabel.isHidden =! 自我 .movi​​eSearchViewViewModel.hasInfo
自我 .infoLabel.text = info
} .disposed(作者:disposeBag)
  searchBar.rx.searchButtonClicked 
.asDriver(onErrorJustReturn:())
.drive(onNext:{中的[[unown searchBar]
searchBar.resignFirstResponder()
} .disposed(作者:disposeBag)
  searchBar.rx.cancelButtonClicked 
.asDriver(onErrorJustReturn:())
.drive(onNext:{中的[[unown searchBar]
searchBar.resignFirstResponder()
} .disposed(作者:disposeBag)
  setupTableView() 
}
  ... 
}

在这里,它与MovieListViewController viewDidLoad方法几乎相同,但是我们声明了MovieSearchViewViewModel ,而不是声明MovieSearchViewViewModel 。 在viewDidLoad ,我们实例化它通过带有RxCocoa的搜索栏扩展名,该扩展名将text属性公开为驱动程序。 此外,我们使用searchButtonClicked处理搜索栏searchButtonClicked和cancelButtonClicked,以便在传递新事件时关闭屏幕键盘。

最后,让我们更新表视图数据源实现以使用我们的新视图模型。

  扩展名 MovieSearchViewController:UITableViewDataSource,UITableViewDelegate { 

func tableView( _ tableView:UITableView,numberOfRowsInSection部分:Int)-> Int {
返回 movieSearchViewViewModel.numberOfMovies
}
  func tableView( _ tableView:UITableView,cellForRowAt indexPath:IndexPath)-> UITableViewCell { 
   cell = tableView.dequeueReusableCell(withIdentifier:“ MovieCell”,for:indexPath)  ! 电影单元 
如果 viewModel = movieSearchViewViewModel.viewModelForMovie(at:indexPath.row){
cell.configure(viewModel:viewModel)
}
返回单元
}
}

在这里,我们只需要更新numberOfRowsInSection方法即可返回MovieSearchViewViewModel numberOfMovies属性。 在tableView(_:cellForRowAt:)我们可以使用MovieSearchViewViewModel viewModelForMovieAtIndex:返回相应indexPath row上的indexPath row

这就是将MovieSearchViewController重构为MVVM的全部内容,您也可以删除Search Bar委托的实现,因为我们已经在使用RxCocoa从搜索栏中订阅和处理这些事件!

就是这样!尝试构建该应用程序,以确保它在所有重构中均能正常运行。 您可以在下面的GitHub存储库中查看项目的完整源代码。

alfianlosari / MovieInfoMVVMiOS
使用通过MVVM构建的TMDb API的电影信息应用程序。 通过创建一个 github.com 来为alfianlosari / MovieInfoMVVMiOS开发做出贡献

结论

使用MVVM作为体系结构构建应用程序并不复杂。 从长远来看,ViewModel确实可以帮助View Controller变得更轻量级并封装我们的数据转换。 在视图模型上进行测试也非常简单明了,无需担心视图控制器。

尽管RxSwift可能会变得非常复杂,并且对刚刚接触过反应式编程的开发人员来说具有陡峭的学习曲线,但它还具有强大的功能,例如节流阀,还有更多值得探索的地方。 下次,让我们探讨如何使用RxSwift的RxTest和RxBlocking在View Model上执行单元测试。 因此,在此之前,让我们继续终身学习,不断发展壮大。 雨燕快乐ing。