通过视图控制器容器和子视图控制器避免庞大的视图控制器

首发于:【译】通过视图控制器容器和子视图控制器避免庞大的视图控制器

通过视图控制器容器和子视图控制器避免庞大的视图控制器

通过视图控制器容器和子视图控制器避免庞大的视图控制器
视图控制器容器和子视图控制器图解

View Controller 是一个提供基本构建块的组件,在 iOS 开发中我们以它为基础构建应用。在 Apple MVC 世界中,它作为 View 和 Model 的中间人,在两者之间充当协调者的角色。它以观察者控制器开始,响应模型更改、更新视图、使用目标操作从视图中接受用户交互、然后更新模型。

通过视图控制器容器和子视图控制器避免庞大的视图控制器
Apple MVC 图解(Apple 公司提供)

作为一名 iOS 开发者,很多次我们将面临处理庞大的 View Controller 问题,即便我们使用了像 MVVM、MVP 或 VIPER 这样的架构。某些时刻,View Controller 在一个屏幕上承担了太多职责。这违反了 SRP(单一职责原则),在模块之间形成了强度耦合,并使得重用和测试每个组件变得异常困难。

我们可以将下面的应用截图作为示例。你可以看到在一个屏幕上至少存在 3 种职责:

  1. 显示电影列表;
  2. 显示可以选择应用于电影列表的过滤列表;
  3. 清除所选过滤器的选项。

通过视图控制器容器和子视图控制器避免庞大的视图控制器

如果我们准备使用单一的 View Controller 来构建此屏幕,由于它在一个 view controller 中承担了过多职责,因此可以保证这个 view controller 将变得非常庞大和臃肿。

我们如何解决这个问题呢?其中一个解决方案是使用 View Controller 容器和子 View Controller。以下是使用该方案的好处:

  1. 将电影列表封装到 MovieListViewController 中,它只负责显示电影列表并对 Movie 模型中的更改做出响应。如果我们只想显示没有过滤器的电影列表,我们也可以在另一个屏幕中重用它。
  2. 将过滤器中的列表和选择逻辑封装到 FilterListViewController 中,它单独负责显示和过滤器的选择。当用户选择和取消选择时,我们可以使用委托与父 View Controller 进行通信。
  3. 将主 View Controller 缩减为一个 ContainerViewController,它只负责将选中的过滤器从过滤列表应用到 MovieListViewController 中的 Movie 模型。它还设置布局并将子 view controller 添加到容器视图中。

你可以在下面的 GitHub 代码仓库中查看完整的项目源代码。

使用 Storyboard 来布置 View Controller

通过视图控制器容器和子视图控制器避免庞大的视图控制器
使用 Storyboard 来布置 View Controller

  1. ContainerViewController:View Controller 容器提供了 2 个容器视图,用于将子 View Controller 嵌入到水平 UIStackView 中。它还提供了单个 UIButton 来清空所选的过滤器。它还嵌入在充当初始 View Controller 的 UINavigationController 中。
  2. FilterListMovieController:它是 UITableViewController 的子类,具有分类样式和一个用来显示过滤器名称的标准单元格。它还分配了 Storyboard ID,因此可以通过编程的方式在 ContainerViewController 中对它进行实例化。
  3. 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 协议,因此可以使用每个枚举及其属性的哈希值存储在唯一集合中。过滤器的选择存储在包含 MovieFilterSet 的实例属性下。

要与其他对象通信,通过 FilterListControllerDelegate 使用委托模式,委托有三个方法需要实现:

  1. 选择一个过滤器。
  2. 取消选择一个过滤器。
  3. 清空所有已选择过滤器。
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 中,我们有以下几个实例属性:

  1. FilterListContainerViewMovieListContainerView: 用于添加子 view controller 的容器视图。
  2. FilterListViewControllerMovieListViewController:使用 Storyboard ID 实例化的影片列表和筛选器列表 view controller 的引用。
  3. Movie:使用默认硬编码的电影实例的 Movie 数组。

viewDidLoad 被调用时,我们调用该方法来设置子 View Controller。以下是它要执行的几项任务:

  1. 使用 Storyboard ID 实例化 FilterListViewControllerMovieListViewController
  2. 将它们分配给实例属性;
  3. MovieListViewController 分配给 movies 数组;
  4. ContainerViewController 指定为 FilterListViewController 的委托,以便它可以响应过滤器选择;
  5. 设置子视图框架并使用扩展帮助方法将它们添加为子 View Controller。

对于 FilterListViewControllerDelegate 的实现,当选择或取消选择过滤器时,将针对每个类型、评级和持续时间过滤默认的电影数据。然后,过滤器的结果将分配给 MovieListViewControllermovies 属性。要取消选择所有过滤器,它只会分配默认的电影数据。

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 与父级进行通信,我们可以使用委托模式。

该方法还提供了模块松耦合的优点,这可以为每个组件带来更好的可重用性和可测试性。随着我们的应用变得更大、更复杂,该方法确实有助于我们扩展它。让我们继续学习

相关推荐