[譯] MVVM-C 與 Swift

Abyssea發表於2017-04-13

MVVM-C 與 Swift

[譯] MVVM-C 與 Swift

簡介

現今,iOS 開發者面臨的最大挑戰是構建一個健壯的應用程式,它必須易於維護、測試和擴充套件。

在這篇文章裡,你會學到一種可靠的方法來達到目的。

首先,簡要介紹下你即將學習的內容:
架構模式.

架構模式

它是什麼

架構模式是給定上下文中軟體體系結構中常見的,可重用的解決方案。架構與軟體設計模式相似,但涉及的範圍更廣。架構解決了軟體工程中的各種問題,如計算機硬體效能限制,高可用性和最小化業務風險。一些架構模式已經在軟體框架內實現。

摘自 Wikipedia

在你開始一個新專案或功能的時候,你需要花一些時間來思考架構模式的使用。通過一個透徹的分析,你可以避免耗費很多天的時間在重構一個混亂的程式碼庫上。

主要的模式

在專案中,有幾種可用的架構模式,並且你可以在專案中使用多個,因為每個模式都能更好地適應特定的場景。

當你閱讀這幾種模式時,主要會遇到:

Model-View-Controller

[譯] MVVM-C 與 Swift

這是最常見的,也許在你的第一個 iOS 應用中已經使用過。不幸地是,這也是最糟糕的模式,因為 Controller 不得不管理每一個依賴(API、資料庫等等),包括你應用的業務邏輯,而且與 UIKit 的耦合度很高,這意味著很難去測試。

你應該避免這種模式,用下面的某種來代替它。

Model-View-Presenter

[譯] MVVM-C 與 Swift

這是第一個 MVC 模式的備選方案之一,一次對 ControllerView 之間解耦的很好的嘗試。

在 MVP 中,你有一層叫做 Presenter 的新結構來處理業務邏輯。而 View —— 你的 UIViewController 以及任何 UIKit 元件,都是一個笨的物件,他們只通過 Presenter 更新,並在 UI 事件被觸發的時候,負責通知 Presenter。由於 Presenter 沒有任何 UIKit 的引用,所以非常容易測試。

Viper

[譯] MVVM-C 與 Swift

這是 Bob 叔叔的清晰架構的代表。

這種模式的強大之處在於,它合理分配了不同層次之間的職責。通過這種方式,你的每個層次做的的事變得很少,易於測試,並且具備單一職責。這種模式的問題是,在大多數場合裡,它過於複雜。你需要管理很多層,這會讓你感到混亂,難於管理。

這種模式並不容易掌握,你可以在這裡找到關於這種架構模式更詳細的文章。

Model-View-ViewModel

[譯] MVVM-C 與 Swift

最後但也是最重要的,MVVM 是一個類似於 MVP 的框架,因為層級結構幾乎相同。你可以認為 MVVM 是 MVP 版本的一個進化,而這得益於 UI 繫結。

UI 繫結是在 ViewViewModel 之間建立一座單向或雙向的橋樑,並且兩者之間以一種非常透明地方式進行溝通。

不幸地是,iOS 沒有原生的方式來實現,所以你必須通過三方庫/框架或者自己寫一個來達成目的。

在 Swift 裡有多種方式實現 UI 繫結:

RxSwift (或 ReactiveCocoa)

RxSwiftReactiveX 家族的一個 Swift 版本的實現。一旦你掌握了它,你就能很輕鬆地切換到 RxJava、RxJavascript 等等。

這個框架允許你來用函式式(FRP)的方式來編寫程式,並且由於內部庫 RxCocoa,你可以輕鬆實現 ViewViewModel 之間的繫結:

class ViewController: UIViewController {

    @IBOutlet private weak var userLabel: UILabel!

    private let viewModel: ViewModel
    private let disposeBag: DisposeBag

    private func bindToViewModel() {
        viewModel.myProperty
            .drive(userLabel.rx.text)
            .disposed(by: disposeBag)
    }
}複製程式碼

我不會解釋如何徹底地使用 RxSwift,因為這超出本文的目標,它自己會有文章來解釋。

FRP 讓你學習到了一種新的方式來開發,你可能對它或愛或恨。如果你沒用過 FRP 開發,那你需要花費幾個小時來熟悉和理解如何正確地使用它,因為它是一個完全不同的程式設計概念。

另一個類似於 RxSwift 的框架是 ReactiveCocoa,如果你想了解他們之間主要的區別的話,你可以看看這篇文章

代理

如果你想避免匯入並學習新的框架,你可以使用代理作為替代。不幸地是,使用這種方法,你將失去透明繫結的功能,因為你必須手動繫結。這個版本的 MVVM 非常類似於 MVP。

這種方式的策略是通過 View 內部的 ViewModel 保持一個對代理實現的引用。這樣 ViewModel 就能在無需引用任何 UIKit 物件的情況下更新 View

這有個例子:

class ViewController: UIViewController, ViewModelDelegate {

    @IBOutlet private weak var userLabel: UILabel?

    private let viewModel: ViewModel

    init(viewModel: ViewModel) {
        self.viewModel = viewModel
        super.init(nibName: nil, bundle: nil)
        viewModel.delegate = self
    }

    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    func userNameDidChange(text: String) {
        userLabel?.text = text
    }
}


protocol ViewModelDelegate: class {
    func userNameDidChange(text: String)
}

class ViewModel {

    private var userName: String {
        didSet {
            delegate?.userNameDidChange(text: userName)
        }
    }
    weak var delegate: ViewModelDelegate? {
        didSet {
            delegate?.userNameDidChange(text: userName)
        }
    }

    init() {
        userName = "I ? hardcoded values"
    }
}複製程式碼

閉包

和代理非常相似,不過不同的是,你使用的是閉包來代替代理。

閉包是 ViewModel 的屬性,而 View 使用它們來更新 UI。你必須注意在閉包裡使用 [weak self],避免造成迴圈引用。

關於 Swift 閉包的迴圈引用,你可以閱讀這篇文章

這有一個例子:

class ViewController: UIViewController {

    @IBOutlet private weak var userLabel: UILabel?

    private let viewModel: ViewModel

    init(viewModel: ViewModel) {
        self.viewModel = viewModel
        super.init(nibName: nil, bundle: nil)
        viewModel.userNameDidChange = { [weak self] (text: String) in
            self?.userNameDidChange(text: text)
        }
    }

    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    func userNameDidChange(text: String) {
        userLabel?.text = text
    }
}

class ViewModel {

    var userNameDidChange: ((String) -> Void)? {
        didSet {
            userNameDidChange?(userName)
        }
    }

    private var userName: String {
        didSet {
            userNameDidChange?(userName)
        }
    }

    init() {
        userName = "I ? hardcoded values"
    }
}複製程式碼

抉擇: MVVM-C

在你不得不選擇一個架構模式時,你需要理解哪一種更適合你的需求。在這些模式裡,MVVM 是最好的選擇之一,因為它強大的同時,也易於使用。

不幸地是這種模式並不完美,主要的缺陷是 MVVM 沒有路由管理。

我們要新增一層新的結構,來讓它獲得 MVVM 的特性,並且具備路由的功能。於是它就變成了:Model-View-ViewModel-Coordinator (MVVM-C)

示例的專案會展示 Coordinator 如何工作,並且如何管理不同的層次。

[譯] MVVM-C 與 Swift

入門

你可以在這裡下載專案原始碼。

這個例子被簡化了,以便於你可以專注於 MVVM-C 是如何工作的,因此 GitHub 上的類可能會有輕微出入。

示例應用是一個普通的儀表盤應用,它從公共 API 獲取資料,一旦資料準備就緒,使用者就可以通過 ID 查詢實體,如下面的截圖:

[譯] MVVM-C 與 Swift

應用程式有不同的方式來新增檢視控制器,所以你會看到,在有子檢視控制器的邊緣案例中,如何使用 Coordinator

MVVM-C 的層級結構

Coordinator

它的職責是顯示一個新的檢視,並注入 ViewViewModel 所需要的依賴。

Coordinator 必須提供一個 start 方法,來建立 MVVM 層次並且新增 View 到檢視的層級結構中。

你可能會經常有一組 Coordinator 子類,因為在你當前的檢視中,可能會有子檢視,就像我們的例子一樣:

final class DashboardContainerCoordinator: Coordinator {

    private var childCoordinators = [Coordinator]()

    private weak var dashboardContainerViewController: DashboardContainerViewController?
    private weak var navigationController: UINavigationControllerType?

    private let disposeBag = DisposeBag()

    init(navigationController: UINavigationControllerType) {
        self.navigationController = navigationController
    }

    func start() {
        guard let navigationController = navigationController else { return }
        let viewModel = DashboardContainerViewModel()
        let container = DashboardContainerViewController(viewModel: viewModel)

        bindShouldLoadWidget(from: viewModel)

        navigationController.pushViewController(container, animated: true)

        dashboardContainerViewController = container
    }

    private func bindShouldLoadWidget(from viewModel: DashboardContainerViewModel) {
        viewModel.rx_shouldLoadWidget.asObservable()
            .subscribe(onNext: { [weak self] in
                self?.loadWidgets()
            })
            .addDisposableTo(disposeBag)
    }

    func loadWidgets() {
        guard let containerViewController = usersContainerViewController() else { return }
        let coordinator = UsersCoordinator(containerViewController: containerViewController)
        coordinator.start()

        childCoordinators.append(coordinator)
    }

    private func usersContainerViewController() -> ContainerViewController? {
        guard let dashboardContainerViewController = dashboardContainerViewController else { return nil }

        return ContainerViewController(parentViewController: dashboardContainerViewController,
                                       containerView: dashboardContainerViewController.usersContainerView)
    }
}複製程式碼

你一定能注意到在 Coordinator 裡,一個父類 UIViewController 物件或者子類物件,比如 UINavigationController,被注入到構造器之中。因為 Coordinator 有責任新增 View 到檢視層級之中,它必須知道那個父類新增了 View

在上面的例子裡,DashboardContainerCoordinator 實現了協議 Coordinator

protocol Coordinator {
    func start()
}複製程式碼

這便於你使用多型)。

建立完第一個 Coordinator 後,你必須把它作為程式的入口放到 AppDelegate 中:

class AppDelegate: UIResponder, UIApplicationDelegate {

    var window: UIWindow?

    private let navigationController: UINavigationController = {
        let navigationController = UINavigationController()
        navigationController.navigationBar.isTranslucent = false
        return navigationController
    }()

    private var mainCoordinator: DashboardContainerCoordinator?

    func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {
        window = UIWindow()
        window?.rootViewController = navigationController
        let coordinator = DashboardContainerCoordinator(navigationController: navigationController)
        coordinator.start()
        window?.makeKeyAndVisible()

        mainCoordinator = coordinator

        return true
    }
}複製程式碼

AppDelegate 裡,我們例項化一個新的 DashboardContainerCoordinator,通過 start 方法,我們把新的檢視推入 navigationController 裡。

你可以看到在 GitHub 上的專案是如何注入一個 UINavigationController 型別的物件,並去除 UIKitCoordinator 之間的耦合。

Model

Model 代表資料。它必須儘可能的簡潔,沒有業務邏輯。

struct UserModel: Mappable {
    private(set) var id: Int?
    private(set) var name: String?
    private(set) var username: String?

    init(id: Int?, name: String?, username: String?) {
        self.id = id
        self.name = name
        self.username = username
    }

    init?(map: Map) { }

    mutating func mapping(map: Map) {
        id <- map["id"]
        name <- map["name"]
        username <- map["username"]
    }
}複製程式碼

例項專案使用開源庫 ObjectMapper 將 JSON 轉換為物件。

ObjectMapper 是一個使用 Swift 編寫的框架。它可以輕鬆的讓你在 JSON 和模型物件(類和結構體)之間相互轉換。

在你從 API 獲得一個 JSON 響應的時候,它會非常有用,因為你必須建立模型物件來解析 JSON 字串。

View

View 是一個 UIKit 物件,就像 UIViewController 一樣。

它通常持有一個 ViewModel 的引用,通過 Coordinator 注入來建立繫結。

final class DashboardContainerViewController: UIViewController {

    let disposeBag = DisposeBag()

    private(set) var viewModel: DashboardContainerViewModelType

    init(viewModel: DashboardContainerViewModelType) {
        self.viewModel = viewModel

        super.init(nibName: nil, bundle: nil)

        configure(viewModel: viewModel)
    }

    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    func configure(viewModel: DashboardContainerViewModelType) {
        viewModel.bindViewDidLoad(rx.viewDidLoad)

        viewModel.rx_title
            .drive(rx.title)
            .addDisposableTo(disposeBag)
    }
}複製程式碼

在這個例子中,檢視控制器中的標題被繫結到 ViewModelrx_title 屬性上。這樣在 ViewModel 更新 rx_title 值的時候,檢視控制器中的標題就會根據新的值自動更新。

ViewModel

ViewModel 是這種架構模式的核心層。它的職責是保持 ViewModel 的更新。由於業務邏輯在這個類中,你需要用不同的元件的單一職責來保證 ViewModel 儘可能的乾淨。

final class UsersViewModel {

    private var dataProvider: UsersDataProvider
    private var rx_usersFetched: Observable<[UserModel]>

    lazy var rx_usersCountInfo: Driver<String> = {
        return UsersViewModel.createUsersCountInfo(from: self.rx_usersFetched)
    }()
    var rx_userFound: Driver<String> = .never()

    init(dataProvider: UsersDataProvider) {
        self.dataProvider = dataProvider

        rx_usersFetched = dataProvider.fetchData(endpoint: "http://jsonplaceholder.typicode.com/users")
            .shareReplay(1)
    }

    private static func createUsersCountInfo(from usersFetched: Observable<[UserModel]>) -> Driver<String> {
        return usersFetched
            .flatMapLatest { users -> Observable<String> in
                return .just("The system has \(users.count) users")
            }
            .asDriver(onErrorJustReturn: "")
    }
}複製程式碼

在這個例子中,ViewModel 有一個在構造器中注入的資料提供者,它用於從公共 API 中獲取資料。一旦資料提供者返回了取得的資料,ViewModel 就會通過 rx_usersCountInfo 發射一個新使用者數量相關的新事件。因為繫結了觀察者 rx_usersCountInfo,這個新事件會被髮送給 View,然後更新 UI。

可能會有很多不同的元件在你的 ViewModel 裡,比如一個用來管理資料庫(CoreData、Realm 等等)的資料控制器,一個用來與你 API 和其他任何外部依賴互動的資料提供者。

因為所有 ViewModel 都使用了 RxSwift,所以當一個屬性是 RxSwift 型別(DriverObservable 等等)的時候,就會有一個 rx_ 字首。這不是強制的,只是它可以幫助你更好的識別哪些屬性是 RxSwift 物件。

結論

MVVM-C 有很多優點,可以提高應用程式的質量。你應該注意使用哪種方式來進行 UI 繫結,因為 RxSwift 不容易掌握,而且如果你不明白你做的是什麼,除錯和測試有時可能會有點棘手。

我的建議是一點點地開始使用這種架構模式,這樣你可以學習不同層次的使用,並且能保證層次之間的良好的分離,易於測試。

FAQ

MVVM-C 有什麼限制嗎?

是的,當然有。如果你正做一個複雜的專案,你可能會遇到一些邊緣案例,MVVM-C 可能無法使用,或者在一些小功能上使用過度。如果你開始使用 MVVM-C,並不意味著你必須在每個地方都強制的使用它,你應該始終選擇更適合你需求的架構。

我能用 RxSwift 同時使用函式式和指令式程式設計嗎?

是的,你可以。但是我建議你在遺留的程式碼中保持命令式的方式,而在新的實現裡使用函數語言程式設計,這樣你可以利用 RxSwift 強大的優勢。如果你使用 RxSwift 僅僅為了 UI 繫結,你可以輕鬆使用命令式編寫程式,而只用函式響應式程式設計來設定繫結。

我可以在企業專案中使用 RxSwift 嗎?

這取決於你要開新專案,還是要維護舊程式碼。在有遺留程式碼的專案中,你可能無法使用 RxSwift,因為你需要重構很多的類。如果你有時間和資源來做,我建議你新開一專案一點一點的做,否則還是嘗試其他的方法來解決 UI 繫結的問題。

需要考慮的一個重要事情是,RxSwift 最終會成為你專案中的另一個依賴,你可能會因為 RxSwift 的破壞性改動而導致浪費時間的風險,或者缺少要在邊緣案例中實現功能的文件。


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOSReact前端後端產品設計 等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃

相關文章