在 Runtastic,我們完成了 38 個 iOS App,而我們的團隊也擴增到了 20 位 iOS Developer,同時我們的 iOS 程式庫包含了超過 700,000 行的程式碼。一個成長中的團隊帶來了程式庫的增長,也造成了程式碼之間的高複雜性及耦合性。而這樣會帶來災難性的結果,除非你關心整個程式架構以及在開發軟體時遵照一些開發原則,例如:
- 遵照單一功能原則(Follow the single responsibility principle)
- 可測試性的設計(Design for testability)
- 擁有明確的相依關係(Have clear dependencies)
- 讓程式碼保持可讀性及可維護性(Keep your code readable and maintainable)
雖然大部分程式都會從 Apple MVC 架構開始,但最後可能就變成 Massive View Controller 了。試著開啟終端機在專案的根目錄中執行find . -type f -exec wc -l {} + | sort -n
,你可能就會發現一些伴隨著上千行程式瑪的 ViewController,就像我們過去所寫的。
了解到我們正面臨著越來越多如何在 MVC 架構中堅持上述開發原則的挑戰後,我們便著手尋找其他的設計模式。
作為現今 iOS 社群中的一大討論話題,你可以在眾多設計模式中選擇,像是 MVC、MVP、MVVM 或是 VIPER。經過一些研究之後,我們決定採用 MVVM 設計模式。但還有一些問題沒有解決,例如路由(Routing,呈現一個新畫面)或是資料綁定(Data Binding)。因此,我們也仔細看了一下 VIPER 設計模式。VIPER 的確有這些問題的解決方法,但使用後我們發現 VIPER 有著過多重複使用的樣板程式碼以及較高的學習曲線等缺點。最後,我們把從 VIPER 中學到的一些收獲整合進我們自己改寫的 MVVM 設計模式,成果就是 MVVMC 設計模式。
MVVMC – 什麼是 C?
C 代表的是 calçots
,一種特別的洋蔥品種,與我們的同事及這個模式的創始者一樣來自於加泰隆尼亞。藉由洋蔥一層一層的樣子來比喻 App 中的一系列畫面。代表一個畫面的類別群組稱為一個 calçot
,而它是由 Model、View、ViewModel、Interactor 及 Coordinator 所組成。
Calçot 的組成
讓我們來用最近在 Runtastic app 上完成開發的實際例子來說明上面這張圖解吧。以使用者群組來說,一個使用者可以是多個群組的成員。你可以在下面的畫面看到一個使用者的所加入的群組。
Model
Model 裡的是單純資料,透過 calçot(特別是 ViewModel)來解讀。它沒有運算邏輯或是與其他元件相依。
struct GroupsList { // groups that are part of this model let groups: [Group] // category of a group (enum) let groupsCategory: GroupCategory }
ViewModel
ViewModel 包含了將資料呈現在畫面上的運算邏輯(例如:提供一個在地化的群組分類文字)。任何從 View 中觸發的互動會依據互動的種類委託給 Interactor 或 Coordinator。
final class GroupsViewModel { private let interactor: GroupsInteractorProtocol private let coordinator: GroupsCoordinatorProtocol private var groupsList: GroupsList { return interactor.groupsList } init(interactor: GroupsInteractorProtocol, coordinator: GroupsCoordinatorProtocol) { self.interactor = interactor self.coordinator = coordinator } var groupsCategory: String { return groupsList.groupsCategory.localizedString } func fetchGroups() { // interaction to be handled within calçot interactor.fetchGroups() } func dismissGroup() { // interaction leading to a different screen or calçot coordinator.dismissGroup() } }
上面的範例用來表示 ViewModel 如何準備 Data 呈現在 View 上。另外,如果是在同一個 calçot 的話會委託給 Interactor 處理互動,或者如果是導向不同的 calçot 的話則是委託給 Coordinator 處理。
View
View(一般來說是指 UIKit 相關類別,例如:UIView 或 UIViewController) 只關注呈現從 ViewModel 取得的資料。一旦資料更新(例如下面的程式碼就是透過設定新的 ViewModel 來更新),就會跟著更新 UI。
final class GroupsViewController: UIViewController { var viewModel: GroupsViewModel { didSet { updateUI() } } init(viewModel: GroupsViewModel) { self.viewModel = viewModel super.init(nibName: nil, bundle: nil) } private func updateUI() { // update UI } // ... }
Interactor
Interactor 包含了 Model 以及依據互動(interactions)跟事件(event)的商業邏輯運算來更新 Model。此外,Interactor 也處理一些外部的相依性(所屬的calçot之外),例如資料庫的存取、網路連線的呼叫或是與其他元件的連結。
protocol GroupsInteractorProtocol { var groupsList: GroupsList { get } func fetchGroups() } final class GroupsInteractor: GroupsInteractorProtocol { private(set) var groupsList: GroupsList private let dataProvider: GroupDataProvider // ... func fetchGroups() { // fetch data from a data provider dataProvider.fetchGroups() { (groups, error) in // Error handling? Naaah, this stuff always works 😀 // Update the model with new data self.groupsList = GroupsList(groups: groups, groupsCategory: .joined) } } }
在這個例子中,GroupsInteractor
負責從資料提供者(例如網路或資料庫)取得使用者的群組,同時建立一個 GroupList
的新實例。
Coordinator
Coordinator 主要負責畫面之間導引及在需要的地方建立新的 calçots。藉由一個簡單的介面可以從一個畫面導引到另一個(通常透過 calçot 表示)。當導引至不同的 calçot 時,Coordinator 會為新的 calçot 準備所有需要的物件並初始化它(包括 ViewController、ViewModel、Interactor 及 一個新的 Coordinator)。
protocol GroupsCoordinatorProtocol: class { func present(group: Group) func dismissGroup() } final class GroupsCoordinator: GroupsCoordinatorProtocol { weak var navigationController: UINavigationController? func present(group: Group) { // Preparing the new calçot let groupCoordinator = GroupCoordinator(navigationController: navigationController) let groupInteractor = GroupInteractor(group: group) let groupViewModel = GroupViewModel(interactor: groupInteractor, coordinator: groupCoordinator) let groupViewController = GroupViewController(viewModel: groupViewModel) // Navigate to the new screen navigationController?.pushViewController(groupViewController, animated: true) } // ... }
在我們的範例裡,Coordinator 提供了從群組列表畫面到群組細節畫面的選項。當觸發後(例如使用者點擊一個群組),它就會初始化新的 calçot。
當 Model 改變之時…
View 取決於 ViewModel,而 ViewModel 取決於 Model。所以當 Model 改變時,View 需要被通知並且因此更新畫面。藉由以下幾種方法可以達到這樣的目的:
- 使用觀察者模式(Observer Pattern)來觀察 Model 的變化(例如:ObserverSet)。
- 使用委託模式(Delegate Pattern)來明確地傳達變化。
- 使用 KVO相關的程式庫(例如:Swift Bond)
- 使用 Swift 4 中關於 KVO 的功能(Swift 4 related to KVO)
- 使用函數響應式程式設計(Functional Reactive Programming)程式庫(例如:ReactiveCocoa、RxSwift)
當有多個物件在監聽變化時我們較喜歡使用觀察者模式(Observer Pattern),而當 View 與 ViewModel 之間有一對一的關係時則會使用委託模式(Delegate Pattern)。以下就是在 GroupInteractorProtocol 中使用 ObserverSet 的樣子。
protocol GroupInteractorProtocol: class { init(group: Group) var group: Group { get } var groupMembersDidChange: ObserverSet{ get } func add(user: User) func remove(user: User) } final class GroupsCoordinator: GroupsCoordinatorProtocol { func present(group: Group) { // … // observe changes on the group members and set the View’s new ViewModel interactor.groupMembersDidChange.add { [weak groupViewController, weak groupInteractor] in guard let interactor = groupInteractor else { return } groupViewController?.viewModel = GroupViewModel(interactor: interactor, coordinator: groupCoordinator) } // ... } }
當 Model 改變,Interactor 會建立新的 ViewModel 並將它設定到 View。在這個範例中,我們使用一個不可變的 ViewModel,因為我們通常會希望 ViewModel 是不可變的。雖然可能讓他們更加複雜及難以測試。,但在某些情況下(例如對於部分 UI 更新的具體溝通)會有條件地使用可變的 ViewModel。
MVVMC 如何提高可測試性
使用結構化設計模式的一大好處在於可以提高軟體元件的可測試性。我們依據一些基礎的方針來幫助我們測試 MVVMC。首先,我們通常使用 Protocol 而不是特定的類別。這讓你僅需注入一個模擬物件(Mock Object),就可以讓它更容易的測試類別裡實際的商業邏輯以及與其他元件的互動。讓我們來看看在 ViewModel 裡是長得怎樣。
class GroupsFakeInteractor: GroupsInteractorProtocol { // … var groupsList = GroupsList(groups: [], groupsCategory: .joined) func fetchGroups() { groupsList = GroupsList(groups: (0..<50).map { _ in Group(id: "0", name: "Test", members: []) }, groupsCategory: .joined) groupsListDidChange.notify() } } class GroupsFakeCoordinator: GroupsCoordinatorProtocol { var presented = false func present(group: Group) { presented = true } // ... }
藉由被注入的類別,我們現在可以撰寫一個整合測試來驗證 ViewModel 是否正確地與 Interactor 及 Coordinator 整合在一起。
final class GroupsViewModelTests: XCTestCase { private var interactor: GroupsFakeInteractor! private var coordinator: GroupsFakeCoordinator! private var viewModel: GroupsViewModel! override func setUp() { super.setUp() interactor = GroupsFakeInteractor() coordinator = GroupsFakeCoordinator() viewModel = GroupsViewModel(interactor: interactor, coordinator: coordinator) } override func tearDown() { // ... super.tearDown() } func testFetchUserGroups() { let exp = expectation(description: "Fetch groups") // This completion block should be called when groups are fetched interactor.groupsListDidChange.add { [weak interactor] in XCTAssertEqual(interactor?.groupsList.groups.count, 50) exp.fulfill() } // Fetch mocked user groups viewModel.fetchGroups() waitForExpectations(timeout: 2.0, handler: nil) } }
小結
MVVMC 是我們回答關於 MVVM 的一些問題以及作為我們程式碼常用設計模式的答案。MVVMC 仍在持續改進,所以我們會很高興的獲得一些回饋以及好奇你會如何運用 MVVMC 來解決你 App 中的一些架構上的挑戰。
我們也建立了一個範例 GitHub Repository,在這裡你可以找到所有在本篇文章出現的程式碼。