基於徹底解耦合的實驗性iOS架構

發表於2016-01-20

這周我決定做一個關於徹底解耦合的應用架構的實驗。我想探究的主題是:

“如果所有的應用內通訊都通過一個事件流來完成會怎麼樣?”

我構造了一個待辦事項應用,因為這是我一時激動下所能想到的最原始微型的專案。我會大概地說一下應用結構背後的想法,展示具體實現中的一些程式碼片段,然後給出幾個有關利弊的結論。

整個專案在Github上。作為參考,這篇文章基於0.1標籤下的程式碼。

event-mvvm-demo.gif

應用演示

架構總述

為了有一個名字來關聯,我把這個架構叫做EventMVVM。它使用少量的MVVM(Model-View-ViewModel)架構。雖然它使用ReactiveCocoa作為事件流的管道,但是我在後面會說到許多工具也可以代替。它是用Swift寫的,這有點重要,由於Swift的列舉關聯值的特性以及容易定義和使用值型別。

我能夠解釋架構的最好方法是命名和列舉參與者,定義它們,再列出規則。

  • Event
  • EventsSignal & EventsObserver
  • Server
  • Model
  • ViewModel
  • View

Event

一個Event是一個訊息的構建程式碼塊。定義為列舉,每種情況下都有多達一個相關聯的值(注意:這與ReactiveCocoa的Event不同)。你可以把它看作一個強型別NSNotification。每一種情況約定以Request或Response開始。下面是幾個例子。

  • Model和ViewModel”型別”的事件都包括在Event列舉中(註解:1)。
  • RequestReadTodos沒有引數,因為這個應用不需要預先篩選或排序註解:2
  • 我們使用Result來封裝返回值或異常注:3
  • 所有列舉項的關聯值都是值型別,這對於確保系統的健全是很重要的。同一個Event可能被任何一個的執行緒上的許多物件接收到。

EventsSignal & EventsObserver

eventsSignal和eventsObserver將是我們共享的事件流。我們將把它們注入進類裡,這些類將能夠附加觀察者塊到eventsSignal,併傳送新的Event到eventsObserver。

我們把這個元組放在一個叫做AppContext的類裡。它們使用一個ReactiveCocoa的Signal和一對通過.pipe()建立的觀察者來實現。這裡有一些實現細節,稍後我們將討論。

簡而言之語法如下:

Server

Server是一個長久存活的類,它包含觀察者並能傳送訊息。在我們的示例應用中,有兩個Server–ViewModelServerhe和ModelServer。這些都是由AppDelegate建立並持有的。從名字你可能會認為ViewModelServer設定了我們應用的ViewModel相關的職責的觀察者。例如,它負責為ViewModels接收請求並滿足它們,不是改變事件裡的ViewModel,就是傳送一個新的事件請求它需要的資料(註解:4註解:5)。

Server代表我們應用裡的”智慧”物件。它們是協調器。它們建立和操縱我們的ViewModel、Model值型別,並與其他server通過建立Event和附加在它們之上的值進行交流。

Model

一個Model是一個包含基本資料的值型別。在標準MVVM裡,它不應該包含任何一個針對底層資料庫的東西。

在示例應用中,我用擴充套件來把Todo model物件序列化成TodoObject用於我們的Realm資料庫。

模型層只知道自己。它不知道ViewModel和View。

ViewModel

一個ViewModel是一個值型別,它包含在View層裡並且是一個可以直接使用的屬性。例如,UILabel顯示的文字就該是一個String。ViewModel在init函式裡接收和儲存一個Model物件,並將之轉變為View層可使用的。一個ViewModel可使其他ViewModels能夠被子檢視等使用。

按這種解釋(註解:6),ViewModels是完全惰性的,並且不能非同步操作和向事件流傳送訊息。這確保它們可以安全地線上程間傳遞。

ViewModel不知道View層。它們可以操作其他ViewModel和Model。

View

我們的View層是UIKit,包括UIViewControllers和UIViews及其子類。雖然我的初衷是探索讓View層也通過事件流傳送自己的事件,但是在這個簡單的實現裡卻是不必要的,並且可能是最使人分心的(註解:7)。

View層只允許與View和ViewModel層進行互動。這意味著它對Model一無所知。

實現

現在我們對所有的元件系統已經有了一個基本的瞭解,讓我們深入進程式碼,看看它是如何工作的。

The Spec(軟體規格說明書)

我們的待辦列表的特點是什麼?這類似於我們的Event。(對我來說,這是最激動人心的部分。)Event.swift:

  • RequestTodoViewModels:我們希望能夠看到所有待辦事項按預設順序排序,並過濾掉已刪除的條目。
  • RequestToggleCompleteTodoViewModel:我們需要能夠在列表檢視把待辦事項標記為完成。
  • RequestDeleteTodoViewModel:我們也需要能夠將在列表檢視刪除它們。
  • RequestNewTodoDetailViewModel:我們需要能夠建立新的待辦事項。
  • RequestTodoDetailViewModel:我們需要能夠漂亮地檢視/編輯一個待辦事項。
  • RequestUpdateDetailViewModel:我們需要能夠提交我們的更改。

這些都是我們的請求。它們將所有來自View層。因為這些只是我們廣播的事件/訊息,不一定有直接的一對一的響應。這對我們同時有積極和消極的後果。

影響之一是我們需要更少類的響應事件。ResponseTodoViewModels和RequestTodoViewModels會有一對一的響應,但RequestToggleCompleteTodoViewModel、RequestDeleteTodoViewModel和RequestUpdateDetailViewModel都會由ResponseTodoViewModel響應。這簡化了我們的view的程式碼,也保證了一個view可以獲得更新並傳給被一個不同的view改變的ViewModel,我們也不需要額外做什麼。

RequestNewTodoDetailViewModel和RequestTodoDetailViewModel(又名新建和編輯)將由ResponseTodoDetailViewModel響應。

有趣的是,RequestUpdateDetailViewModel必須由ResponseUpdateDetailViewModel和ResponseTodoViewModel響應,因為它們的底層待辦Model改變了。稍後我們將詳細探討這個場景。

為了滿足這些來自View層的請求,ViewModelServer需要有自己的對Model資料的請求。這些都是一對一的請求-響應。

  • RequestReadTodos -> ResponseTodos
  • RequestWriteTodo -> ResponseTodo

我們在待辦Model裡通過設定一個flag來實現刪除。這種技術明顯使它能更容易地協調我們的應用層之間變化。

以下是一個很長的圖,有關這四個主要物件如何傳送和觀察事件。

event-mvvm-diagram.jpg

系統設定

正如之前所說,AppContext包含元組eventSignal和eventObserver。我們會將它注入到我們所有的其他高層元件,並允許它們進行交流。

我們必須保留ModelServer和ViewModelServer,因為他們沒有view層和互相的直接引用(註解:8)。

記得TodoListViewModel只是一個惰性結構。雖然對於這個簡單的應用,我們可以讓TodoListViewController建立自己的ViewModel,但是注入是更好的實踐途徑。你可以很容易地想象把”列表的列表”功能新增到應用。在這種情況下我們(可能?)不需要改變我們的任何介面。

View層:列表

實際上我們的系統邊界很清楚。View層將處理所有ViewModel的請求並觀察所有ViewModel的響應。

我們這個部分的主題是TodoListViewController。作為參考:

我們會傳送我們的第一個事件去請求TodoViewModels來填檢視出現時的列表。

接著我們需要設定一個觀察者來響應事件。View層的觀察者們總是會放置在viewDidLoad裡,同時它的生命週期和UIViewController本身的一樣。

剖析一個觀察者

現在我們需要深入瞭解語法。我們所有觀察者的結構非常相似:

  • 生命週期
  • 過濾
  • 拆箱
  • 對映
  • 錯誤處理
  • 輸出

對於View層,輸出的形式通常是副作用(如更新ViewModel和重新整理列表)。對於其他Server,輸出通常是傳送另一個Event。

讓我們看看Event.ResponseTodoViewModels。

  • #1:這是一個ReactiveCocoa實現細節,它(相當於註解9)把觀察者生命週期限制在self的生命週期裡。換句話說,當TodoListViewController消失時,停止處理這個觀察者。
  • #2:這裡是我們在必要時從事件中過濾和拆包的地方。記住,我們在觀察整個應用傳送的Event的消防帶。我們只想要Event.ResponseTodoViewModels,並且如果得到,我們希望它的值被傳遞。對於其他所有到達的事件,它們會被對映到nil然後被ignoreNil()運算子丟棄。
  • #3:這是我們的錯誤處理。promoteErrors是一個ReactiveCocoa的實現細節,它將一個無法報錯的訊號轉化成一個能傳送錯誤到指定型別的訊號。然後attemptMap從Result物件中拆包,並允許我們使用ReactiveCocoa內建的錯誤處理。flatMapError就是我們錯誤的副作用,在這種情況下,錯誤以警報形式呈現。相反,如果我們用observeError,我們的觀察者將在第一個錯誤事件後被處理掉,這不是我們想要的(註解10)。
  • #4:Event可以被eventsSignal交付到任何執行緒。因此,對於任何執行緒的關鍵工作我們需要指定目標排程器。在這種情況下,我們的關鍵工作是UI相關,因此我們使用UIScheduler。注意,只有在observeOn之後的操作能夠在UIScheduler上執行(註解11)。
  • #5:最後,我們有一個來自正確的事件的非錯值。我們將使用這個完全取代TodoListViewModel並且有條件地重新整理列表,如果列表有任何真正的改變。

記住,這個例子實際上是複雜應用的一種,因為有錯誤處理和多個未展開的階段。

更多操作

我們將使用UITableViewRowActionde API來傳送事件為待辦事項標誌完成或刪除它們。

這些Event只是修改ViewModel。View層只關心TodoViewModel粒度級別的變化。

我們想要觀察ResponseTodoViewModel,這使我們的檢視總是顯示最準確的待辦事項。我們也想有動畫效果,因為那樣好看。

這些都是基本的View層。讓我們再看看ViewModelServer,看看我們如何響應這些請求Event和發出新的Event。

ViewModel:列表

ViewModelServer是一個大的配置觀察者的init函式。

Event.RequestTodoViewModels

ViewModelServer監聽ViewModel的請求併傳送ViewModel響應Event。

.RequestTodoViewModels相當簡單。它只是從model層建立一個相對應的請求(註解12)。

我們把這個事件發回eventsObserver來派遣我們的新Event。注意我們必須派遣這個事件在一個特定的排程器裡,否側會死鎖。有關ReactiveCocoa的實現細節超出了本文的範圍,所以暫時我們只要注意必須新增任何觀察者到新事件的對映。

Event.ResponseTodos

現在我們可以得到一個我們剛剛發出的Model事件的響應。

我們把Result對映到Result,並返回result作為一個新的Event。有一個佔位符,在我們可以將Model層的錯誤對映到一個更適合展示給使用者的地方(註解13)。

其他ViewModel事件

在view層,我們看到兩個事件,RequestToggleCompleteTodoViewModel和RequestDeleteTodoViewModel,可能被髮送來動態地改變個別ViewModels。

用於刪除的map塊:

用於標記已完成的map塊:

簡單的轉換,然後我們發出一個訊息。

這兩個事件將在Event.ResponseTodo接收響應。

其他要點

我不會深究其他事件。我只會提一些其他有趣的要點。

TodoDetailViewModel

TodoDetailViewController接受一個TodoDetailViewModel來允許使用者去改變其屬性。當完成按鈕被點選,TodoDetailViewController將用它自己的TodoDetailViewModel傳送一個請求到ViewModelServer。ViewModelServer會驗證所有的新引數然後回覆一個響應。響應事件Event.ResponseUpdateDetailViewModel很有趣,因為它將由三個不同物件的觀察。

  • TodoDetailViewController將觀察它的錯誤。如果有錯誤的驗證,它將在當前上下文前展現錯誤。
  • TodoListViewController將觀察非錯值,作為一個使用者結束編輯ViewModel的訊號去解釋它,然後它應該彈回TodoDetailViewController。
  • ViewModelServer將觀察其本身將傳送的訊息,因為現在它必須立即建立一個更新待辦事項Model併傳送一個寫待辦事項的Event。它的響應會通過正常的Event流傳回並由TodoListViewController透明地更新。

ResponseUpdateDetailViewModel

我有點想把一般化的CRUD如何進行新建和編輯操作集於一個介面。以前儲存過的和未儲存的待辦事項都可以同樣處理。驗證被看作是非同步的,因此這可以很容易地被當作一個在伺服器端的操作。

載入

我沒有實現任何載入指示器,只因這是小事。ViewController會觀察它自己的Request事件並開啟載入指示器作為一個副作用。然後它將關閉載入指示器當作Response事件的副作用。

唯一識別符號

有一件事你可能會注意到,在程式碼庫中每一個值型別必須equatable。由於請求和響應不直接配對,有一個惟一識別符號是能夠過濾和操作響應的關鍵。實際上在起作用的有兩個相等的概念。首先是一般的相等,比如”這兩個model有所有引數的值都相同嗎?”。第二個是身份的相等,比如”這兩個model表示的是相同的底層資源嗎?”(即lhs.id == rhs.id)。身份的相等在操作一個已經被更新並且你想替換它的model時,是有用的。

測試

我認為測試明顯是在ViewModelServer和ModelServer層。這些Servers註冊的觀察者在本質上是純函式,它們收到一個單獨的事件並派遣一個單獨的事件。一個單元測試示例:

上面的部分測試了一個ViewModelServer裡的觀察者,並在ViewModelServer和ModelServer之間的邊界等待獲得結果Event。

整合測試也不是不可能。以下是一個相同事件的整合測試版本,它不再等待在View和ViewModelServer層之間的邊界:

在這種情況下,後臺有兩個其他事件同時傳送,但是我們只等待最後一個。

這兩個server都處在表層,只對EventSignal有依賴性。

回顧

我們已經看了一個非常基本的應用的一些實現,現在讓我們退一步,看看我們一路上發現的利弊。

  • 利 一些在其他架構很難的事情變容易了!:D
  • 弊 一些在其他架構很簡單的事情變難了!:(
  • 利 實際上這種程式碼風格很有趣。
  • 弊 可能有目前未知的效能影響,考慮到存在很多觀察者,每個又接收大量的必須過濾的事件。
  • 利 執行緒似乎很安全。
  • 弊 仍然有很多沒有解決的問題。如何處理影象載入?身份驗證?特定順序的多步操作?列表重排序?更復雜的檢視改變型別?其他非同步API封裝?問題是無止境的。一個半生不熟的待辦事項應用幾乎沒有擴大系統複雜性的範圍。
  • 利 所有程式碼(除了UIKit)風格都很相似且非常實用。
  • 弊 所有的事件是全域性的(對於系統來說),因此在系統規模和複雜性上增長後,更多的意想不到的後果可能發生。
  • 弊 在觀察者宣告裡有相當數量的同樣格式的陳詞濫調。
  • 利 更容易理清物件的所有者和生命週期。
  • 弊 使用Result來異常處理並不適合。我直覺有另一個能做得更好的辦法,我需要研究(註解14)。
  • 利 測試可以說是一個相當無痛的過程。
  • 利 使得”重放”使用者的整個會議過程成為可能,通過管道傳輸,從eventsSignal的序列化儲存的輸出到eventsObserver的一個新的會話。
  • 利 分析會變得很容易,設定作為一個單獨的Server-type物件,當它們被放到流中可以監聽Event並轉換然後在必要時POST到伺服器。

我完成了構建這個待辦事項應用後,我意識到ReactiveCocoa不一定是最好的實現EventMVVM的工具。它的很多特性我並沒用到,我有一些怪癖,它旨在被用而我卻不使用它(註解15)。

我決定去試試我可不可以寫我自己的簡單的為EventMVVM量身定做的庫來實現EventMVVM。我花了一天的事件來與這個型別系統搏鬥,只因為我有一個最先的念頭–我要繼續試著測試。它只有大約100行程式碼。不幸的是,它不能自動化所有我想要的東西,觀察的過程仍有缺點。我會找時間寫一些關於這個庫的事。

你可以在Github看到我的進展。

總結

探索EventMVVM架構很有趣。我可能會繼續探索它,作為兼職。我絕對不會建議用它來實現任何重要的東西。

如果你有任何關於EventMVVM的想法,請通過Twitter讓我知道。我確定這種風格已經有個名稱(也許是觀察者模式?)。

只要新增這個觀察者到AppDelegate,就能獲取到系統中傳遞的每個Event的日誌,該有多酷?

1.未來EventMVVM的擴充套件可以是ModelEvent或ViewModel事件,並且每一個有輸入流。這樣,一個View物件只會看到ViewModel流,而ViewModelServers(稍後我會介紹它)會看到ViewModel和Model流。

2.一個更復雜的應用,將需要一個ReadTodosRequest結構來封裝一個分類描述符和謂詞。或者更好的是,一個更徹底的TodoListViewModel包含所有這些資訊。

3.事實證明,在響應本身嵌入一個可選的錯誤引數會更好。否則,就無法知道這個錯誤與哪個請求相關。我們暫時不考慮這個問題。

4.你當然可以把ViewModelServer和ModelServer合併到一個Server(或把一切都放在AppDelegate),但MVVM是幫助我們分離我們關心的事。

5.我最大的一個未解決的問題是如果Server物件相互大量建立該怎麼辦。任何像樣的應用,一個ViewModelServer的一個流裡有成百上千的觀察者是很笨拙的。它們也可能使用了太多的資源。如果我們把每個ViewModel型別分離出ViewModelServers,那麼主ViewModelServer怎麼知道如何管理它們的生命週期?

6.我大多數其他使用MVVM的專案,有些ViewModel是類並且挑起大部分關於非同步工作和應用內資料流組織的重擔,有些則是惰性的值型別。這背後的原因是通過分離邏輯來讓ViewControllers有點”遲鈍”。

7.這些型別的事件的例子有ViewControllerDidBecomeActive(UIViewController)和ButtonWasTapped(UIButton)。正如你所看到的,這將打破我們只有通過流傳送值型別的假設,並且更需要深思熟慮。當我在工作中把它與其他框架一起使用時發現,你可以跳過很多的障礙來避免UIKit期望你去做的方式,雖然通常你想出來另一種方式會更糟。

8.在”經典”MVVM裡,View將擁有ViewModel的,ViewModel將擁有Model/Controller。

9.準確來說,觀察者將被觸發轉換到完成狀態,當任何事件被髮送並且自身不再是活動的。就我們的目的而言,這不該是一件大事。雖然還有其他的辦法來解決這個問題,但是它們需要更多語法上的要求。

10.回想起來,讓Result一路穿過observeNext並在同一程式碼塊內處理成功和錯誤的情況,可能更清晰。

11.Scheduler是ReactiveCocoa原生的。它很巧妙。

12.如果你不熟悉MVVM,您可能想知道為什麼View層不直接發出Event.RequestReadTodos而是通過ViewModelServer傳送Event.RequestTodoViewModels。一個受歡迎的間接層是讓我們的View層不知道所有與Model層相關的事務。它引入了對自己和專案中的其它人的可預測性,所有型別的物件和值遵守同一套規則–哪些它們可以做,哪些物件它們可以互動。這顯然是一般化的,而且感覺它存在專案的早期,但在大型專案我很少發現它會被毫無根據的優化。

13.不包括Model層的列舉型別錯誤是因為懶。我們設立的轉換管道已經能夠很容易讓我們對於正確的上下文正確地表示。

14.提示:這是新增一個error引數到所有model和ViewModel。

15.它可以用NSNotificationCenter實現(並非我有試過)。也可以用其他的Reactive Swift庫。

相關文章