iOS SWIFT MVVM observable

簡易實作MVVM範例

Kin 2019/11/11 17:36:13
16769

目前大多開發者或多或少都有聽過MVVM design pattern,但是要去練習實作卻往往因各種原因而往後推延,

例如:

    心想:當前專案有點火燒屁股了,之後再來找相關教學文;

    當找到一篇關於MVVM的教學文,快速看完後不時發出:哦~MVVM原來長這樣啊,先存入書籤等之後有時間再來好好研究一下吧!;

    又或是看過說明後,某天旁邊同事問起:欸,你知道MVVM嗎?

    我:知道啊,不就原來的MVC(Model View Controller),變成MVVM(Model View ViewModel)而已嗎!

    同事:那實際要怎麼實作,

    我:嗯...,上次我有找到一篇大概看過一下,連結給你你也研究一下吧!

    然後⋯⋯⋯

 

    就沒然後了。

 

緣由:

簡單講述緣由,初出茅廬的開發者們,手邊的design pattern通常只有MVC這張牌可以打,當接觸的專案逐漸擴大時,往往MVC架構便會使得ViewController程式碼日漸增長而演變成(Massive View Controller),儘管多用extension或是各種命名的Manager去拆分ViewController的工作,但始終脫離不了肥大的命運。

當最後該拆都拆了不該拆的(機車坐墊)也拆了,臃腫的程式碼依然無動於衷,你或許會思考著⋯⋯

或者,你可以考慮拾起MVVM這張牌並琢磨一下這架構。

 

切入主題:

我們先準備好一份會去請求apple music  api的專案方便後續跟code,GitHub連結

開啟專案後看一下專案目錄

目前專案的架構為MVC,而我們的APP實際畫面如下圖

  

 

著手改造:

在MVC中,我們以往的概念會是, Model就是Model,View就是View,Controller就是Controller (這什麼廢話...)

Model用來裝著接下來會使用到的資料,

View呈現畫面,

Controller處理邏輯、接受Notification、請求資料、叫view更新、整理response、各種委託及實作,諸如上述這些工作或者更多,

而此處指的Controller其實就是專案裡的ViewController,也就是說這些事情都會寫在裏頭。

 

那MVVM呢,

Model及View的功用同上不贅述,

ViewModel 處理邏輯、接受Notification、請求資料、叫view更新、整理response⋯⋯

看到這裡不禁覺得這不是跟Controller做的事情差不多只有改名字而已嗎?

相信我,我第一眼看到的印象也跟您一樣,(這不就跟市面上許多手遊一樣只是換個名稱換個皮又可以出來噱一波的概念嗎?)

但正所謂我們不能總是以貌取人,這裡以幾張簡單的圖展示一下MVC及MVVM他們實質上的不同:

MVC: 

MVVM: 

 

MVC雖如圖所示是Controller有update時告訴Model要更新,經由Model通知controller告知view要更新,圖面上看起來好像沒什麼問題,切得乾乾淨淨都是經由Controller作為中繼站聯繫著View與Model,但程式碼裡頭的情況;

因為ViewController代表了兩種身份,它同時是View也是Controller,因此以往我們在建立一個tableView的datasource時我們很習慣的固定起手式就會是:

 

// in ViewController.swift
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
  return musicHandlers.count
}

當controller要替cell代入資料更新畫面時就會是,

// in ViewController.swift 
// in method tableViewCellForRowAt
guard let cell = tableView.dequeueReusableCell(withIdentifier: "ListCell", for: indexPath) as? ListCell else { return UITableViewCell() }
        
let handler = musicHandlers[indexPath.row]
cell.titleLabel.text = handler.collectionName
cell.descriptionLabel.text = handler.name
getImage(string: handler.imageUrl, at: indexPath, cell: cell)
        
return cell

 

看到這裡突然心想:Apple粑粑您圖上的view跟model都是透過controller在傳遞而不會互相接觸才是呀@@

這不就變成只有View跟Model在互相往來,只是他們都住在ViewController這間宿舍裡,看起來就像是不分男女的大學宿舍,這樣不是很美好嗎XD

咳咳⋯⋯⋯⋯⋯⋯

這樣子可能並不太好(尤其咱們台灣是民風比較純樸的國家),若目前是在View上做編輯的話這時因雙方是直接連接的狀態下可能就會一不小心就改動到原本的資料,因此,我們需要一位真正的中間人能將View與Model確實隔離,如每棟宿舍前都會有位舍監負責管理男女學生的進出,這位如同舍監般掌管著天堂之門與地獄深淵之間橋樑的角色便是我們接下來要提的ViewModel。

在建立ViewModel前需要先清查一下在ViewController之中的變數成員們及方法:

// in ViewController.swift
@IBOutlet weak var searchBar: UISearchBar!
@IBOutlet weak var listTableView: UITableView!
    
let service = RequestCommunicator<DownloadMusic>()
var musicHandlers: [MusicHandler] = []
let downloadImageQueue = OperationQueue()
// in ViewController.swift

func initView() { ... }
func prepareRequest(with name: String) { ... }
func getImage(string: String, at indexPath: IndexPath, cell: UITableViewCell) { ... }

我們需要將整個ViewController作為View使用,因此不該位於View的成員變數就有 servicemusicHandlersdownloadImageQueue,方法則是  prepareRquest() ,  getImage() ,另一種說明是View本身不需要知道這些變數及方法的存在只應保持著自身何時被作動及何時該更新畫面即可,其餘的應當搬到ViewModel裡。

Ok,到這裡我們就來建立ViewModel吧!

我們一次建立兩個ViewModel,分別是ViewControllerViewModel以及 ListCellViewModel,我們會希望cell能夠自己處理自己應當更新顯示的部分,所以cell也要有一個自己的ViewModel。

先建立ListCellViewModel.swift如下

 

// in ListCellViewModel.swift
class ListCellViewModel {
  var title: String
  var description: String
  var imageUrlString: String

}

根據tableViewCell,我們可以知道cell有titleLabeldescriptionLabelalbumImageView需要提供資料,因此我們先定義好這些變數成員。

接著建立ViewControllerViewModel.swift

// in ViewControllerViewModel.swift
class ViewControllerViewModel {
    
let service = RequestCommunicator<DownloadMusic>()
var musicHandlers: [MusicHandler] = []    
var listCellViewModels: [ListCellViewModel] = []
       
}

這邊servicemusicHandlers直接從ViewController搬來,而listCellViewModels是給tableViewCell使用的,定義在這的概念就好比ViewController裡頭 tableView的cells這樣的上下層級,也方便我們後續使用上的區分。

對於 prepareRquest() ,  getImage() 這兩個方法,因裡頭都有直接引用View的成員,用到的時間點分別是在請求完成以及下載圖片,因此我們需要給ViewModel再新增一個變數作為出口來通知View可以更新了!這邊 getImage() 取得的圖片是給cell使用,所以這個方法會留在ListCellViewModel去做處理,我們先處理ViewControllerViewModel!

我們ViewModel新增了這些程式碼:

// in ViewControllerViewModel.swift
var onRequestEnd: (() -> Void)?

private func prepareRequest(with name: String) {
  service.request(type: .searchMusic(media: "music", entity: "song", term: name)) { [weak self] (result) in
    switch result {
    case .success(let response):
      guard let musicHandelr = MusicHandler.updateSearchResults(response.data, section: 0),
            let requestEnd = self?.onRequestEnd else  { return }
      self?.musicHandlers.append(contentsOf: musicHandelr)
      self?.convertMusicToViewModel(musics: musicHandelr)
                
    case .failure(let error):
      print("Network error: \(error.localizedDescription)")
    }
  }
}

private func convertMusicToViewModel(musics: [MusicHandler]) {
  for music in musics {
    let listCellViewModel = ListCellViewModel(title: music.collectionName,
                                        description: music.name,
                                           imageUrl: music.imageUrl)
    listCellViewModels.append(listCellViewModel)
  }
   onRequestEnd?()
}

prepareRequest() 內多呼叫了一個方法 convertMusicToViewModel(),在取得伺服器回應的資料後就將其轉換成Cell可直接取用的ListCellViewModel,並且轉換完成後就呼叫onRequestEnd 這個closure來告知ViewController可以更新畫面了。

接著我們到ListCellViewModel.swift增加一些程式碼,

class ListCellViewModel {
    
  var title: String
  var description: String
  var imageUrlString: String
  
  // operations
  private let downloadImageQueue = OperationQueue()
  
  var onImageDownloaded: ((UIImage?) -> Void)?
  
  init(title: String, description: String, imageUrl: String) {
    self.title = title
    self.description = description
    self.imageUrlString = imageUrl
  }
  
  func getImage() {
      
    guard let url = URL(string: imageUrlString) else { return }
    downloadImageQueue.addOperation { [weak self] in
       do {
           let data = try Data(contentsOf: url)
           let image = UIImage(data: data)
           guard let imageDownloaded = self?.onImageDownloaded else { return }
           imageDownloaded(image)
        
       } catch let error {
           printLog(logs: [error.localizedDescription], title: "Get Image Error-")
       }
    }
  } 
}

直接將downloadImageQueue以及方法 getImage() 直接搬過來,並且跟剛剛請求完成的closure一樣,需要新增一個closure變數成員的 onImageDownloaded,以便後續當圖片下載完成時可以通知cell做畫面更新。

到這邊我們的ViewModels也大致準備完成,接下來就可以到ViewController做些更改並且將請求完成與ViewModel建立起連結。

回到ViewController就可以將移植的變數成員以及方法直接移除,然後再另外寫一個銜接ViewModel的方法,完成後就會像是這樣:

// in ViewController.swift
@IBOutlet weak var searchBar: UISearchBar!
@IBOutlet weak var listTableView: UITableView!
    
let viewModel = ViewControllerViewModel()
    
override func viewDidLoad() {       
  super.viewDidLoad()
  // Do any additional setup after loading the view.
        
  initView()
  bindViewModel()
}
    
func initView() {
  searchBar.delegate = self
  listTableView.delegate = self
  listTableView.dataSource = self
  listTableView.separatorStyle = .none
        
}
    
func bindViewModel() {
  viewModel.onRequestEnd = { [weak self] in
    DispatchQueue.main.async {          
      self?.listTableView.reloadData()
    }
  }
 
 // 未來若有其他需要連結的,可在此繼續添加
 // ...
}

實作bindViewModel(),將剛剛建立好的接口closure在這裡去指定當完成時哪些view要更新或是畫面會做什麼動作。

刪除了一些程式碼後,是不是也出現了一些error,那就是我們接下來需要改的地方;

 

先看到UISearchBar的delegate實作這邊,我們先為ViewModel新增一個變數searchText,然後修改如下,

// in ViewControllerViewModel.swift
var searchText: String = "" {
  didSet {
    prepareRequest(with: searchText)
  }
}
// in ViewController.swift
// UISearchBar delegate method
func searchBarSearchButtonClicked(_ searchBar: UISearchBar) {
  viewModel.searchText = searchBar.text ?? ""
  searchBar.endEditing(true)
}

當searchBar按下搜尋時便會將自身的text傳給viewModel.searchText並且在didSet裡呼叫 prepareRequest(),而前面我們也有指定當請求完成時要對tableView做reloadData(),就完成了View產生事件並傳遞給ViewModel,ViewModel負責處理api請求,取得response後更新Model的資料並且通知View更新的流程。

接著繼續往下修改tableView dataSource裡的程式碼,

// in ViewController.swift 
// extension with UITableView delegate
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
  return viewModel.listCellViewModels.count
}
    
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    
  guard let cell = tableView.dequeueReusableCell(withIdentifier: "ListCell", for: indexPath) as? ListCell else { return UITableViewCell() }

  let listCellViewModel = viewModel.listCellViewModels[indexPath.row]
  cell.setup(viewModel: listCellViewModel)

  return cell
}

func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
  let listCellViewModel = viewModel.listCellViewModels[indexPath.row]
    
  convienceAlert(alert: "Tapped: \(listCellViewModel.title)",
          alertMessage: "music: \(listCellViewModel.description)",
               actions: ["確認"],
            completion: nil, actionCompletion: nil)
}

嗯,這邊就是將原來的musicHandlers(Model),改成由ViewModel來跟View溝通;

ListCell的實作內容則增加為,

// in ListCell.swift
@IBOutlet weak var albumImageView: UIImageView!
@IBOutlet weak var titleLabel: UILabel!
@IBOutlet weak var descriptionLabel: UILabel!

private var viewModel: ListCellViewModel?

override func awakeFromNib() {
  super.awakeFromNib()
  // Initialization code
}

override func prepareForReuse() {
  super.prepareForReuse()
  albumImageView.image = nil
  self.viewModel?.onImageDownloaded = nil
    
}

func setup(viewModel: ListCellViewModel) {
  self.viewModel = viewModel
    
  self.titleLabel.text = viewModel.title
  self.descriptionLabel.text = viewModel.description
    
  self.viewModel?.onImageDownloaded = { [weak self] image in
     DispatchQueue.main.async {
        self?.albumImageView.image = image       
    }
  }
  self.viewModel?.getImage()
}

setup() 裡將viewModel要顯示的資訊更新在cell上,而剛才在ListCellViewModel準備好的onImageDownloaded的closure就在這裡進行綁定,viewModel再進行下載圖片的動作。

需要注意的是,在 prepareForReuse() 的時候記得將onImageDownloaded指定到nil,避免當滾動時下方的cell被前面的closure影響了!

 

OK,我們的MVVM介紹到這邊就算全部完成囉!

趕快來build一下,是不是出現跟原來一樣的畫面吧。

這邊老樣子提供一下完整修改後的程式碼,請點這裡下載喔!

這邊感謝各位的觀賞,這篇介紹本人也是近幾日稍微研究後想說拿來火力展示一下的,一方面也是當作練習看看自己是否掌握了整體架構的觀念,那不管是從未學過的朋友還是已經用到滾瓜爛熟想說來電電小弟,從程式碼中找到不合理地方的大大們,或是認為說明不清楚還是帶錯風向的,都歡迎給予建議、建言,感謝大家!

 

 

最後同場加映~ 什麼?帶有RxSwift特性的MVVM!!

很抱歉這份專案不帶任何Cocoapods上的任何套件,當然想踩一下RxSwift的坑的也歡迎自行上網找尋相關資源囉!

在這裡我們只是模擬出一個非常簡單、也非常簡陋的RxSwift裡頭的觀察物件;

先新增一個ObservableCore的file,裡頭的程式碼:

 

// in ObservableCore.swift
class Observable<T> {
  typealias ValueChanged = ((T?) -> Void)

  public private(set) var value: T?
  private var valueChanged: ValueChanged?

  init(_ value: T? = nil) {
    self.value = value
  }

  /// bind valueChanged event
  @discardableResult
  func binding(valueChanged: ValueChanged?) -> Self {
    self.valueChanged = valueChanged
    if let value = value {
      onNext(value)
    }
    return self
  }

  /// pass new value to trigger changed
  func onNext(_ value: T? = nil) {
    self.value = value
    valueChanged?(value)
  }
}

這邊是打造一個Observable的class其目的是為了能夠將任何值以這個物件包起,因此這邊用上泛型(generic),並且在裡頭實作了binding()以及onNext()的方法,作為View與ViewModel之間綁定及傳值的動作;

首先,定義一個類別別名 ValueChanged 的 closure, closure帶有一個泛型的參數,並且宣告其變數成員valueChanged

變數成員 value: T? ,用以供外部引用的屬性。

init(_ value: T? = nil) ,在初始化時就將其本來屬性的值指定給自己的value變數成員

func binding(valueChanged: ValueChanged?) -> Self ,用以取代我們前面建立的onRequestEnd/onImageDownloaded等closure變數。

func onNext(_ value: T? = nil) ,當有新值要賦予給當前的Observable時,就呼叫這個方法並且將新值得代入參數,裡面指定完自身的value以後,會隨即呼叫valueChanged的closure並把新賦予的值傳遞下去。

當然提醒一下,千萬別直接拿著這個Observable類別去實戰當前工作上的專案⋯⋯這只適用於目前練習用的專案,客官們可以自行再擴充一些功能或是修改成較合適使用的模樣。(當然Apple粑粑在2019年提出Combine的API供使用,但是版本就必須限定在iOS13 以上,有興趣的可以去瞧瞧,基本上概念差不多,只是人家是幾十個人的團隊寫的功能肯定很完善!)

接下來我們來改動一下原來的程式碼吧!

 

目前專案因為只有請求完成這個時間點所以我們可以直接定義一個closure變數去做事,但當今天要是有好幾個事件會發生的話,我們的viewModel裡頭可能就會有10幾個closure的變數成員,屆時viewModel整體看起來會很雜亂,因此接下來我們可以建立一個Events及Outputs的struct來幫我們整理那些可能散亂四處的各種事件closure吧!

我們先在ViewControllerViewModel宣告兩個struct,

// in ViewControllerViewModel.swift
// class ViewControllerViewModel {

struct Events {
  var onSearchMusics: Observable<String>
  var onRequestEnd: Observable<Void>
  var onRequestFail: Observable<Error>
  // maybe have more events
  // ...
}

struct Outputs {
   
  var listCellViewModels: [ListCellViewModel]
    
  // maybe have more outputs
  // ...
}

public private(set) var events: Events?
public private(set) var outputs: Outputs?
// }

這裡我將searchText更名為onSearchMusics並宣告在Events裡,當searchBar點擊後便會給予新值到onSearchMusics,對於ViewModel就像是注入了一個新的值促使其執行prepareRequest(),並且執行完以後由onRequestEnd進行通知,而onRequestFail負責將我們request產生的error帶出。

然後再替ViewModel多宣告了兩個外部唯讀的變數成員  eventsoutputs,之後我們的操作就會統一由這兩個小夥伴幫忙管理;

接著將原先的變數成員註解或是直接拿掉,viewModel會出錯,但我們接下來就是要處理這部分,改動如下:

// in ViewControllerViewModel.swift 

private func prepareRequest(with name: String) {
  service.request(type: .searchMusic(media: "music", entity: "song", term: name)) { [weak self] (result) in
    switch result {
    case .success(let response):
        guard let musicHandelr = MusicHandler.updateSearchResults(response.data, section: 0) else  { return }
        self?.musicHandlers.append(contentsOf: musicHandelr)
        self?.convertMusicToViewModel(musics: musicHandelr)
        
    case .failure(let error):
        self?.events?.onRequestFail.onNext(error)
       
    }
  }
}

private func convertMusicToViewModel(musics: [MusicHandler]) {
  for music in musics {      
    let listCellViewModel = ListCellViewModel(title: music.collectionName,
                                        description: music.name,
                                           imageUrl: music.imageUrl)
    
    outputs?.listCellViewModels.append(listCellViewModel)
  }

  events?.onRequestEnd.onNext()
}

這邊很簡單就是加個outputsevents在變數前並順手將request fail的情況送出一個onRequestFail將error帶出去;

接下來我們建立一個protocol來替我們統一與ViewController進行綁定的動作,以及替ViewModel宣告一個delegate的變數成員

// in ViewControllerViewModel.swift
protocol ViewModelDelegate: class {
  func binding() -> ViewControllerViewModel.Events
}
// in ViewControllerViewModel.swift
// in class 
weak var delegate: ViewModelDelegate?

接著增加初始化方法,我們會在初始化時候就順便將delegate指定並且將eventsoutputs給建立完成;

// in ViewControllerViewModel.swift
init(delegate: ViewModelDelegate) {
  self.delegate = delegate
  events = delegate.binding()
  outputs = Outputs(listCellViewModels: [])

  events?.onSearchMusics.binding(valueChanged: { [weak self] (value) in
  guard let text = value else { return }
  self?.prepareRequest(with: text)
  })
}

events就如同剛說的,希望由delegate幫我們做統一,因此直接指定delegate.binding()回傳值給events,這裏outputs就只是放外部需要用到的model因此直接初始化即可,接下來我們在初始化時對於onSearchMusics進行綁定,由於點擊SearchBar的事件是由ViewController那邊給予的,所以我們在這裡進行綁定接收searchBar.text變化並且執行請求(應該沒忘記我們註解了searchText原來還有個didSet{}來進行prepareRequest()的這件事情吧!),ViewModel的改造算是完成了,接著我們也要來修改一下ViewController。

由於ViewModel宣告初始化函式,這裏將viewModel的變數成員修改一下,並且新增一個成員變數searchText

// in ViewController.swift
lazy var viewModel: ViewControllerViewModel = {
  return ViewControllerViewModel(delegate: self)
}()

var searchTextChanged: Observable<String> = Observable()

這裏searchTextChanged宣告成全域變數的用處主要是作為中繼的綁定對象使用,因為我們無法直接對於UISearchBar的text屬性得知valueChanged的時間點(可以自行對UISearchBar進行extension只是有點耗工,而且還要對每個用到的UI元件都做一次),但為了讓接下來要實作的ViewModelDelegate裡的回傳值Events.onSearchMusics有綁定對象,又能夠在UISearchBar的delegate方法裡使searchTextChanged拋出下一個valueChange的事件,所以這樣宣告。

接著實作ViewModelDelegate.binding()的內容:

// in ViewController.swift
extension ViewController: ViewModelDelegate {
  func binding() -> ViewControllerViewModel.Events {
    // prepare bind observable
    let requestEnd = Observable<Void>().binding { [weak self] _ in
      DispatchQueue.main.async {
        self?.listTableView.reloadData()
      }
    }
    
    let requestFail = Observable<Error>().binding { [weak self] (error) in
      DispatchQueue.main.async {
        self?.convienceAlert(alert: "Search error", alertMessage: error?.localizedDescription, actions: ["確認"], completion: nil, actionCompletion: nil)
      }
    }
    
    return ViewControllerViewModel.Events(onSearchMusics: searchTextChanged,
                                            onRequestEnd: requestEnd,
                                           onRequestFail: requestFail)
  }
}

我們宣告了兩個變數作為監聽rquestEnd/Fail並實作了當事件通知時執行listTableView.reloadData()convienceAlert()等動作,最後將變數回傳給delegate(還記得剛剛在ViewModel.init()裡頭我們指定events = delegate.binding() 這件事吧!)。

當這邊的綁定完成後,就可以將原先的bindViewModel()直接拿掉。

最後修改一下UISearchBar delegate及UITableViewDatasource的內容:

// in ViewController.swift
extension ViewController: UISearchBarDelegate {
  func searchBarSearchButtonClicked(_ searchBar: UISearchBar) {
    searchTextChanged.onNext(searchBar.text)
    searchBar.endEditing(true)
  }
}
// in ViewController.swift
extension ViewController: UITableViewDelegate, UITableViewDataSource {
    
  func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
    return viewModel.outputs?.listCellViewModels.count ?? 0
  }

  func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    
    guard let cell = tableView.dequeueReusableCell(withIdentifier: "ListCell", for: indexPath) as? ListCell else { return UITableViewCell() }
    
    if let listCellViewModel = viewModel.outputs?.listCellViewModels[indexPath.row] {
      cell.setup(viewModel: listCellViewModel)
    }
    
    return cell
  }

  func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
    guard let listCellViewModel = viewModel.outputs?.listCellViewModels[indexPath.row] else { return }
    
    convienceAlert(alert: "Tapped: \(listCellViewModel.title)",
            alertMessage: "music: \(listCellViewModel.description)",
                 actions: ["確認"],
              completion: nil, actionCompletion: nil)
  }
    
}

build起來吧!app看起來應該還是跟原來一樣,但是卻這麼耗工的改成這種模式XD

但,別高興得太早,還有CellViewModel要修改⋯⋯

CellViewModel的修改跟ViewModel差異不大,這邊就直接上程式碼吧!

// in ListCellViewModel.swift
class ListCellViewModel {
    
  struct Events {
    var onImageDownloaded: Observable<UIImage>
    var onTitleChanged: Observable<String>
    var onDescriptionChanged: Observable<String>
    var onRequestFail: Observable<Error>?
  }

  private var musicHandler: MusicHandler
  var events: Events

  // operations
  private let downloadImageQueue = OperationQueue()

  init(_ model: MusicHandler, observerFail: Observable<Error>? = nil) {
    self.musicHandler = model
    
    events = Events(onImageDownloaded: Observable(),
                       onTitleChanged: Observable(musicHandler.collectionName),
                 onDescriptionChanged: Observable(musicHandler.name),
                        onRequestFail: observerFail)
    
    getImage(urlString: musicHandler.imageUrl)
  }

  func getImage(urlString: String) {
    
    guard let url = URL(string: urlString) else { return }
    downloadImageQueue.addOperation { [weak self] in
      do {
        let data = try Data(contentsOf: url)
        let image = UIImage(data: data)
        self?.events.onImageDownloaded.onNext(image)
        
      } catch let error {
        self?.events.onRequestFail?.onNext(error)
      }
    }
  }

  func clearOnReuse() {
    events.onTitleChanged.binding(valueChanged: nil)
    events.onImageDownloaded.binding(valueChanged: nil)
    events.onDescriptionChanged.binding(valueChanged: nil)
    events.onRequestFail?.binding(valueChanged: nil)
  }
    
}

一樣用Events幫忙管理各種事件,初始函式改成直接傳遞musicHandler方便使用,多了個observerFail的參數要帶入,observerFail用意就是將ListCellViewModel在downloadImage時產生的Error經由ViewModel的onRequestFail帶給ViewController做呈現。

clearOnReuse(),還記得我們原先以clusure實作時也會在cell.prepareForReuse()時將它指定成nil,這邊意思相同。

因為ListCellViewModel有變動再記得至View

// in ViewController.swift
// extension of UITableView delegate
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
guard let listCellViewModel = viewModel.outputs?.listCellViewModels[indexPath.row] else { return }

convienceAlert(alert: "Tapped: \(listCellViewModel.events.onTitleChanged.value ?? "")",
        alertMessage: "music: \(listCellViewModel.events.onDescriptionChanged.value ?? "")",
             actions: ["確認"],
          completion: nil, actionCompletion: nil)
}

最後再到ListCell進行修改,這裏我就只貼改動的部分:

// in ListCell
override func prepareForReuse() {
  super.prepareForReuse()
  albumImageView.image = nil
  viewModel?.clearOnReuse()
    
}

func setup(viewModel: ListCellViewModel) {
  self.viewModel = viewModel

  viewModel.events.onTitleChanged.binding { [weak self] value in
    DispatchQueue.main.async {
      self?.titleLabel.text = value
    }
  }

  viewModel.events.onDescriptionChanged.binding { [weak self] value in
    DispatchQueue.main.async {
      self?.descriptionLabel.text = value
    }
  }

  viewModel.events.onImageDownloaded.binding { [weak self] value in
    DispatchQueue.main.async {
      self?.albumImageView.image = value
    }
  }
   
}
到這邊修改成有Observable特性的MVVM也算是告一段落了!
 
最後的最後,可能還是有人看完教學文以後當日後想自己在著手寫起時看著自己的程式碼遲遲下不了手,這邊就提供一些比較簡單的流程參考:
1. 先查看ViewController裡與View無關的變數成員及方法,剪下貼過去就對了!
2. 關於任何動作的發生時機給他一個closure,方法內需調用View進行更新的動作由closure送出。
3. Cell類別請都給他一個ViewModel,若是要全部由ViewController的ViewModel來管理也沒關係的!
4. 原先有一些資料索取的delegate&dataSource呢? 在ViewController現場我們只留關於UI的建構過程。
5. 保持View、ViewModel、Model單向通道,也就是說通知View更新的只會是ViewModel,View接收開始編輯資料一定丟給ViewModel處理,別讓View直接對接Model取資料,非不得已也請加上外部唯讀的關鍵字給該屬性。
 
感謝收看!
 

參考文獻:

https://zh.wikipedia.org/wiki/MVVM

https://koromiko1104.wordpress.com/2017/10/05/mvvmapp/

https://www.appcoda.com.tw/tableview-mvvm/

https://codeburst.io/swift-mvvm-two-way-binding-win-b447edc55ff5

 

=========================================================================================

一些不太重要的圖片引用連結:

西遊記

侏羅紀公園

Apple MVC

MVVM-WIKI

 

Kin