歡迎來到真實世界 - 原來是那個傳說中的MVVM阿
好不容易來到了續作的第三集,就這樣以近乎休刊般的速度,也寫了三篇長篇了。雖然對很多高手前輩來說,這些都是非常基礎的東西,但小蛇在跌跌撞撞了許久(還在跌)後,覺得有些東西還是自己寫下來,可以再次釐清自己的觀念,也偷偷希望能夠獲得高手指點或是加入討論,這就是邊緣人參與社會的方式阿(跟本就只是偷懶吧)。
話不多說(已經說很多了,想看電影心得可以直接跳到最後),就讓我們進入今天的主題。
這篇我們要來談談開發上更貼近實務的部份:如何設計一個好的軟體架構,以及如何測試它。在iOS開發過程中,如果是比較大型的app,通常複雜度都非常高,而且手機開發所需要架構的東西,必須要融合前後端的知識,從跟使用者第一線接觸的UI,到手機底層的資料庫,都必須透過你的code來連接跟協調。這個架構好不好讀、好不好維護、好不好測試,就會是整個開發的重點了,如果這個架構不是很好,接手的人或合作的人無法快速理解,就連你自己有時候都看不太懂,那未來某一天你一定掉進你自己挖出來的大坑裡(對,小蛇我還在我挖的坑裡)。
講架構或許有點抽象,要把既有的架構法則套到自己的程式中也不是一天兩天的事情,但有個好方法或許可以試一下,從現在開始,你可以試著培養自己的測試腦。甚麼是測試腦?就是接下來我所要做的事情,我所要做的改變,都是為了要讓測試更容易。你很難想像怎樣的程式是乾淨的程式,畢竟軟體開發的法則很多,光是要不要寫註解就有非常多說法了,對於像小蛇一樣資歷不深的人來說,跟本背不起來更不用說活用了。但專注在讓code容易寫測試,就沒有那麼難了,因為你一開始會發現,ㄊㄇㄉ我的程式跟本無法寫測試阿阿阿!從這邊開始,你就會去研究怎樣decoupling,研究design pattern,研究各種既有的架構,而不是因為教主說它好用就用,這就是好的開始。所以來跟我說一次,感恩ㄙ...不對,是「我要讓測試更容易」!
關於這個架構文,我會把故事拆分成兩篇文章,第一篇會講到一般的Apple MVC架構既有的問題,還有我們要怎樣改善它。第二篇則是會講到如何針對MVVM的架構來撰寫unit test。
TL;DR
在這篇文章裡,你可以了解到:
- Apple MVC架構所帶來的問題
- 利用MVVM來設計更乾淨的架構
- 一個簡單的MVVM App範例
同時,你在這篇文章裡將不會看到:
- MVC、MVVM、VIPER的比較
- MVVM洗腦大會
- 萬能的軟體架構
以一個軟體開發者來說,除非你停止開發(或停止呼吸)了,不然軟體架構永遠都不會有最好的、最完美的時候,在開發的過程中,你總是可以找到更好的模式,總是會學到新的方法,這些東西都可以不斷讓你的架構更乾淨更好懂,所以在這篇文章裡,重點會放在為甚麼要使用MVVM,它解決了怎樣的問題,相信這些背後的脈絡,也同時可以套用在其它不同的架構上,MVVM只是你跟真實世界接觸的載體而已(硬要套點電影式的假哲學)。
Apple MVC
Apple所提倡的MVC(Model-View-Controller),是在iOS開發過程中,第一個會遇到的架構。在原本的MVC之中,Model代表資料,View代表視圖,Controller則是負責商業邏輯。這三者的互動方式如下圖:
Controller同時擁有View跟Model,並且做為統整兩邊的橋樑的角色。但故事來到了iOS開發,因為View角色特殊的關係,原本的MVC變成了Model-ViewController+View:
ViewController包含了View,並且加入了一些View的life cycle的邏輯。因為UIViewController地位特別的關係,View跟Controller的code都會出現在UIViewController裡面,這樣會造成UIViewController變得相當肥大,也就是大家常說的Massive View Controller。並且這個UIViewController其實很不好寫Unit Test,因為你的Controller邏輯跟View綁得太深。你如果想要測某個Controller的功能,就必須要mock某個view以及它的life cycle,這樣是很不符合經濟效益的。
針對Apple MVC的問題,網路上已經有非常多的討論跟解法,不管是那一種解決辦法,主要的施力點,都是把過多的邏輯,從UIViewController裡面切分出來,並且設計一個乾淨的架構,讓所有的物件能夠盡量遵循single responsibility原則,不要有分工不明的問題。目前常見的替代架構有MVVM、VIPER兩種,都是解決Massive View Controller的好方法,也都有各自的優缺點。因為MVVM比較好上手,也比較能夠拿來解釋切分權責的步驟,所以接下來我們會以MVVM為主,介紹MVVM以及怎樣拿MVVM來解決MVC的問題。
MVVM - Model - View - ViewModel
MVVM的概念最早應該是在2005年由Microsoft的John Gossman提出來的,它的概念是,整個模組會拆分成三個部份,View、ViewModel、Model,其中View的角色就是單純的視覺元件,像是按鈕、文字標籤等等,在View裡面不會有邏輯、狀態等等,單純就是個呈現資料的元件。而要讓View呈現資料,最直覺的方式,就是把View跟Model做綁定,讓View的元件跟著Model一起做變化。但這樣會有個問題,就是通常Model來的資料,並不是簡單就能轉換成View的樣式的,這時候就需要有個物件,介在View跟Model的中間,這個物件會掌管這些跟View高度相關的邏輯的操作,像是轉換Date物件變成人看得懂的文字格式等,稱之為ViewModel。上面的概念可以畫成這樣的資料流:
流程上,ViewModel會從Model取到資料,並且把資料整理好成為方便顯示的樣子,而View一看到ViewModel的資料有更新,就會跟著一起更新,這就是一個最單純的MVVM資料流。
在iOS開發上,依照上述MVVM的定義,UIViewController變成一個單純的View,而我們會另外產生一個ViewModel來負責presentational logic跟部份的controller logic。所以在你的ViewController裡面,就只會有:
- View logic,所有跟呈現有關的Code
- 綁定ViewModel
而在ViewModel裡面,則是負責兩個部份:
- Controller logic,如pagination, error handling,… etc
- Presentation logic,提供接口讓View綁定(binding)
開發上,一旦View綁定好ViewModel的資料,在撰寫商業邏輯的時候,就可以不用管包括動畫、轉場、main thread等等跟View相關的問題,因為分工明確所以就不會有寫起來綁手綁腳的感覺。更棒的是,並且因為ViewModel是一個單純的、沒有相依於View的物件,所以要做測試簡單多了!
在文末我們會討論這個其實也身兼多職的ViewModel到底有甚麼問題,就讓我們繼續看下去~
A simple gallery app - MVC
接下來,我們要用一個簡單的例子,來讓大家了解怎麼從MVC轉換到MVVM。
這是一個簡單的App,具有下面兩個功能:
- 會從500px API抓取熱門相片,並且把相片排成列表show出來,每張相片都會顯示標題、描述、跟拍攝日期。
- 如果使用者點選了非賣品,app就不會讓使用者進到下一頁,並且跳出錯誤訊息。
請看以下示意圖:
在這個app裡,我們會有一個Model,叫Photo,代表的就是一張照片,這個Model會跟JSON上拿到的資料格式一樣,如下:
struct Photo {
let id: Int
let name: String
let description: String?
let created_at: Date
let image_url: String
let for_sale: Bool
let camera: String?
}
而我們透過一個APIService物件,上網去抓資料,並且把資料轉成Photo物件供ViewController使用,並且由一個activity indicator來顯示讀取中的資訊,這個事件發生在ViewDidLoad裡面:
self?.activityIndicator.startAnimating()
self.tableView.alpha = 0.0
apiService.fetchPopularPhoto { [weak self] (success, photos, error) in
DispatchQueue.main.async {
self?.photos = photos
self?.activityIndicator.stopAnimating()
self?.tableView.alpha = 1.0
self?.tableView.reloadData()
}
}
而這個tableView的data source,也會寫在這個VIewController裡面,如下:
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
// ....................
let photo = self.photos[indexPath.row]
//Wrap the date
let dateFormateer = DateFormatter()
dateFormateer.dateFormat = "yyyy-MM-dd"
cell.dateLabel.text = dateFormateer.string(from: photo.created_at)
//.....................
}
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return self.photos.count
}
這個tableView的數量就等同於抓下來的Photo數量,並且在reuse cell時,取對應的photo物件,打包成對應的格式,設定到cell上面。在這裡,因為Date物件無法直接顯示在View上面,需要變成”yyyy-MM-dd”這樣的格式,所以我們要在指定資料到UILabel上之前做轉換,把Date轉成字串讓label可以正確顯示。
而跟使用者互動的delegate部份,則是這樣:
func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? {
let photo = self.photos[indexPath.row]
if photo.for_sale { // If item is for sale
self.selectedIndexPath = indexPath
return indexPath
}else { // If item is not for sale
let alert = UIAlertController(title: "Not for sale", message: "This item is not for sale", preferredStyle: .alert)
alert.addAction( UIAlertAction(title: "Ok", style: .cancel, handler: nil))
self.present(alert, animated: true, completion: nil)
return nil
}
}
在func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath?
裡面,我們會先去判斷使用者點選的照片,如果點選的照片是for sale的,就記錄使用者點選的indexPath供segue使用。如果點選的不是for sale,就跳出一個alert,說這是非賣品,並且回傳nil,讓segue不要發生。
以上就是一個最簡單的app,詳細的原始碼可以參照這裡(tag::”MVC”)。
這是基本的Apple MVC架構,也是各種教學中常見的範例,打字快一點的人應該不用幾分鐘就可以刻出這樣的東西來。但這東西有甚麼問題?在這個MVC裡面,同時也有像是activity indicator、tableview出現或消失等等的邏輯(Presnetation logic),也有上網取資料的邏輯(Controller logic),加上View跟它們的life cycle整個被綁在UIViewController裡面,所以這個ViewController的角色變得有點混亂。更麻煩的是,這個ViewController是很難被測試的!除非Mock整張tableView與它的cell,不然我們沒辦法知道date是不是真的正確地被轉成該有的樣子,我們也需要mock activity indicator,才能夠知道loading的狀態是不是有正確地對應,這個測試寫起來會非常可怕。
為了讓測試變的更容易,讓我們動手來改變這一切。
Let’s do MVVM
為了要解決上面這些問題,我們的首要之務就是要清理ViewController,讓部份的邏輯獨立出來,成為一個有主權、有領土、能自決的物件!(是不是很值得支持) 回顧剛剛MVVM的定義,我們目前的任務就是:
- 把View跟ViewModel做綁定
- 把controller logic跟presentation logic從ViewController移到ViewModel
先看綁定的部份,在頁面上,我們的View上有幾個主要元件:
- activity Indicator (loading/finish)
- tableView (show/hide)
- cells (title, description, created date)
如果我們把這些元件的資料跟狀態整理出來,抽象化成為一些ViewModel的接口,就會變成像下圖這樣:
所有的View的狀態,都有他們對應的ViewModel properties,並且每個cell也都有相對應的ViewModels,這樣就能夠確保View的長相就是我們在ViewModel上面看到的一樣。
那實作上我們要怎麼做綁定呢?
Implement the Binding with Closure
在Swift裡,要做到資料綁定,有幾種方法:
- 用ObjC的KVO pattern
- 使用FRP套件如RxSwift或ReactiveCocoa提供的binding功能
- 自己實作
使用ObjC的KVO(Key-Value Observer)是個不錯的方法,但是因為KVO本身在ObjC裡特別的設計,所有更新數值都是透過一個delegate function來達成,所以使用KVO在ViewController裡面會變得有點複雜,這樣就失去我們想要簡化的意義了。使用FRP(Functional Reactive Programming)提供的binding是最方便的,一旦引入了signal跟event的概念後,View跟ViewModel之間的互動就有統一且直觀的作法,不過因為FRP是一個不小的概念,為了避免失焦所以在這邊我們也不使用。自己實作綁定的話,有不少方法,像是這篇利用decorator pattern來做到不同類型的物件綁定,很值得深入研究。
在這篇文章,我們選擇一個更單純的做法:利用Closure,讓ViewController去等待ViewModel的改變,來觸發View的更新,達到綁定的效果。
具體來說,一個在ViewModel裡面,即將跟View綁定的property,會長這個樣子:
var prop: T {
didSet {
self.propChanged?()
}
}
在View初始化ViewModel時,會順便設定好viewModel.triggerVIewUpdate:
// When Prop changed, do something in the closure
viewModel.propChanged = { in
DispatchQueue.main.async {
// Do something to update view
}
}
所以每當ViewModel裡面的prop更新時,都會觸發這個closure,進而讓View做某些更新,這種綁定很好理解並且瑣碎的code也不多,也可以很靈活的運用。
Interfaces for binding - ViewModel
現在我們可以來寫code了!我們先設計出簡單的PhotoListViewModel,具有接口如下:
private var cellViewModels: [PhotoListCellViewModel] = [PhotoListCellViewModel]() {
didSet {
self.reloadTableViewClosure?()
}
}
var numberOfCells: Int {
return cellViewModels.count
}
func getCellViewModel( at indexPath: IndexPath ) -> PhotoListCellViewModel
var isLoading: Bool = false {
didSet {
self.updateLoadingStatus?()
}
}
每個cell的ViewModel,都被存在cellViewModels裡面,都可以透過getCellViewModel來取得,cell的數量則是透過numberOfCells來取得,只要cellViewModels這個property一更新,tableView就會跟著重整。而每一個cellViewModel會長得像下面這樣:
struct PhotoListCellViewModel {
let titleText: String
let descText: String
let imageUrl: String
let dateText: String
}
這個PhotoListCellViewModel代表每一個即將出現在cell上面的資訊,所以cell只要照著上面的資料呈現視圖就好,不用做任何轉換。
Bind View with ViewModel
有了上面這些接口,接著我們就要來撰寫ViewController的部份。首先,在ViewDidLoad先指定好ViewModel的closure:
// Observer by closure
viewModel.showAlertClosure = { [weak self] () in
DispatchQueue.main.async {
if let message = self?.viewModel.alertMessage {
self?.showAlert( message )
}
}
}
viewModel.updateLoadingStatus = { [weak self] () in
DispatchQueue.main.async {
let isLoading = self?.viewModel.isLoading ?? false
if isLoading {
self?.activityIndicator.startAnimating()
self?.tableView.alpha = 0.0
}else {
self?.activityIndicator.stopAnimating()
self?.tableView.alpha = 1.0
}
}
}
viewModel.reloadTableViewClosure = { [weak self] () in
DispatchQueue.main.async {
self?.tableView.reloadData()
}
}
針對tableView的datasource,則改成從PhotoListViewModel拿到PhotoListCellViewModel,在利用cellViewModel來指示cell要怎樣呈現:
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
guard let cell = tableView.dequeueReusableCell(withIdentifier: "photoCellIdentifier", for: indexPath) as? PhotoListTableViewCell else {
fatalError("Cell not exists in storyboard")
}
let cellVM = viewModel.getCellViewModel( at: indexPath )
cell.nameLabel.text = cellVM.titleText
cell.descriptionLabel.text = cellVM.descText
cell.mainImageView?.sd_setImage(with: URL( string: cellVM.imageUrl ), completed: nil)
cell.dateLabel.text = cellVM.dateText
return cell
}
這樣資料流就變成了,ViewModel一旦整理好資料,View就會去跟ViewModel拿整理好的資料,更新自己並且顯現出來。完整的資料流會長得像下圖一樣:
User interaction - View
如果使用者有互動的時候呢?我們在ViewModel裡面,提供了這樣的接口:
func userPressed( at indexPath: IndexPath )
這個接口讓ViewModel能夠接收使用者的行為,並且針對這樣的行為做出對應的動作。對ViewController來說,table view delegate就可以變得更簡單了:
func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? {
self.viewModel.userPressed(at: indexPath)
if viewModel.isAllowSegue {
return indexPath
}else {
return nil
}
}
View一接收到使用者的動作,就馬上把它傳給ViewModel,並且由ViewModel透過isAllowSegue來決定,到底該不該啟動這個segue,View就是一個能夠體察上意的,恩,好官員XD
Implementation of PhotoListViewModel
那ViewModel裡面是長怎樣呢?在這個應用中,ViewModel負責上網抓資料,並且把資料轉換成供呈現的PhotoListCellViewModel,所以我們使用了一個array來裝這這些cellViewModels:
private var cellViewModels: [PhotoListCellViewModel] = [PhotoListCellViewModel]()
在ViewModel的初始化階段,我們做兩件事情:
- 注入dependency - APIService
- 開始抓取資料
init( apiService: APIServiceProtocol ) {
self.apiService = apiService
initFetch()
}
func initFetch() {
self.isLoading = true
apiService.fetchPopularPhoto { [weak self] (success, photos, error) in
self?.processFetchedPhoto(photos: photos)
self?.isLoading = false
}
}
在這段code裡面,我們會跟APIService要資料,在要資料之前先把isLoading設定成true,因為isLoading有跟View做綁定,所以View會針對這個事件轉換成讀取中的樣式。在資料全部都取下來之後,在processFetchedPhoto(photo:)裡面,把資料轉化成適合顯示的樣子(cellViewModels),並且把讀取中的狀態設定成false,這時候View會因為isLoading變成false,activity indicator就會停止轉動,也會因為cellViewModel的更新,重整table view並且把新的資料show出來。
下面是processFetchedPhoto的實作:
private func processFetchedPhoto( photos: [Photo] ) {
self.photos = photos // Cache
var vms = [PhotoListCellViewModel]()
for photo in photos {
vms.append( createCellViewModel(photo: photo) )
}
self.cellViewModels = vms
}
它把收到的photo,透過createCellViewModel( photo: Photo)轉成了一個一個的CellViewModel,這些CellViewModel,在資料上長得跟Cell是一樣的,未來Cell在呈現時,就會照著CellViewModel的資訊,一五一十地反應出來。
Yay!我們終於完成了所有的綁定跟改寫!
以上的程式,都可以在我的Github裡面找到。
其中MVC版的App可以翻到”MVC”這個tag,而最新的commit就是MVVM加上測試的版本。
“MVVM is Not Very Good”
就像上面提到的,永遠沒有最好的架構,相信你看完這篇文章之後,也大概猜得到MVVM有甚麼問題了,網路上也已經有蠻多關於MVVM的缺點的討論,如:
MVVM is Not Very Good - Soroush Khanlou
The Problems with MVVM on iOS - Daniel Hall
其中,第一篇可以說是砲火猛烈,並且引起了不少討論,兩篇作者共同的論點是MVVM其實跟MVC跟本差不多,只是把一推code從viewController移到另外一個地方,它們還是一堆code。這個說法基本上忽略了很重要的一點:透過MVVM,我們離可測試的程式碼又躍進了一大步了!對ViewModel來說,它完全沒有View的包袱,但又可以利用簡單的assert來測試呈現效果。MVVM跟原本的MVC乍看之下很像,但就測試跟權責分離來說,還是很不一樣的。
對於小弟來說,MVVM最大的缺點,就是controller跟presentation layer的定義模糊,大多數(包含我自己)的人,都把controller的工作,跟presenter一起放在view model裡面了,也就是說view model同時又負責協調網路層、資料庫層(controller),同時也處理轉換資料成為可綁定的對象(presenter),以我們的相片app來說,PhotoListViewModel做了相當多的controller任務,但PhotoListCellViewModel就是個單純的presenter。
另外,MVVM還有一個非常致命的缺點,就是缺少router跟builder這兩個角色,router負責頁面切換的邏輯,而builder負責初始化這一切。這兩個角色,在大多的MVVM應用中,都被寫到viewController裡面了。
以上這兩點,當然也已經有人提出並且有對應的解法了,其中一種解法是VIPER架構,另外一個則是MVVM+FlowController(Improve your iOS Architecture with FlowControllers),這兩個都是非常棒的設計,其中MVVM+FlowController的概念我很喜歡,未來會再針對router+builder的議題研究一下再做分享。
架構只是輔助
在開發世界中,沒有最好的架構,與其爭論那一個比較好或者誰用的是正統誰用的不是,不如先了解一下,這些架構出現的前因後果,還有他們身上所背負的使命,如果能夠一直保持著「我要讓測試變得好寫」這樣的心情去看待這些架構,就會發現他們已經很有效地完成了他們的任務了。我選用MVVM來架構我的程式的理由非常簡單,就是它比較好理解,也比較容易上手。可以延伸閱讀Soroush Khanlou的另外一篇文章8 Patterns to Help You Destroy Massive View Controller,裡面提到了很多架構的基本法則,你會發現大家努力的方向都是類似的,都盡量希望物件能夠有單一的責任,都希望利用composite pattern來decoupling。而現階段MVVM的設計也是朝著這樣的方前在前進著。
下一篇我們就要來進入幫MVVM寫測試的世界囉!將以休刊般的速度出刊,敬請期待!
文章參考非常多相關的資料,但還是很怕有觀念上的謬誤,如果有誤歡迎大力指正,也歡迎在底下加入討論。
參考資料
Introduction to Model/View/ViewModel pattern for building WPF apps - John Gossman
iOS Architecture Patterns - Bohdan Orlov
Model-View-ViewModel with swift - SwiftyJimmy
Swift Tutorial: An Introduction to the MVVM Design Pattern - DINO BARTOŠAK
MVVM - Writing a Testable Presentation Layer with MVVM - Brent Edwards
Bindings, Generics, Swift and MVVM - Srdan Rasic
恭喜你很有耐心地看到了這裡(直接按End好像也會到這邊?),延續之前的好傳統(?),分享一下個人近期喜愛的好電影!
最近看了一部電影,American History X,是1998年的電影,主角是後來演了轟動世界的Fighting Club的Edward Norton。故事是在描述一個具有種族主義的天才主角,在入獄前後,跟家人還有社區的互動跟心路歷程。導演巧妙地利用一長一短的雙時間軸,深刻地描繪了哥哥(3到5年)跟弟弟(一個晚上)的互動與成長。這部電影直接把美國的種族主義問題,毫不掩飾地搬上檯面,逼觀眾加入這個戰局,好好思考這樣反智的行為是怎樣產生的,還有他們的歷史脈絡。喜歡社會思考的電影人千萬不要錯過,尤其這樣赤裸地講出種族主義問題的電影真的不多,加上Edward Norton非常完美的演出,真的是一部經典好電影。
*
說明得很清楚,thank you
thank your tour
請問一下
現在的 photos 裡面有一個 swift observer “didSet” 去 trigger tableView.reloadData()
如果這邊要刪除其中一個 photo 並且要用 tableView.delete(rows: [indexPath], animation: .right) 這個method
請問這個 didSet 裡面的 reloadData() 是不是只能拿掉惹 QQ
不拿掉會 crash 因為資料跟 tableView row 數量不一致,但實在是不太想拿掉,要在很多地方加上 reloadData 實在很阿匝
hello, 如果是刪掉的話,應該是把刪除的動作先傳回data source,讓data source 先把資料拿掉,再觸發tableView.delete(rows: [indexPath], animation: .right)。分工的方式可以參考https://www.appcoda.com.tw/tableview-mvvm/喔!
不太懂呢,這樣不會導致目前的資料跟 tableView cell 的數目不一致導致 fatal error 嗎
應該說,關鍵在tableview的datasouce要先改變,再觸發self.cellViewModels = vms。而且理想上不管是新增或刪除,更新tableView應該都是透過一致的介面(在這邊是cellViewModels的修改)會比較好