- 原文地址:MVVM-C with Swift
- 原文作者:Marco Santarossa
- 譯文出自:掘金翻譯計劃
- 譯者:Deepmissea
- 校對者:atuooo,1992chenlu
MVVM-C 與 Swift
簡介
現今,iOS 開發者面臨的最大挑戰是構建一個健壯的應用程式,它必須易於維護、測試和擴充套件。
在這篇文章裡,你會學到一種可靠的方法來達到目的。
首先,簡要介紹下你即將學習的內容:
架構模式.
架構模式
它是什麼
架構模式是給定上下文中軟體體系結構中常見的,可重用的解決方案。架構與軟體設計模式相似,但涉及的範圍更廣。架構解決了軟體工程中的各種問題,如計算機硬體效能限制,高可用性和最小化業務風險。一些架構模式已經在軟體框架內實現。
摘自 Wikipedia。
在你開始一個新專案或功能的時候,你需要花一些時間來思考架構模式的使用。通過一個透徹的分析,你可以避免耗費很多天的時間在重構一個混亂的程式碼庫上。
主要的模式
在專案中,有幾種可用的架構模式,並且你可以在專案中使用多個,因為每個模式都能更好地適應特定的場景。
當你閱讀這幾種模式時,主要會遇到:
Model-View-Controller
這是最常見的,也許在你的第一個 iOS 應用中已經使用過。不幸地是,這也是最糟糕的模式,因為 Controller
不得不管理每一個依賴(API、資料庫等等),包括你應用的業務邏輯,而且與 UIKit
的耦合度很高,這意味著很難去測試。
你應該避免這種模式,用下面的某種來代替它。
Model-View-Presenter
這是第一個 MVC 模式的備選方案之一,一次對 Controller
和 View
之間解耦的很好的嘗試。
在 MVP 中,你有一層叫做 Presenter
的新結構來處理業務邏輯。而 View
—— 你的 UIViewController
以及任何 UIKit
元件,都是一個笨的物件,他們只通過 Presenter
更新,並在 UI 事件被觸發的時候,負責通知 Presenter
。由於 Presenter
沒有任何 UIKit
的引用,所以非常容易測試。
Viper
這是 Bob 叔叔的清晰架構的代表。
這種模式的強大之處在於,它合理分配了不同層次之間的職責。通過這種方式,你的每個層次做的的事變得很少,易於測試,並且具備單一職責。這種模式的問題是,在大多數場合裡,它過於複雜。你需要管理很多層,這會讓你感到混亂,難於管理。
這種模式並不容易掌握,你可以在這裡找到關於這種架構模式更詳細的文章。
Model-View-ViewModel
最後但也是最重要的,MVVM 是一個類似於 MVP 的框架,因為層級結構幾乎相同。你可以認為 MVVM 是 MVP 版本的一個進化,而這得益於 UI 繫結。
UI 繫結是在 View
和 ViewModel
之間建立一座單向或雙向的橋樑,並且兩者之間以一種非常透明地方式進行溝通。
不幸地是,iOS 沒有原生的方式來實現,所以你必須通過三方庫/框架或者自己寫一個來達成目的。
在 Swift 裡有多種方式實現 UI 繫結:
RxSwift (或 ReactiveCocoa)
RxSwift 是 ReactiveX 家族的一個 Swift 版本的實現。一旦你掌握了它,你就能很輕鬆地切換到 RxJava、RxJavascript 等等。
這個框架允許你來用函式式(FRP)的方式來編寫程式,並且由於內部庫 RxCocoa,你可以輕鬆實現 View
和 ViewModel
之間的繫結:
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 是如何工作的,因此 GitHub 上的類可能會有輕微出入。
示例應用是一個普通的儀表盤應用,它從公共 API 獲取資料,一旦資料準備就緒,使用者就可以通過 ID 查詢實體,如下面的截圖:
應用程式有不同的方式來新增檢視控制器,所以你會看到,在有子檢視控制器的邊緣案例中,如何使用 Coordinator
。
MVVM-C 的層級結構
Coordinator
它的職責是顯示一個新的檢視,並注入 View
和 ViewModel
所需要的依賴。
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
型別的物件,並去除 UIKit
和 Coordinator
之間的耦合。
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)
}
}複製程式碼
在這個例子中,檢視控制器中的標題被繫結到 ViewModel
的 rx_title
屬性上。這樣在 ViewModel
更新 rx_title
值的時候,檢視控制器中的標題就會根據新的值自動更新。
ViewModel
ViewModel
是這種架構模式的核心層。它的職責是保持 View
和 Model
的更新。由於業務邏輯在這個類中,你需要用不同的元件的單一職責來保證 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 型別(Driver
、Observable
等等)的時候,就會有一個 rx_
字首。這不是強制的,只是它可以幫助你更好的識別哪些屬性是 RxSwift 物件。
結論
MVVM-C 有很多優點,可以提高應用程式的質量。你應該注意使用哪種方式來進行 UI 繫結,因為 RxSwift 不容易掌握,而且如果你不明白你做的是什麼,除錯和測試有時可能會有點棘手。
我的建議是一點點地開始使用這種架構模式,這樣你可以學習不同層次的使用,並且能保證層次之間的良好的分離,易於測試。
FAQ
MVVM-C 有什麼限制嗎?
是的,當然有。如果你正做一個複雜的專案,你可能會遇到一些邊緣案例,MVVM-C 可能無法使用,或者在一些小功能上使用過度。如果你開始使用 MVVM-C,並不意味著你必須在每個地方都強制的使用它,你應該始終選擇更適合你需求的架構。
我能用 RxSwift 同時使用函式式和指令式程式設計嗎?
是的,你可以。但是我建議你在遺留的程式碼中保持命令式的方式,而在新的實現裡使用函數語言程式設計,這樣你可以利用 RxSwift 強大的優勢。如果你使用 RxSwift 僅僅為了 UI 繫結,你可以輕鬆使用命令式編寫程式,而只用函式響應式程式設計來設定繫結。
我可以在企業專案中使用 RxSwift 嗎?
這取決於你要開新專案,還是要維護舊程式碼。在有遺留程式碼的專案中,你可能無法使用 RxSwift,因為你需要重構很多的類。如果你有時間和資源來做,我建議你新開一專案一點一點的做,否則還是嘗試其他的方法來解決 UI 繫結的問題。
需要考慮的一個重要事情是,RxSwift 最終會成為你專案中的另一個依賴,你可能會因為 RxSwift 的破壞性改動而導致浪費時間的風險,或者缺少要在邊緣案例中實現功能的文件。
掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 Android、iOS、React、前端、後端、產品、設計 等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃。