更新於 2023/07/15閱讀時間約 20 分鐘

閒談軟體設計:MVC

前言

很久前就曾想寫這個主題,但若寫的不好,恐怕又引起一番論戰,畢竟眾多 framework 實作 MVC 的方式都不一樣,從某 framework 入手認識 MVC 的開發者對 MVC 的認知可能就和從另一個 framework 了解 MVC 的開發者不同。
例如文中範例使用的 Java Swing,採用簡化版的 Model UI-Delegate 模型,UI-delegate 封裝 view (負責繪製元件) 和 controller (處理低階事件更新畫面並轉成高階事件給有興趣的 listener)。例如當使用者對一個按鈕按下滑鼠左鍵時,按鈕會轉成按下的狀態,我想使用 Java Swing 的開發者應該不會想處理這麼低階的事件,但我們仍可以呼叫 JButton 的 addActionListener 註冊高階事件 (action event 而非 mouse event) 處理按鈕被按下時要做些什麼事。
Figure 1 — Java Swing Model Delegate (圖片來源:Java Swing, 2nd Ed, O’Reilly)
Figure 1 — Java Swing Model Delegate (圖片來源:Java Swing, 2nd Ed, O’Reilly)
會想回頭寫這個主題是因為最近面試 iOS 工程師時,在履歷中常常看到導入 MVVM,然後問為什麼要導入 MVVM 時,最常聽到的答案是這樣不會有很肥大的 view controller,但如果再問 view controller 是 MVC 的那一個部分,很多人卻回答不出個所以然,所以想聊聊這個很多種說法的 MVC pattern。

MVC 的歷史

MVC 是 Xerox Palo Alto Research Center (PARC) 在替 Smalltalk 設計 GUI 函式庫時所提出來的一個設計思維 (從論文發表於 1988 年可知,MVC 是一個歷史悠久的 pattern,後續也有多種變形),希望能滿足二個目標:(1) 建立一組系統元件支援互動式的開發流程 (例如,Visual Studio 的 Form Builder 或是 Xcode 的 Interface Builder);(2) 提供一組系統元件讓開發者能開發可攜的互動式圖形應用程式。
為了滿足上述的二個目標,勢必過去所有東西都攪和在一起的寫法是不可行的 (特別是第二點),因此將應用程式的商業邏輯及資料 (model)、使用者介面的繪製 (view)、以及如何與輸入介面互動 (controller) 拆開成三個獨立的元件,然後用相依 (dependent) 及廣播變動 (broadcasting change) 的方式組成一個互動的循環標準:
Figure 2 — MVC interaction (圖片出處:A Description of the Model-View-Controller User Interface Paradigm in the Smalltalk-80 System, Glenn E. Krasner and Stephen T. Pope, ParcPlace Systems, Inc.)
View 從 Model 取得資訊並在螢幕上進行繪製;Controller 接收到事件後,修改 Model 的狀態,有必要的話通知 View 重新繪製畫面;Model 的狀態改變後,會通知所有的相依 (View 通常是其中一個,Controller 也可能是);View 收到通知後會再次跟 Model 取得新的狀態,然後更新到畫面上;Controller 收到通知後可能改變處理事件的方式。這也就是為什麼 MVC 常和 Observer pattern 一起討論的原因了,廣播變動很適合用 Observer 來實現。

從設計一個 UI 元件開始

所以我們先從設計一個元件可以繪製像下方一樣的圓餅圖開始吧,依照剛剛所說的我們需要一個 Model。等等,Model 不是指商業邏輯嗎?我們又不是設計一個應用程式,怎麼會有 Model?有的!我們現在的 scope 就這麼小,這個元件就是一個應用程式,可以接受不同的資料輸入然後畫出圓餅圖。
Figure 3 — A simple pie chart (圖片出處:https://developer.apple.com/support/app-store/)
觀察 Java 2D 的 API 會發現,畫圓餅圖不難,只要建立對應的Arc2D物件然後呼叫 Graphis2D 填滿顏色就可以完成了,從下面的範例程式,很容易就能設計出一個 PieChartModel,提供 methods 能讓 PieChart 取得以下資訊:標題、圖中有幾個區塊、每個區塊的顏色、名稱、起始角度 (starting angle) 及延伸角度 (extent angle)。剩下的繪製邏輯像是高度、寬度和起始座標該怎麼計算,PieChart 會自己處理。
於是我們有第一個版本的 Model (Figure 4 中藍色的部分) 及 View (Figure 4 中綠色的部分) 了 。
Figure 4— PieChart View and Model (version 1)
等等,一般開發者應該不會期望他們得自己提供一個角度的實作吧?一般不是只要提供資料就好嗎?而且 Java Swing 的元件通常會提供預設的 Model 實作不是嗎?所以繼續優化第一個版本的設計,首先我們可以將資料來源與外表設定拆開成 PieChartDataModel 和 PieChartAppearanceModel,並可以透過 PieChart 的 setter 設定對應的實作。
從 Figure 5 可以看到 PieChartDataModel 引入了 NamedValue 資料結構,這其實是修正第一個版本中 PieChartModel 個別取得數值和名稱,數量可能不匹配的問題。由於 PieChartDataModel 只提供數值,缺少圓餅圖中個別區塊的起始角度和延伸角度的計算,因此多了一個類別 PieAngleCalculator,根據資料計算需要的角度 (會獨立拆出一個類別是為了測試方便,實務上計算的邏輯放入 PieChart 也是常見的做法)。此外,PieChartAppearanceModel 不但定義區塊的顏色,也定義標題和區塊副標題的字型與顏色,讓 PieChart 可以有更多外觀的設定。
Figure 5 — PieChart View and Model (version 2)
特別的是 DefaultPieChartDataModel 及 DefaultPieChartAppearanceModel,由於有這二個預設類別,PieChart 不只有需要提供實作的建構子,也有無參數的建構子 (Listing 2),這邊先賣個關子,為什麼在建構子裡是呼叫 setter 而不是直接 assign 參數給 member data?DefaultPieChartDataModel 的實作很簡單,內部有一個 NamedValue 的 List,讓開發者能新增或刪除數值。另一個外觀的實作可以很單純隨機產生一個顏色,並根據 PieChart 的大小決定字型。
現在 Model 和 View 都有了,廣播變動和相依呢?ok,剛剛有提到,這很容易用 Observer 的方式實現:新增二個 listener 介面 (符合 Java Swing 的命名慣例):PieChartDataModelListener 和 PieChartAppearanceModelListener,然後 PieChart 同時實作這二個介面,當有變動時,直接重繪 (repaint),另外在 Model 的介面上個別加上 add 和 remove 的函式管理相依。
Figure 6 — PieChart Model View with Observer
最後,誰替 Model 和 View 把相依建立起來呢?一般來說不會是 Model 主動做這件事,因為 Model 並不知道 View 的存在,所以剛剛提到的 setter 就可以派上用場了,如 Listing 6 所示,我們可以在 setter 中順便管理相依,view 將自己從舊的 model 移除然後新增到新的 model 中,除此之外,view 也更新 calculator 和 model 的關聯。
到目前為止,Controller 都還沒有出場,實際上,若沒有要處理任何事件的話,整體的設計就到此為止,沒有一定要有 Controller。但作為一個範例,我們還是替 PieChart 接上滑鼠事件,新增一個 PieChartMouseEventListener類別實作 MouseListener 和 MouseMotionListener,根據滑鼠座標判斷目前滑鼠停在哪個區塊上,或是選取哪個區塊,將低階的滑鼠事件轉成高階的事件,因此使用 PieChart 的開發者便可以實作並註冊 PieChartEventListener 以接收高階的事件。
Figure 7 — PieChart Model, View, and Controller
從 Figure 7 可以看到,大多數的邏輯應該是落在 Model (資料邏輯) 和 View (繪製邏輯) 身上,Controller 應該是若有似無 (只有一個橘色的類別),一個很薄的存在,所以如果發現有很多邏輯落在 Controller 身上,哪肯定有哪裡做錯了,檢查是否有邏輯應該是在 Model 身上卻在 Controller 身上。

應用程式的角度

現在我們有一個 PieChart 元件,也有 Java Swing 提供的 JTable 等眾多 UI 元件,該是將開發的 scope 放大了,假設我們要開發一個像 Figure 8 的應用程式,可以在第一排選取想顯示的年度,根據年度列出該年的有哪些選舉,若是全國性的選舉 (公投或是總統選舉),第二即使沒有選取,下方左側也會列出選舉結果 (以縣市作為群組),右側則是政黨比例。
若是地區選舉,第二排的第一個下拉式選單會預先選擇第一個直轄市或縣,然後下方左側一樣會列出選舉結果 (以區或縣轄市為群組),右側仍是政黨比例,若第二個或第三個下拉式選單有選擇時,下方會自動根據選取的條件進行更新。
Figure 8 — Election stats app prototype
即便是這樣單頁式的小應用程式,您會發現也有很多的邏輯在裡面,看似好像是 UI 的邏輯,其實不是,而是選舉結果如何過濾及統計的商業邏輯。若直接以目錄 (樹狀) 的資料結構出發,很容易可以想像成每個投開票所,每個候選人的得票是樹的末節點 (leaf node),然後一層一層往上群組起來,所以只要套個 composite pattern 就能輕鬆算出每一層的總得票。
但實務上,考量到資料庫的速度,通常不會撈出所有的結果後再來處理,因此,Model 層的資料結構和資料庫表格的結構可能會不一樣,表示可能會有像 Figure 9 這樣一個複雜的 Model (這只是一個示範用的簡單例子,實際可能更複雜,但這裡 Model 的完整性不是重點),ElectionStatsMainModel 是一個 Facade 介面提供多數查詢所需要的函式。
Figure 9 — Election Business Domain Model
有了 Business Domain Model 後就可以開始寫 View 和 Controller 了,真是如此?若仔細看一下 Figure 8 會發現,光是這樣的畫面也藏了許多邏輯,例如第一排選了什麼,第二排的選項和下方的內容要跟著更新,第二排的選項左邊又影響右邊,選擇後下方的內容也要更新。若比照 PieChart 把整個畫面也視為一個 UI 元件,似乎也應該要有個 Model 管理這些邏輯。
以 Java Swing 來說,我們可以提供 ComboBoxModel 給 JComboBox (下拉式選單),只要 ComboBoxModel 的內容或選取的項目有任何變動,JComboBox 都會立即反映在 UI 上,因此,這畫面的 Model 要能提供五個 ComboBoxModel 分別代表年度、選舉別、直轄市/縣、區/縣轄市和里五種不同的選項來源;接著選票數量的顯示可以 JTable 來呈現,此時會發現能提供一個 TableModel 給 JTable,和 JComboBox 一樣,JTable 能立即反應 TableModel 內的資料變動;最後,提供一個 PieChartDataModel,就能夠在 UI 上顯示圓餅圖,若想用政黨代表色,額外提供一個 PieChartAppearanceModel 即可。
Figure 10 — ElectionStatsDashboard and ElectionStatsDashboardModel
這時候會發現,ElectionStatsDashboard 真的只要負責將需要的 UI 元件建立出來,並用 layout 管理員把 UI 排好,幾乎沒有什麼邏輯在 view 身上,因為所有複雜的邏輯都委託給 ElectionStatsDashboardModel。剩下幾個小問題,誰實作 ElectionStatsDashboardModel?以及我們有兩個不同的 Model,怎麼串起來?答案其實還蠻單純的,提供一個實作 ElectionStatsDashboardModel的 adapter 就搞定了,最重要的一點是,這個 adapter 不需要用耗時的 UI 測試來驗證正確性,完全可以用單元測試框架進行所有邏輯的測試。
Figure 11 — ElectionStatsDashboardModel Adapter
不知道例子到這裡,大家是否有感受到 MVC 的好處?分離 Model 和 View 的邏輯,彼此能獨立開發,讓測試變容易,同時盡可能最大化可測試的程式至少,我當初是這樣學 MVC 的。

回到 iOS App 開發

討論 iOS App 開發前,我想可能得先回答 UIViewController 到底是 view 還是 controller?還是說它和 Java Swing 的 UI-Delegate 一樣呢?從官方文件列出的四個主要責任來看:
  • Updating the contents of the views, usually in response to changes to the underlying data.
  • Responding to user interactions with views.
  • Resizing views and managing the layout of the overall interface.
  • Coordinating with other objects — including other view controllers — in your app.
確實蠻接近 UI-Delegate:它既是 view 也是 controller (就命名來說,它已經告訴您答案了),主要的原因是 Xcode 提供的 Interface Builder 編輯後最終的結果是個 XML 檔案,是一種 passive view,只有 layout 描述,沒有其他繪製或是更新資料到 UI 上的邏輯,於是將資料反映到 UI 上的 view 邏輯就變成 view controller 最主要的工作之一。
加上 @IBAction 只能接到 view controller (file owner) 上,於是很多人就很像早期用 Visual Basic 寫程式一樣,在 Form Builder 拉一拉畫面,在按鈕上按兩下,然後就把邏輯寫在自動產生的 method 中一樣,最後所有邏輯集中在 view controller 身上。
這還不是最慘的,畫面中有使用到 UITableView 或 UICollectionView,通常對應的 view controller 會直接實作元件需要的 data source 和 delegate (這兩者通常會放在一起,因為彼此都需要對方的資料,就這一點來說,這兩個不算是良好設計的介面),於是很多人也就把 model 相關的邏輯放進來,這都導致 view controller 變肥大。
但 view controller 很肥大誰的錯?是 MVC 的錯?不是,是開發者的錯,把不該放在 view controller 的邏輯放到 view controller 身上了。MVC 從來就沒教您把 model、view 和 controller 三種不同概念的程式放在一起。
如果開發 iOS App 時,每個畫面都像是在設計PieChart一樣用心,把 model 和 view 仔細拆開,在串接每個畫面和實際的商業邏輯時,像是上述的選舉統計程式那樣,您會發現一點點 MVVM 的影子,即便從文章開始到現在,我都沒提到過 MVVM 長什麼樣子。

結論

曾有人問我,要不要在公司的專案,強制一律使用 MVVM,我當時的答案是沒必要,我個人現在寫程式的主要原則是 KISS (Keep It Simple Stupid),只堅持 model 要獨立出來,該是 model 的邏輯都要放到 model 中,model 要有測試,其他部分,要用 iOS 風格的 MVC、MVP 或是 MVVM,對我來說都只是 MVC 在不同 force 下所做的變形。
設計該留點彈性,有時候一個簡單畫面,卻用上一堆 design pattern,有點殺雞用牛刀,雖然這彈性也可能留下菜鳥工程師亂寫的後遺症,但搭配團隊有好的 code review 機制,其實這問題應該不會發生才對。
最後,程式寫的不好,在怪 MVC 之前,先想想您的 MVC 寫對了嗎?

後記

[1] MVC 的原始論文有 34 頁,對習慣看網路文章的人來說不算短,若只看概念,可以只看到 User Interface Component Hierarchy 一節就可以結束,剩下的是當初實作的一些細節討論 (其實也很有意思),這樣的話就是 12 頁的長度,蠻值得一看的,若 12 頁還嫌長,那...看這一篇文章就好了 XD
[2] 是否能把 PieChartDataModel 和 PieChartAppearanceModel合在一起,變回原本的 PieChartModel 呢?基本上沒什麼不可以,但 data 和 appearance 兩者的變動頻率差蠻多的,為了不互相影響,拆開還是比較合適的。假設有開發者考量 data model 讀取效率實作 PieChartModel,此時 PieChart 為了外觀新增一個 getter,會迫使開發者也得實作該 getter,這不是一個好現象,反之亦然。
[3] 想更深入了解 framework 如何實作 MVC 的話,我真心推薦從 Java Swing 著手,雖然文章一開始說 Java Swing 使用簡化版 MVC,但在 model 及 view 的設計卻十分的漂亮,就如同結論所述的 controller 是 model 和 view 之間的膠水,若有似無的存在,Java Swing 在這一方面也處理得很合適。
分享至
成為作者繼續創作的動力吧!
© 2024 vocus All rights reserved.