使用靜態模式:Swift MVVM 教程
已發表: 2022-03-11今天,我們將看到用戶對實時數據驅動應用程序的新技術可能性和期望如何給我們構建程序的方式帶來新的挑戰,尤其是我們的移動應用程序。 雖然本文是關於 iOS 和 Swift 的,但許多模式和結論同樣適用於 Android 和 Web 應用程序。
在過去的幾年裡,現代移動應用程序的工作方式發生了重要變化。 由於更普遍的互聯網訪問和推送通知和 WebSockets 等技術,在當今的許多移動應用程序中,用戶通常不再是運行時事件的唯一來源,也不一定是最重要的來源。
讓我們仔細看看兩種 Swift 設計模式在現代聊天應用程序中的表現如何:經典的模型-視圖-控制器 (MVC) 模式和簡化的不可變模型-視圖-視圖模型模式 (MVVM,有時風格化為“ViewModel 模式” ”)。 聊天應用就是一個很好的例子,因為它們有許多數據源,並且需要在收到數據時以多種不同的方式更新其 UI。
我們的聊天應用程序
我們將在本 Swift MVVM 教程中用作指南的應用程序將具有我們從 WhatsApp 等聊天應用程序中了解的大部分基本功能。 讓我們回顧一下我們將要實現的功能並比較 MVVM 和 MVC。 應用程序:
- 將從磁盤加載以前收到的聊天記錄
- 將通過
GET
請求與服務器同步現有聊天 - 當有新消息發送給用戶時將收到推送通知
- 一旦我們進入聊天屏幕,將連接到 WebSocket
- 可以在聊天中
POST
- 當收到關於我們當前不在的聊天的新消息時,將顯示應用內通知
- 當我們收到當前聊天的新消息時,將立即顯示新消息
- 當我們閱讀未讀消息時將發送已讀消息
- 當有人閱讀我們的消息時會收到一條已讀消息
- 更新應用程序圖標上的未讀消息計數器徽章
- 將所有收到或更改的消息同步回 Core Data
在這個演示應用程序中,將沒有真正的 API、WebSocket 或 Core Data 實現,以使模型實現更加簡單。 相反,我添加了一個聊天機器人,一旦你開始對話,它就會開始回复你。 但是,如果存儲和連接是真實的,則所有其他路由和調用的實現方式都是如此,包括返回之前的小的異步暫停。
已構建以下三個屏幕:
經典 MVC
首先,有用於構建 iOS 應用程序的標準 MVC 模式。 這是 Apple 構建其所有文檔代碼的方式,也是 API 和 UI 元素期望工作的方式。 這是大多數人在學習 iOS 課程時所學到的。
通常,MVC 被指責為導致幾千行代碼臃腫的UIViewController
。 但是如果應用得當,每一層之間有很好的分離,我們可以擁有非常纖細的ViewController
,它只充當View
、 Model
和其他Controller
之間的中間管理器。
這是應用程序的 MVC 實現的流程圖(為了清楚起見,省略了CreateViewController
):
讓我們詳細了解這些層。
模型
模型層通常是 MVC 中問題最少的層。 在這種情況下,我選擇使用ChatWebSocket
、 ChatModel
和PushNotificationController
在Chat
和Message
對象、外部數據源和應用程序的其餘部分之間進行調解。 ChatModel
是應用程序中的事實來源,並且僅在此演示應用程序中在內存中工作。 在現實生活中的應用程序中,它可能會得到 Core Data 的支持。 最後, ChatEndpoint
處理所有 HTTP 調用。
看法
視圖非常大,因為它必須處理很多職責,因為我已經小心地將所有視圖代碼與UIViewController
分開。 我做了以下事情:
- 使用(非常推薦的)狀態
enum
模式來定義視圖當前所處的狀態。 - 添加了連接到按鈕和其他觸發動作的界面項目的功能(例如在輸入聯繫人姓名時點擊返回。)
- 設置約束並每次回調委託。
一旦你將UITableView
放入混合中,視圖現在比UIViewController
大得多,導致令人擔憂的 300 多行代碼和ChatView
中的許多混合任務。
控制器
由於所有模型處理邏輯都已移至ChatModel
。 所有的視圖代碼——可能潛伏在不太優化的分離項目中——現在都存在於視圖中,因此UIViewController
非常苗條。 視圖控制器完全不知道模型數據的外觀、獲取方式或顯示方式——它只是協調。 在示例項目中,沒有一個UIViewController
超過 150 行代碼。
然而,ViewController 仍然做了以下事情:
- 作為視圖和其他視圖控制器的代表
- 如果需要,實例化和推送(或彈出)視圖控制器
- 向
ChatModel
發送和接收呼叫 - 根據視圖控制器週期的階段啟動和停止 WebSocket
- 做出合乎邏輯的決定,例如如果消息為空則不發送消息
- 更新視圖
這仍然很多,但主要是協調、處理回調塊和轉發。
好處
- 這種模式為大家所理解,由蘋果公司推廣
- 適用於所有文檔
- 不需要額外的框架
缺點
- 視圖控制器有很多任務; 其中很多基本上是在視圖和模型層之間來回傳遞數據
- 不太適合處理多個事件源
- 班級往往對其他班級了解很多
問題定義
只要應用程序遵循用戶的操作並對其做出響應,這就會非常有效,就像您想像的那樣,Adobe Photoshop 或 Microsoft Word 之類的應用程序可以工作。 用戶採取行動,UI 更新,重複。
但是現代應用程序是相互連接的,通常不止一種方式。 例如,您通過 REST API 進行交互,接收推送通知,在某些情況下,您還連接到 WebSocket。
這樣一來,視圖控制器突然需要處理更多的信息源,並且每當在沒有用戶觸發的情況下接收到外部消息時(例如通過 WebSocket 接收消息),信息源都需要找到返回正確的路徑視圖控制器。 這需要大量代碼將每個部分粘合在一起以執行基本相同的任務。
外部數據源
讓我們看看當我們收到推送消息時會發生什麼:
class PushNotificationController { class func received(notification: UNNotification, whenProcessed result: (_ shouldShow: Bool) -> Void) { let shouldShowNotification: Bool defer { result(shouldShowNotification) } let content = notification.request.content let date = DateParser.date(from: content.subtitle) ?? Date() let sender: Message.Sender = .other(name: content.title) let pushedMessage = Message(with: sender, message: content.body, state: .sent, sendDate: date) ChatModel.received(message: pushedMessage, by: content.title) if let chatViewController = chatViewController(handling: content.title) { chatViewController.received(message: pushedMessage) shouldShowNotification = false } else { shouldShowNotification = true } updateChats(for: content.title) } private static func updateChats(for contact: String) { guard let chat = ChatModel.loadedChats.first(where: { (chat) -> Bool in chat.contact == contact }) else { return assertionFailure("Chat for received message should always exist") } BaseNavigationViewController.navigationController?.viewControllers.forEach({ (viewController) in switch viewController { case let chatsViewController as UpdatedChatDelegate: chatsViewController.updated(chat: chat) default: break } }) } private static func chatViewController(handling contact: String) -> ChatViewController? { guard let lastViewController = BaseNavigationViewController.navigationController?.viewControllers.last as? ChatViewController, lastViewController.chat.contact == contact else { return nil } return lastViewController } }
我們必須手動挖掘視圖控制器堆棧,以確定在我們收到推送通知後是否有需要更新自身的視圖控制器。 在這種情況下,我們還想更新實現UpdatedChatDelegate
的屏幕,在這種情況下,它只是ChatsViewController
。 我們也這樣做是為了知道我們是否應該禁止通知,因為我們已經在Chat
它的用途了。 在這種情況下,我們最終將消息傳遞給視圖控制器。 很明顯, PushNotificationController
需要對應用程序了解太多才能完成其工作。
如果ChatWebSocket
也將消息傳遞到應用程序的其他部分,而不是與ChatViewController
建立一對一的關係,我們將在那裡面臨同樣的問題。
很明顯,每次我們添加另一個外部源時,我們都必須編寫相當具有侵入性的代碼。 這段代碼也很脆弱,因為它嚴重依賴應用程序結構並將數據傳遞回層次結構以工作。
代表們
一旦我們添加了其他視圖控制器,MVC 模式也會增加額外的複雜性。 這是因為視圖控制器傾向於通過委託、初始化程序以及(在情節提要的情況下)傳遞數據和引用時的prepareForSegue
來了解彼此。 每個視圖控制器都處理自己與模型或中介控制器的連接,它們都在發送和接收更新。
此外,視圖通過委託與視圖控制器進行通信。 雖然這確實有效,但這意味著我們需要採取很多步驟來傳遞數據,而且我總是發現自己在回調和檢查委託是否真的設置了很多。
可以通過更改另一個視圖控制器中的代碼來破壞一個視圖控制器,例如ChatsListViewController
中的陳舊數據,因為ChatViewController
不再調用updated(chat: Chat)
。 尤其是在更複雜的場景中,讓一切保持同步是一件很痛苦的事情。
視圖和模型之間的分離
通過將所有與視圖相關的代碼從視圖控制器移除到customView
並將所有與模型相關的代碼移動到專用控制器,視圖控制器非常精簡和分離。 但是,仍然存在一個問題:視圖想要顯示的內容與模型中的數據之間存在差距。 ChatListView
就是一個很好的例子。 我們想要顯示的是一個單元格列表,這些單元格告訴我們正在與誰交談、最後一條消息是什麼、最後一條消息的日期以及Chat
中剩下多少未讀消息:
但是,我們傳遞的模型不知道我們想看到什麼。 相反,它只是與聯繫人的Chat
,包含消息:
class Chat { let contact: String var messages: [Message] init(with contact: String, messages: [Message] = []) { self.contact = contact self.messages = messages } }
現在可以快速添加一些額外的代碼來獲取最後一條消息和消息計數,但是將日期格式化為字符串是完全屬於視圖層的任務:
var unreadMessages: Int { return messages.filter { switch ($0.sender, $0.state) { case (.user, _), (.other, .read): return false case (.other, .sending), (.other, .sent): return true } }.count } var lastMessage: Date? { return messages.last?.sendDate }
所以最後我們在顯示時格式化ChatItemTableViewCell
中的日期:
func configure(with chat: Chat) { participant.text = chat.contact lastMessage.text = chat.messages.last?.message ?? "" lastMessageDate.text = chat.lastMessage.map { lastMessageDate in DateRenderer.string(from: lastMessageDate) } ?? "" show(unreadMessageCount: chat.unreadMessages) }
即使在一個相當簡單的示例中,也很明顯在視圖需要的內容和模型提供的內容之間存在張力。
靜態事件驅動的 MVVM,又名靜態事件驅動的“視圖模型模式”
靜態 MVVM 與視圖模型一起工作,但不是通過它們創建雙向流量——就像我們過去使用 MVC 通過視圖控制器所做的那樣——我們創建不可變的視圖模型,每次 UI 需要更改以響應事件時更新 UI .
事件幾乎可以由代碼的任何部分觸發,只要它能夠提供事件enum
所需的關聯數據。 例如,接收received(new: Message)
事件可以由推送通知、WebSocket 或常規網絡調用觸發。
讓我們在圖表中查看它:
乍一看,它似乎比經典的 MVC 示例複雜得多,因為要完成完全相同的事情涉及更多的類。 但仔細觀察,這些關係都不再是雙向的了。
更重要的是,對 UI 的每次更新都會由事件觸發,因此對於發生的所有事情,只有一條通過應用程序的路徑。 可以立即清楚您可以期待哪些事件。 如果需要,您應該在哪裡添加新行為,或者在響應現有事件時添加新行為,這也很清楚。
重構之後,我得到了很多新的類,如上所示。 你可以在 GitHub 上找到我的靜態 MVVM 版本的實現。 但是,當我將更改與cloc
工具進行比較時,很明顯實際上根本沒有那麼多額外的代碼:
圖案 | 文件 | 空白的 | 評論 | 代碼 |
---|---|---|---|---|
MVC | 30 | 386 | 217 | 1807 |
MVVM | 51 | 442 | 359 | 1981年 |
代碼行數只增加了 9%。 更重要的是,這些文件的平均大小從 60 行代碼下降到只有 39 行。
同樣至關重要的是,最大的下降可以在 MVC 中通常最大的文件中找到:視圖和視圖控制器。 視圖只有原始大小的 74%,而視圖控制器現在只有原始大小的 53%。
還應該注意的是,許多額外的代碼是庫代碼,它們有助於將塊附加到可視樹中的按鈕和其他對象,而不需要 MVC 的經典@IBAction
或委託模式。
讓我們一一探索這個設計的不同層次。
事件
該事件始終是一個enum
,通常帶有關聯的值。 通常它們會與模型中的一個實體重疊,但不一定如此。 在這種情況下,應用程序分為兩個主要事件enum
: ChatEvent
和MessageEvent
。 ChatEvent
用於聊天對象本身的所有更新:
enum ChatEvent { case started case loaded(chats: [Chat]) case creating(chat: Chat) case created(chat: Chat) case createChatFailed(reason: String) }
另一個處理所有與消息相關的事件:
enum MessageEvent { case sending(message: Message, contact: String, previousMessages: [Message]) case sent(message: Message, contact: String) case failedSending(message: Message, contact: String, reason: String) case received(message: Message, contact: String) case userReads(messagesSentBy: String) case userRead(othersMessages: [Message], sentBy: String) case otherRead(yourMessage: Message, reader: String) }
將您的*Event
enum
限制在合理的大小是很重要的。 如果您需要 10 個或更多案例,這通常表明您試圖涵蓋多個主題。
注意: enum
概念在 Swift 中非常強大。 我傾向於經常使用帶有關聯值的enum
,因為它們可以消除您在使用可選值時可能會遇到的很多歧義。
Swift MVVM 教程:事件路由器
事件路由器是應用程序中發生的每個事件的入口點。 任何可以提供相關值的類都可以創建一個事件並將其發送到事件路由器。 因此它們可以由任何類型的來源觸發,例如:
- 用戶進入特定的視圖控制器
- 用戶點擊某個按鈕
- 應用程序啟動
- 外部事件,例如:
- 網絡請求返回失敗或新數據
- 推送通知
- WebSocket 消息
事件路由器應該盡可能少地知道事件的來源,最好什麼都不知道。 此示例應用程序中的所有事件都沒有任何指示它們來自何處,因此很容易混合到任何類型的消息源中。 例如,WebSocket 觸發相同的事件——received received(message: Message, contact: String)
——作為新的推送通知。
事件(您已經猜到了)被路由到需要進一步處理這些事件的類。 通常,唯一被調用的類是模型層(如果需要添加、更改或刪除數據)和事件處理程序。 我將進一步討論這兩者,但事件路由器的主要功能是為所有事件提供一個簡單的訪問點,並將工作轉發給其他類。 下面以ChatEventRouter
為例:
class ChatEventRouter { static func route(event: ChatEvent) { switch event { case .loaded(let chats): ChatEventHandler.loaded(chats: chats) case .creatingChat(let contact): let chat = ChatModel.create(chatWith: contact) ChatEndpoint.create(chat: chat) ChatEventHandler.creatingChat() case .created(let chat): ChatEventHandler.created(chat: chat) case .createChatFailed(let reason): ChatEventHandler.failedCreatingChat(reason: reason) } } }
這裡幾乎沒有發生什麼:我們唯一要做的就是更新模型並將事件轉發到ChatEventHandler
以便更新 UI。

Swift MVVM 教程:模型控制器
這與我們在 MVC 中使用的類完全相同,因為它已經運行得很好。 它代表應用程序的狀態,通常由 Core Data 或本地存儲庫支持。
模型層——如果在 MVC 中正確實現——很少需要任何重構來適應不同的模式。 最大的變化是模型的更改發生在更少的類中,這使得更改發生的位置更加清晰。
在此模式的另一種選擇中,您可以觀察模型的變化並確保它們得到處理。 在這種情況下,我選擇僅讓*EventRouter
和*Endpoint
類更改模型,因此對模型的更新時間和地點有明確的責任。 相反,如果我們正在觀察變化,我們將不得不編寫額外的代碼來通過ChatEventHandler
傳播非模型更改事件(如錯誤),這將使事件如何在應用程序中流動變得不那麼明顯。
Swift MVVM 教程:事件處理程序
事件處理程序是視圖或視圖控制器可以註冊(和取消註冊)自己作為偵聽器以接收更新的視圖模型的地方,這些視圖模型是在ChatEventRouter
調用ChatEventHandler
上的函數時構建的。
可以看到,它大致反映了我們之前在 MVC 中使用的所有視圖狀態。 如果您想要其他類型的 UI 更新——比如聲音或觸發 Taptic 引擎——也可以從這裡完成。
protocol ChatListListening: class { func updated(list: ChatListViewModel) } protocol CreateChatListening: class { func updated(create: CreateChatViewModel) } class ChatEventHandler { private static var chatListListening: [ChatListListening?] = [] private static var createChatListening: [CreateChatListening?] = [] class func add(listener: ChatListListening) { weak var weakListener = listener chatListListening.append(weakListener) } class func remove(listener: ChatListListening) { chatListListening = chatListListening.filter { $0 !== listener } } class func add(listener: CreateChatListening) { weak var weakListener = listener createChatListening.append(weakListener) listener.updated(create: CreateChatViewModelBuilder.build(isSending: false, error: nil)) } class func remove(listener: CreateChatListening) { createChatListening = createChatListening.filter { $0 !== listener } } class func started() { ChatEndpoint.fetchChats() let loadingViewModel = ChatListViewModelBuilder.buildLoading() chatListListening.forEach { $0?.updated(list: loadingViewModel) } } class func loaded(chats: [Chat]) { let chatList = ChatListViewModelBuilder.build(for: chats) chatListListening.forEach { $0?.updated(list: chatList) } } class func creatingChat() { let createChat = CreateChatViewModelBuilder.build(isSending: true, error: nil) createChatListening.forEach { $0?.updated(create: createChat) } } class func failedCreatingChat(reason: String) { let createChat = CreateChatViewModelBuilder.build(isSending: false, error: reason) createChatListening.forEach { $0?.updated(create: createChat) } } class func created(chat: Chat) { let createChat = CreateChatViewModelBuilder.build(isSending: false, error: nil) createChatListening.forEach { $0?.updated(create: createChat) } updateAllChatLists() let chatViewController = ChatViewController(for: chat) BaseNavigationViewController.pushViewController(chatViewController, animated: true, removePreviousFromStack: true) } class func updateAllChatLists() { let chatListViewModel = ChatListViewModelBuilder.build(for: ChatModel.allChats()) chatListListening.forEach { $0?.updated(list: chatListViewModel) } } }
此類僅確保在發生特定事件時正確的偵聽器可以獲得正確的視圖模型。 如果需要設置初始狀態,則新偵聽器可以在添加時立即獲得視圖模型。 始終確保向列表添加weak
引用以防止保留週期。
Swift MVVM 教程:查看模型
這是許多 MVVM 模式所做的與靜態變體所做的最大區別之一。 在這種情況下,視圖模型是不可變的,而不是將自己設置為模型和視圖之間的永久雙向綁定中間體。 我們為什麼要這樣做? 讓我們停下來解釋一下。
創建在所有可能情況下都能正常運行的應用程序的最重要方面之一是確保應用程序的狀態正確。 如果 UI 與模型不匹配或有過時的數據,我們所做的一切都可能導致保存錯誤的數據或應用程序崩潰或以意外方式運行。
應用這種模式的目標之一是我們在應用程序中沒有狀態,除非它是絕對必要的。 究竟什麼是狀態? 狀態基本上是我們存儲特定類型數據表示的每個地方。 一種特殊類型的狀態是您的 UI 當前所處的狀態,當然我們無法通過 UI 驅動的應用程序來阻止這種狀態。 其他類型的狀態都是與數據相關的。 如果我們有一個Chat
數組的副本在 Chat List 屏幕中備份我們的UITableView
,這就是重複狀態的示例。 傳統的雙向綁定視圖模型將是我們用戶的Chat
副本的另一個示例。
通過傳遞一個在每次模型更改時都會刷新的不可變視圖模型,我們消除了這種類型的重複狀態,因為在它應用到 UI 之後,它就不再被使用了。 然後我們只有兩種我們無法避免的狀態——UI 和模型——它們彼此完美同步。
所以這裡的視圖模型與一些 MVVM 應用程序有很大的不同。 它僅用作視圖反映模型狀態所需的所有標誌、值、塊和其他值的不可變數據存儲,但視圖不能以任何方式更新它。
因此它可以是一個簡單的不可變struct
。 為了使這個struct
盡可能簡單,我們將使用視圖模型構建器對其進行實例化。 視圖模型的有趣之處之一是它獲得了像shouldShowBusy
和shouldShowError
這樣的行為標誌,它們替換了之前在視圖中找到的狀態enum
機制。 這是我們之前分析過的ChatItemTableViewCell
的數據:
struct ChatListItemViewModel { let contact: String let message: String let lastMessageDate: String let unreadMessageCount: Int let itemTapped: () -> Void }
因為視圖模型構建器已經處理了視圖需要的確切值和操作,所以所有數據都是預先格式化的。 同樣新的是一個塊,一旦點擊一個項目就會觸發。 讓我們看看它是如何由視圖模型構建器製作的。
查看模型生成器
視圖模型構建器可以構建視圖模型的實例,將諸如Chat
或Message
之類的輸入轉換為為特定視圖完美定制的視圖模型。 在視圖模型構建器中發生的最重要的事情之一是確定視圖模型中塊內部實際發生的事情。 視圖模型構建器附加的塊應該非常短,盡快調用架構其他部分的功能。 這樣的塊不應該有任何業務邏輯。
class ChatListItemViewModelBuilder { class func build(for chat: Chat) -> ChatListItemViewModel { let lastMessageText = chat.messages.last?.message ?? "" let lastMessageDate = (chat.messages.last?.sendDate).map { DateRenderer.string(from: $0) } ?? "" let unreadMessageCount = ChatModel.unreadMessages(for: chat.contact).count return ChatListItemViewModel(contact: chat.contact, message: lastMessageText, lastMessageDate: lastMessageDate, unreadMessageCount: unreadMessageCount, itemTapped: { show(chat: chat) }) } private class func show(chat: Chat) { let chatViewController = ChatViewController(for: chat) BaseNavigationViewController.pushViewController(chatViewController, animated: true) } }
現在所有的預格式化都發生在同一個地方,行為也在這裡決定。 它是這個層次結構中非常重要的一個類,看看演示應用程序中的不同構建器是如何實現的並處理更複雜的場景可能會很有趣。
Swift MVVM 教程:視圖控制器
這種架構中的視圖控制器做的很少。 它將設置和拆除與其視圖相關的所有內容。 這樣做是最合適的,因為它會獲取在正確的時間添加和刪除偵聽器所需的所有生命週期回調。
有時它需要更新根視圖未覆蓋的 UI 元素,例如導航欄中的標題或按鈕。 這就是為什麼如果我有一個覆蓋給定視圖控制器的整個視圖的視圖模型,我通常仍然將視圖控制器註冊為事件路由器的偵聽器; 之後我將視圖模型轉發到視圖。 但是,如果屏幕的某個部分具有不同的更新率,也可以直接將任何UIView
註冊為偵聽器,例如有關某個公司的頁面頂部的實時股票行情。
ChatsViewController
的代碼現在很短,只需要不到一頁。 剩下的是覆蓋基本視圖,在導航欄中添加和刪除添加按鈕,設置標題,將自身添加為偵聽器,並實現ChatListListening
協議:
class ChatsViewController: UIViewController { private lazy var customView: ChatsView = { let customView = ChatsView() return customView }() private var addButton: UIBarButtonItem = UIBarButtonItem(barButtonSystemItem: .add, target: nil, action: nil) override func loadView() { view = customView } override func viewDidLoad() { super.viewDidLoad() ChatEventHandler.add(listener: self) ChatEventRouter.route(event: .started) title = "Chats" } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) navigationItem.rightBarButtonItem = addButton } override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) navigationItem.rightBarButtonItem = nil } } extension ChatsViewController: ChatListListening { func updated(list: ChatListViewModel) { addButton.action(block: { _ in list.addChat() }) customView.display(viewModel: list) } }
由於ChatsViewController
已被剝離到最低限度,因此無法在其他地方完成任何事情。
Swift MVVM 教程:查看
不可變 MVVM 架構中的視圖仍然非常繁重,因為它仍然有一個任務列表,但與 MVC 架構相比,我設法將其剝離了以下職責:
- 確定響應新狀態需要更改的內容
- 為動作實現委託和函數
- 處理手勢和触發動畫等視圖到視圖觸發器
- 以可以顯示的方式轉換數據(如
Date
到String
)
尤其是最後一點有相當大的優勢。 在 MVC 中,當視圖或視圖控制器負責轉換數據以供顯示時,它將始終在主線程上執行此操作,因為很難將需要在該線程上發生的 UI 的真正更改與實際發生的事情分開不需要在上面運行。 並且在主線程上運行非 UI 更改代碼會導致應用程序響應速度降低。
相反,使用這種 MVVM 模式,從通過點擊觸發的塊到構建視圖模型並將其傳遞給偵聽器的所有內容 - 我們可以在單獨的線程上運行這一切,並且只進入主線程結束做 UI 更新。 如果我們的應用程序在主線程上花費的時間更少,它將運行得更順暢。
一旦視圖模型將新狀態應用到視圖,它就可以消失而不是作為另一層狀態徘徊。 可能觸發事件的所有內容都附加到視圖中的項目,我們不會與視圖模型進行通信。
重要的是要記住一件事:您不必通過視圖控制器將視圖模型映射到視圖。 如前所述,視圖的某些部分可以由其他視圖模型管理,尤其是在更新率不同時。 考慮一個由不同的人編輯的 Google 表格,同時保持一個聊天窗格為協作者打開——每當有聊天消息到達時刷新文檔並不是很有用。
一個著名的例子是類型查找實現,當我們輸入更多文本時,搜索框會更新為更準確的結果。 這就是我在CreateAutocompleteView
類中實現自動完成的方式:整個屏幕由CreateViewModel
提供服務,但文本框正在監聽AutocompleteContactViewModel
。
另一個例子是使用表單驗證器,它可以構建為“本地循環”(將錯誤狀態附加或刪除到字段並聲明表單有效)或通過觸發事件來完成。
靜態不可變視圖模型提供更好的分離
通過使用靜態 MVVM 實現,我們最終成功地完全分離了所有層,因為視圖模型現在在模型和視圖之間架起了橋樑。 我們還讓管理不是由用戶操作引起的事件變得更容易,並消除了我們應用程序不同部分之間的大量依賴關係。 視圖控制器唯一要做的就是將自己註冊(和註銷)到事件處理程序,作為它想要接收的事件的偵聽器。
好處:
- 視圖和視圖控制器的實現往往要輕得多
- 類更加專業化和分離
- 可以從任何地方輕鬆觸發事件
- 事件遵循可預測的路徑通過系統
- 狀態僅從一個地方更新
- 應用程序可以提高性能,因為它更容易在主線程之外工作
- Views receive tailor-made view models and are perfectly separated from the models
缺點:
- A full view model is created and sent every time the UI needs to update, often overwriting the same button text with the same button text, and replacing blocks with blocks that do exactly the same
- Requires some helper extensions to make button taps and other UI events work well with the blocks in the view model
- Event
enum
s can easily grow pretty large in complex scenarios and might be hard to split up
The great thing is that this is a pure Swift pattern: It does not require a third-party Swift MVVM framework, nor does it exclude the use of classic MVC, so you can easily add new features or refactor problematic parts of your application today without being forced to rewrite your whole application.
There are other approaches to combat large view controllers that provide better separation as well. I couldn't include them all in full detail to compare them, but let's take a brief look at some of the alternatives:
- Some form of the MVVM pattern
- Some form of Reactive (using RxSwift, sometimes combined with MVVM)
- The model-view-presenter pattern (MVP)
- The view-interactor-presenter-entity-router pattern (VIPER)
Traditional MVVM replaces most of the view controller code with a view model that is just a regular class and can be tested more easily in isolation. Since it needs to be a bi-directional bridge between the view and the model it often implements some form of Observables. That's why you often see it used together with a framework like RxSwift.
MVP and VIPER deal with extra abstraction layers between the model and the view in a more traditional way, while Reactive really remodels the way data and events flow through your application.
The Reactive style of programming is gaining a lot of popularity lately and actually is pretty close to the static MVVM approach with events, as explained in this article. The major difference is that it usually requires a framework, and a lot of your code is specifically geared towards that framework.
MVP is a pattern where both the view controller and the view are considered to be the view layer. The presenter transforms the model and passes it to the view layer, while I transform the data into a view model first. Since the view can be abstracted to a protocol, it's much easier to test.
VIPER takes the presenter from MVP, adds a separate “interactor” for business logic, calls the model layer “entity,” and has a router for navigation purposes (and to complete the acronym). It can be considered a more detailed and decoupled form of MVP.
So there you have it: static event-driven MVVM explained. I look forward to hearing from you in the comments below!