iOS App 程式開發

利用 Container View Controller 拆開職責 視圖控制器不再複雜又臃腫!

利用 Container View Controller 拆開職責 視圖控制器不再複雜又臃腫!
利用 Container View Controller 拆開職責 視圖控制器不再複雜又臃腫!
In: iOS App 程式開發, Swift 程式語言
本篇原文(標題:Avoiding Massive View Controller using Containment & Child View Controller)刊登於作者 Medium,由 Alfian Losari 所著,並授權翻譯及轉載。

container-view-controller-1

視圖控制器 (View Controller) 這個元件提供基本構建塊,作為我們在 iOS 開發中構建 App 的基礎。在 Apple MVC 世界中,它是視圖 (View) 和模型 (Model) 之間的橋樑,充當兩者之間的協調者。控制器是一個觀察者,針對模型的更改作回應,然後更新視圖;並從使用 Target Action 的視圖接受用戶操作,然後更新模型。

container-view-controller-2
(圖片來源:Apple Inc.

身為 iOS 開發人員,即使我們使用 MVVM、MVP 或 VIPER 等架構,我們還是經常面對 Massive View Controller 的問題。有時候,視圖控制器在單一螢幕上職責過多,違反了單一責任原則 (Single Responsibility Principle, SRP),導致模組之間產生緊密耦合,難以重用及測試每個元件。

以下面的 App 截圖為範例,你可以看到一個螢幕中至少有三件事情要處理:

  1. 顯示電影列表
  2. 顯示可以選擇應用於電影列表的過濾器列表
  3. 清除所選過濾器的設定

container-view-controller-3

如果我們使用單一視圖控制器構建這螢幕,視圖控制器肯定會變得非常臃腫,因為它在一個螢幕中要處理的職責太多了。

怎樣才能解決這個問題呢?其中一個解決方案是使用視圖控制器容器 (View Controller Containment) 和子視圖控制器 (Child View Controller)。讓我們來看看這個解決方案的好處:

  1. 將電影列表封裝到 MovieListViewController 中,它只負責顯示電影列表,並對 Movie 模型的更改作出反應。如果我們想只顯示沒有過濾器的電影列表,我們也可以在另一個螢幕中重用這個 MovieListViewController
  2. 將列表呈現 (listing) 和過濾選項 (selection of filters) 的邏輯封裝到 FilterListViewController 中,它只負責顯示和處理過濾選項。當用戶選擇和取消某個過濾選項時,我們可以使用 Delegate 與父視圖控制器進行溝通。
  3. 將主視圖控制器減小為一個 ContainerViewController,它只負責將選定的過濾條件從過濾器列表應用到 MovieListViewController 中的 Movie 模型,同時設置佈局,並使用容器視圖 (Container View) 添加子視圖控制器。

你可以在 GitHub 上查看完整的專案原始碼。

使用 Storyboard 的視圖控制器結構

container-view-controller-4

觀察上面的 Storyboard,以下是我們用來構建過濾器螢幕的視圖控制器:

  1. ContainerViewController:這個視圖控制器容器提供了兩個容器視圖,將子視圖控制器嵌入到水平的 UIStackView 中。它提供了一個 UIButton 來取消已選擇的過濾選項。它還嵌入到一個做為初始視圖控制器的 UINavigationController 中。
  2. FilterListMovieController:這個視圖控制器是 UITableViewController 的子類,帶有 Grouped 樣式、和一個用來顯示過濾選項名稱的 Standard Cell。它還配有 Storyboard ID,因此可以通過編程方式從 ContainerViewController 實例化。
  3. MovieListViewController:這個視圖控制器是 UITableViewController 的子類,帶有 Plain 樣式、和一個用來顯示 Movie 屬性的 Subtitle Cell。它像 FilterListViewController 一樣,也配有 Storyboard ID。

電影列表視圖控制器

電影列表視圖控制器負責顯示作為實例屬性公開的 Movie 模型列表,我們使用 Swift didSet 屬性觀察器來響應模型中的更改,然後重新加載 UITableView。每個 Cell 使用預設 subtitle UITableViewCellStyle 的樣式,來顯示 Movie 的 Title(標題)、Duration(片長)、Rating(評價)和 Genre(類型)。


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

過濾列表視圖控制器

過濾器列表在三個不同的 section 中顯示 MovieFilter 列舉 (enum):genreratingdurationMovieFilter 列舉遵從 Hashable 協定,因此可以使用每個列舉及其屬性的 hash 值,將列舉獨立地儲存於 Set 中。過濾選項的選擇則是儲存在包含 MovieFilter 的 Set 實例屬性下。

要與其他物件溝通,我們可以利用 FilterListControllerDelegate 使用委任模式 (Delegate Pattern)。實現委任的方法有三種:

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

視圖控制器容器內的整合

ContainerViewController 裡面,我們有幾個實例屬性:

  1. FilterListContainerView & MovieListContainerView:用於添加子視圖控制器的容器視圖。
  2. FilterListViewController & MovieListViewController:使用 Storyboard ID 實例化的影片列表和篩選器列表視圖控制器的引用。
  3. movie:使用預設 hardcode 電影實例的 Movie 陣列。

當調用 viewDidLoad 時,我們呼叫該方法來設置子視圖控制器。以下是它執行的任務:

  1. 使用 Storyboard ID 實例化 FilterListViewControllerMovieListViewController
  2. 將它們分配給實例屬性;
  3. MovieListViewController 分配給 movies 陣列;
  4. ContainerViewController 指定為 FilterListViewControllerdelegate,以便它回應過濾器選擇;
  5. 設置子視圖框架,並使用擴展 (extension) 幫助方法將它們添加為子視圖控制器。

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


總結

通過這個範例專案,我們可以看到在 App 內使用視圖控制器容器和子視圖控制器的好處。我們可以將單個視圖控制器的職責,分給多個視圖控制器,每個視圖控制器只具有單一職責。我們還需要確保,子視圖控制器對其父級沒有任何依賴。為了讓子視圖控制器與父級進行通信,我們可以使用委任模式。

這個方法還有模塊鬆耦合的優點,提高每個元件的可重用性和可測試性。隨著我們的 App 變得更大、更複雜,這個方法確實有助於我們擴展它。讓我們一起繼續學習吧 📖!

本篇原文(標題:Avoiding Massive View Controller using Containment & Child View Controller)刊登於作者 Medium,由 Alfian Losari 所著,並授權翻譯及轉載。
作者簡介:Alfian Losari 是一位軟體工程師和 Swift 愛好者,他非常熱愛一切關係科技及其價值的事。他的座右銘是:如果人停止學習,就永遠不會進步。
譯者簡介:陳奕先-過去為平面財經記者,專跑產業新聞,2015 年起跨進軟體開發世界,希望在不同領域中培養新的視野,於新創學校 ALPHA Camp 畢業後,積極投入 iOS 程式開發,目前任職於國內電商公司。聯絡方式:電郵 [email protected]

FB : https://www.facebook.com/yishen.chen.54
Twitter : https://twitter.com/YeEeEsS

作者
AppCoda 編輯團隊
此文章為客座或轉載文章,由作者授權刊登,AppCoda編輯團隊編輯。有關文章詳情,請參考文首或文末的簡介。
評論
很好! 你已成功註冊。
歡迎回來! 你已成功登入。
你已成功訂閱 AppCoda 中文版 電子報。
你的連結已失效。
成功! 請檢查你的電子郵件以獲取用於登入的連結。
好! 你的付費資料已更新。
你的付費方式並未更新。