歡迎來到真實世界 - 原來是那個傳說中的MVVM阿

Published Oct 05, 2017
歡迎來到真實世界 - 原來是那個傳說中的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則是負責商業邏輯。這三者的互動方式如下圖:

model_view_controller_2x.png

Controller同時擁有View跟Model,並且做為統整兩邊的橋樑的角色。但故事來到了iOS開發,因為View角色特殊的關係,原本的MVC變成了Model-ViewController+View:

MassiveVC.png

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。上面的概念可以畫成這樣的資料流:

MVVM-Basic.png

流程上,ViewModel會從Model取到資料,並且把資料整理好成為方便顯示的樣子,而View一看到ViewModel的資料有更新,就會跟著一起更新,這就是一個最單純的MVVM資料流。

在iOS開發上,依照上述MVVM的定義,UIViewController變成一個單純的View,而我們會另外產生一個ViewModel來負責presentational logic跟部份的controller logic。所以在你的ViewController裡面,就只會有:

  1. View logic,所有跟呈現有關的Code
  2. 綁定ViewModel

而在ViewModel裡面,則是負責兩個部份:

  1. Controller logic,如pagination, error handling,… etc
  2. Presentation logic,提供接口讓View綁定(binding)

開發上,一旦View綁定好ViewModel的資料,在撰寫商業邏輯的時候,就可以不用管包括動畫、轉場、main thread等等跟View相關的問題,因為分工明確所以就不會有寫起來綁手綁腳的感覺。更棒的是,並且因為ViewModel是一個單純的、沒有相依於View的物件,所以要做測試簡單多了!

giphy-downsized.gif

在文末我們會討論這個其實也身兼多職的ViewModel到底有甚麼問題,就讓我們繼續看下去~

A simple gallery app - MVC

接下來,我們要用一個簡單的例子,來讓大家了解怎麼從MVC轉換到MVVM。
這是一個簡單的App,具有下面兩個功能:

  1. 會從500px API抓取熱門相片,並且把相片排成列表show出來,每張相片都會顯示標題、描述、跟拍攝日期。
  2. 如果使用者點選了非賣品,app就不會讓使用者進到下一頁,並且跳出錯誤訊息。

請看以下示意圖:

giphy.gif

在這個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的定義,我們目前的任務就是:

  1. 把View跟ViewModel做綁定
  2. 把controller logic跟presentation logic從ViewController移到ViewModel

先看綁定的部份,在頁面上,我們的View上有幾個主要元件:

  1. activity Indicator (loading/finish)
  2. tableView (show/hide)
  3. cells (title, description, created date)

如果我們把這些元件的資料跟狀態整理出來,抽象化成為一些ViewModel的接口,就會變成像下圖這樣:

MVVM.001.png

所有的View的狀態,都有他們對應的ViewModel properties,並且每個cell也都有相對應的ViewModels,這樣就能夠確保View的長相就是我們在ViewModel上面看到的一樣。

那實作上我們要怎麼做綁定呢?

Implement the Binding with Closure

在Swift裡,要做到資料綁定,有幾種方法:

  1. 用ObjC的KVO pattern
  2. 使用FRP套件如RxSwift或ReactiveCocoa提供的binding功能
  3. 自己實作

使用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拿整理好的資料,更新自己並且顯現出來。完整的資料流會長得像下圖一樣:

MVVM.001-1.png

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的初始化階段,我們做兩件事情:

  1. 注入dependency - APIService
  2. 開始抓取資料
  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!我們終於完成了所有的綁定跟改寫!

giphy-downsized (2).gif

以上的程式,都可以在我的Github裡面找到。

Github - MVVMPlayground

其中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

Introduction to MVVM - objc

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好像也會到這邊?),延續之前的好傳統(?),分享一下個人近期喜愛的好電影!

MV5BMDNhMDNhODQtODdhMi00M2VjLWJjM2EtZGI0MDFiYzQ3MTU5XkEyXkFqcGdeQXVyODAwMTU1MTE@._V1_SY1000_CR0,0,678,1000_AL_.jpg

最近看了一部電影,American History X,是1998年的電影,主角是後來演了轟動世界的Fighting Club的Edward Norton。故事是在描述一個具有種族主義的天才主角,在入獄前後,跟家人還有社區的互動跟心路歷程。導演巧妙地利用一長一短的雙時間軸,深刻地描繪了哥哥(3到5年)跟弟弟(一個晚上)的互動與成長。這部電影直接把美國的種族主義問題,毫不掩飾地搬上檯面,逼觀眾加入這個戰局,好好思考這樣反智的行為是怎樣產生的,還有他們的歷史脈絡。喜歡社會思考的電影人千萬不要錯過,尤其這樣赤裸地講出種族主義問題的電影真的不多,加上Edward Norton非常完美的演出,真的是一部經典好電影。

*

Discover and read more posts from ShihTing Huang (Neo)
get started
Enjoy this post?

Leave a like and comment for ShihTing