通过视图控制器容器和子视图控制器避免庞大的视图控制器
首发于:【译】通过视图控制器容器和子视图控制器避免庞大的视图控制器
通过视图控制器容器和子视图控制器避免庞大的视图控制器
视图控制器容器和子视图控制器图解
View Controller 是一个提供基本构建块的组件,在 iOS 开发中我们以它为基础构建应用。在 Apple MVC 世界中,它作为 View 和 Model 的中间人,在两者之间充当协调者的角色。它以观察者控制器开始,响应模型更改、更新视图、使用目标操作从视图中接受用户交互、然后更新模型。
Apple MVC 图解(Apple 公司提供)
作为一名 iOS 开发者,很多次我们将面临处理庞大的 View Controller 问题,即便我们使用了像 MVVM、MVP 或 VIPER 这样的架构。某些时刻,View Controller 在一个屏幕上承担了太多职责。这违反了 SRP(单一职责原则),在模块之间形成了强度耦合,并使得重用和测试每个组件变得异常困难。
我们可以将下面的应用截图作为示例。你可以看到在一个屏幕上至少存在 3 种职责:
- 显示电影列表;
- 显示可以选择应用于电影列表的过滤列表;
- 清除所选过滤器的选项。
如果我们准备使用单一的 View Controller 来构建此屏幕,由于它在一个 view controller 中承担了过多职责,因此可以保证这个 view controller 将变得非常庞大和臃肿。
我们如何解决这个问题呢?其中一个解决方案是使用 View Controller 容器和子 View Controller。以下是使用该方案的好处:
- 将电影列表封装到
MovieListViewController
中,它只负责显示电影列表并对Movie
模型中的更改做出响应。如果我们只想显示没有过滤器的电影列表,我们也可以在另一个屏幕中重用它。 - 将过滤器中的列表和选择逻辑封装到
FilterListViewController
中,它单独负责显示和过滤器的选择。当用户选择和取消选择时,我们可以使用委托与父 View Controller 进行通信。 - 将主 View Controller 缩减为一个 ContainerViewController,它只负责将选中的过滤器从过滤列表应用到
MovieListViewController
中的Movie
模型。它还设置布局并将子 view controller 添加到容器视图中。
你可以在下面的 GitHub 代码仓库中查看完整的项目源代码。
使用 Storyboard 来布置 View Controller
使用 Storyboard 来布置 View Controller
ContainerViewController
:View Controller 容器提供了 2 个容器视图,用于将子 View Controller 嵌入到水平UIStackView
中。它还提供了单个UIButton
来清空所选的过滤器。它还嵌入在充当初始 View Controller 的UINavigationController
中。FilterListMovieController
:它是UITableViewController
的子类,具有分类样式和一个用来显示过滤器名称的标准单元格。它还分配了 Storyboard ID,因此可以通过编程的方式在ContainerViewController
中对它进行实例化。MovieListViewController
:它是UITableViewController
的子类,具有 Plain 样式和一个用来显示Movie
属性的小标题单元格。它还跟FilterListViewController
一样分配了 Storyboard ID。
电影列表 View Controller
此 view controller 负责显示作为实例公开属性的 Movie
模型列表。我们使用 Swift 的 didSet
属性观察器来响应模型的更改,然后重新加载 UITableView
。单元格使用默认小标题样式 UITableViewCellStyle
来显示电影的标题、持续时间、评级和流派。
import UIKit struct Movie { let title: String let genre: String let duration: TimeInterval let rating: Float } class MovieListViewController: UITableViewController { var movies = [Movie]() { didSet { tableView.reloadData() } } let formatter: DateComponentsFormatter = { let formatter = DateComponentsFormatter() formatter.allowedUnits = [.hour, .minute] formatter.unitsStyle = .abbreviated formatter.maximumUnitCount = 1 return formatter }() override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { return movies.count } override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: true) } override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath) let movie = movies[indexPath.row] cell.textLabel?.text = movie.title cell.detailTextLabel?.text = "\(formatter.string(from: movie.duration) ?? ""), \(movie.genre.capitalized), rating: \(movie.rating)" return cell } }
过滤器列表 View Controller
过滤器列表在 3 个单独的部分中显示 MovieFilter
枚举:流派、评级和持续时间。MovieFilter
枚举本身符合 Hashable
协议,因此可以使用每个枚举及其属性的哈希值存储在唯一集合
中。过滤器的选择存储在包含 MovieFilter
的 Set
的实例属性下。
要与其他对象通信,通过 FilterListControllerDelegate
使用委托
模式,委托有三个方法需要实现:
- 选择一个过滤器。
- 取消选择一个过滤器。
- 清空所有已选择过滤器。
import UIKit enum MovieFilter: Hashable { case genre(code: String, name: String) case duration(duration: TimeInterval, name: String) case rating(value: Float, name: String) var hashValue: Int { switch self { case .genre(let code, let name): return "\(code)-\(name)".hashValue case .rating(let value, let name): return "\(value)-\(name)".hashValue case .duration(let duration, let name): return "\(duration)-\(name)".hashValue } } } protocol FilterListViewControllerDelegate: class { func filterListViewController(_ controller: FilterListViewController, didSelect filter: MovieFilter) func filterListViewController(_ controller: FilterListViewController, didDeselect filter: MovieFilter) func filterListViewControllerDidClearFilters(controller: FilterListViewController) } class FilterListViewController: UITableViewController { let filters = MovieFilter.defaultFilters weak var delegate: FilterListViewControllerDelegate? var selectedFilters: Set<MovieFilter> = [] override func viewDidLoad() { super.viewDidLoad() } func clearFilter() { selectedFilters.removeAll() delegate?.filterListViewControllerDidClearFilters(controller: self) tableView.reloadData() } override func numberOfSections(in tableView: UITableView) -> Int { return filters.count } override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { return filters[section].filters.count } override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { return filters[section].title } override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: true) let filter = filters[indexPath.section].filters[indexPath.row] if selectedFilters.contains(filter) { selectedFilters.remove(filter) delegate?.filterListViewController(self, didDeselect: filter) } else { selectedFilters.insert(filter) delegate?.filterListViewController(self, didSelect: filter) } tableView.reloadRows(at: [indexPath], with: .automatic) } override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath) let filter = filters[indexPath.section].filters[indexPath.row] switch filter { case .genre(_, let name): cell.textLabel?.text = name case .rating(_, let name): cell.textLabel?.text = name case .duration(_, let name): cell.textLabel?.text = name } if selectedFilters.contains(filter) { cell.accessoryType = .checkmark } else { cell.accessoryType = .none } return cell } }
在容器 View Controller 中集成
在 ContainerViewController
中,我们有以下几个实例属性:
FilterListContainerView
和MovieListContainerView
: 用于添加子 view controller 的容器视图。FilterListViewController
和MovieListViewController
:使用 Storyboard ID 实例化的影片列表和筛选器列表 view controller 的引用。Movie
:使用默认硬编码的电影实例的Movie
数组。
当 viewDidLoad
被调用时,我们调用该方法来设置子 View Controller。以下是它要执行的几项任务:
- 使用 Storyboard ID 实例化
FilterListViewController
和MovieListViewController
; - 将它们分配给实例属性;
- 将
MovieListViewController
分配给 movies 数组; - 将
ContainerViewController
指定为FilterListViewController
的委托,以便它可以响应过滤器选择; - 设置子视图框架并使用扩展帮助方法将它们添加为子 View Controller。
对于 FilterListViewControllerDelegate
的实现,当选择或取消选择过滤器时,将针对每个类型、评级和持续时间过滤默认的电影数据。然后,过滤器的结果将分配给 MovieListViewController
的 movies
属性。要取消选择所有过滤器,它只会分配默认的电影数据。
import UIKit class ContainerViewController: UIViewController { @IBOutlet weak var filterListContainerView: UIView! @IBOutlet weak var movieListContainerView: UIView! var filterListVC: FilterListViewController! var movieListVC: MovieListViewController! let movies = Movie.defaultMovies override func viewDidLoad() { super.viewDidLoad() setupChildViewControllers() } private func setupChildViewControllers() { let storyboard = UIStoryboard(name: "Main", bundle: nil) let filterListVC = storyboard.instantiateViewController(withIdentifier: "FilterListViewController") as! FilterListViewController addChild(childController: filterListVC, to: filterListContainerView) self.filterListVC = filterListVC self.filterListVC.delegate = self let movieListVC = storyboard.instantiateViewController(withIdentifier: "MovieListViewController") as! MovieListViewController movieListVC.movies = movies addChild(childController: movieListVC, to: movieListContainerView) self.movieListVC = movieListVC } @IBAction func clearFilterTapped(_ sender: Any) { filterListVC.clearFilter() } private func filterMovies(moviesFilter: [MovieFilter]) { movieListVC.movies = movies .filter(with: moviesFilter.genreFilters) .filter(with: moviesFilter.ratingFilters) .filter(with: moviesFilter.durationFilters) } } extension ContainerViewController: FilterListViewControllerDelegate { func filterListViewController(_ controller: FilterListViewController, didSelect filter: MovieFilter) { filterMovies(moviesFilter: Array(controller.selectedFilters)) } func filterListViewController(_ controller: FilterListViewController, didDeselect filter: MovieFilter) { filterMovies(moviesFilter: Array(controller.selectedFilters)) } func filterListViewControllerDidClearFilters(controller: FilterListViewController) { movieListVC.movies = Movie.defaultMovies } }
结论
通过研究示例项目。我们可以看到在我们的应用中使用 View Controller 容器和子 View Controller 的好处。我们可以将单个 View Controller 的职责划分为单独的 View Controller,它们只具有单一职责(SRP)。我们还需要确保子 View Controller 对其父级没有任何依赖。为了让子 View Controller 与父级进行通信,我们可以使用委托模式。
该方法还提供了模块松耦合的优点,这可以为每个组件带来更好的可重用性和可测试性。随着我们的应用变得更大、更复杂,该方法确实有助于我们扩展它。让我们继续学习