[譯]Bindings, Generics, Swift and MVVM

逐水而上發表於2018-04-19

本文是譯文。原文連結

[譯]Bindings, Generics, Swift and MVVM

上一篇文章我已經介紹了MVVM設計模式作為一種對MVC的發展,但是最終我提出的解決方案只覆蓋了特定的場景----不可變的modelviewmodel。為了覆蓋剩餘的場景,我們需要可變的viewmodel來把變化傳遞給views或者是viewcontrollers

這篇文章我將通過使用Swift泛型和閉包來實現觀察模式,從而展示簡單的繫結機制。

基本資料型別物件和原始型別的值都沒有給我們提供觀察他們改變的方法。為了這麼做我們必須控制它們的setter(或者設定它們的方式),在那裡通知那些對它感興趣的物件。很幸運,Swift足夠機智,不允許那樣做。擁有那種程度的自由度將會快速地導致出錯。然而建立我們自己的資料型別並且以我們希望的方式定製它是可行的。我們可以讓它們包含基礎資料和原始型別。這將會使得修改被包含型別的值時需要通過我們的型別的介面,那就是我們可以做一些滿足我們需求的事情的地方。讓我們以一個基本的String型別嘗試做一下。我們把這個新型別叫做DynamicString

class DynamicString {
  var value: String {
    didSet {
      println("did set value to \(value)")
    }
  }
  
  init(_ v: String) {
    value = v
  }
}複製程式碼

我們給value的屬性觀察器附上了一些程式碼。下面是一個它怎麼工作的例子:

let name = DynamicString("Steve")   // does not print anything
println(name.value)  // prints: Steve
name.value = "Tim"   // prints: did set value to Tim
println(name.value)  // prints: Tim複製程式碼

如你所見,給value賦新值觸發了它的屬性觀察,列印了那個值。這就是我們身披銀甲所向披靡的騎士。改變發生時我們將會使用屬性觀察通知感興趣的團體,讓我們把它叫做listenerslisteners是什麼呢?在我們上篇文章MVVM的例子裡它是viewcontrollerviewcontrollerviewmodel的改變感興趣,這樣它能夠對自己包含的檢視進行對應的更新。但是我們想要從每個我們建立的自定義string物件引用viewcontroler嗎?我不希望這樣。也許我們可以建立一個listener協議,讓每個listener來遵從它。這行不通----listeners可能想要監聽許多其他物件(屬性)。我們需要另一個騎士。swift就有,它叫閉包(Object-C裡叫block,其他語言裡叫lambda)。我們可以把listener定義為一個接受String型別引數的閉包。

class DynamicString {
  typealias Listener = String -> Void
  var listener: Listener?

  func bind(listener: Listener?) {
    self.listener = listener
  }

  var value: String {
    didSet {
      listener?(value)
    }
  }
  
  init(_ v: String) {
    value = v
  }
}複製程式碼

我們用typealias命令生成了一個新的型別,Listener,它是一個接受String型別引數並沒有返回值得閉包。宣告瞭一個Listener型別的屬性,它是可選型別,因此並不是必須設定的(我們的DynamicString型別並不是必須有一個收聽者)。然後我們給listener創造了一個setter,只是為了讓語法更漂亮些。最後我們修改了屬性觀察器,當新值被設定時呼叫那個listener閉包。就是這了,讓我們看看例子:

let name = DynamicString("Steve")

name.bind({
  value in
  println(value)
})

name.value = "Tim"  // prints: Tim
name.value = "Groot" // prints: Groot複製程式碼

這樣,每次我們給DynamicString物件設定新值的時候,listener被觸發,列印了那個值。注意一下我們的繫結語法看起來並不太好。很幸運,Swift充滿了語法糖,其中有兩個可以幫助我們。第一個,如果一個函式的最後一個引數是一個閉包,這個閉包表示式可以被定義在圓括號呼叫引數之後。這樣的閉包叫做尾隨閉包。另外,如果函式只有一個引數,那麼圓括號可以完全省略。第二個語法糖是,Swift自動給內聯閉包提供縮寫的引數名,可以用$0,$1,$2這樣的名稱來引用閉包的引數值。利用這些知識我們得到了這個:

name.bind {
  println($0)
}複製程式碼

這樣更漂亮些!我們可以隨意實現listener的閉包。除了列印新值,我們可以讓它更新label的文字。

let name = DynamicString("Steve")
let nameLabel = UILabel()

name.bind {
  nameLabel.text = $0
}

println(nameLabel.text)  // prints: nil

name.value = "Tim"
println(nameLabel.text)  // prints: Tim

name.value = "Groot"
println(nameLabel.text)  // prints: Groot複製程式碼

如你所見,每次name值改變的時候,label的文字都會更新,但是第一次呢?Steve去哪了?我們不應該忘記他。如果你思考一小會兒,你就會注意到bind方法只是設定了收聽者,但是並沒有觸發它。我們可以實現另一個方法來實現它。我們把它稱作bindAndFire

class DynamicString {
  ...
  func bindAndFire(listener: Listener?) {
    self.listener = listener
    listener?(value)
  }
  ...
}複製程式碼

如果我們用這個方法來修改我們的例子,我們就把Steve找回來了。

...
name.bindAndFire {
  nameLabel.text = $0
}

println(nameLabel.text)  // prints: Steve
...複製程式碼

很棒啊,我們走過了很長一大段路。我們引入了一個新的string型別,它允許我們給它繫結一個收聽者來觀察值的變化,我們已經展示了它如何執行指定的動作例如更新label文字。

但是String型別並不是我們要使用的唯一一種型別,因此讓我們繼續用這個方法來擴充套件到其他型別。我們可以建立一些相似的類,對於Integer...嗯...然後是 Float, Double and Boolean?那麼還有NSDate, UIView or dispatch_queue_t?這些似乎相當痛苦啊……的確,如果就這麼做我們會瘋掉的!

相反,我們將請出Swift最強大的特性之一----泛型。它讓我們能夠寫出靈活的可複用的函式和型別,它們可以運用於任何型別。如果你不熟悉泛型,就開啟這個連結Generics去摟一眼吧。然後我們會把DynamicString型別重寫為Dynamic這個泛型。

看起來大概這個樣子:

class Dynamic<T> {
  typealias Listener = T -> Void
  var listener: Listener?
  
  func bind(listener: Listener?) {
    self.listener = listener
  }
  
  func bindAndFire(listener: Listener?) {
    self.listener = listener
    listener?(value)
  }

  var value: T {
    didSet {
      listener?(value)
    }
  }
  
  init(_ v: T) {
    value = v
  }
}複製程式碼

我們把DynamicString類重新命名為Dynamic,通過在類名後面新增<T>把它標記為一個泛型類並且把所有的String型別名改為T。現在我們的Dynamic型別可以包括所有其他型別,並且給它擴充套件了收聽者機制。

這裡是一些?:

let name = Dynamic<String>("Steve")
let alive = Dynamic<Bool>(false)
let products = Dynamic<[String]>(["Macintosh", "iPod", "iPhone"])複製程式碼

吃不吃驚。意不意外。它可以變得更好。Swift編譯器如此強大,它可以從函式的(這個例子裡是構造器的)引數推斷型別,因此,只要寫成這樣就行了:

let name = Dynamic("Steve")
let alive = Dynamic(false)
let products = Dynamic(["Macintosh", "iPod", "iPhone"])複製程式碼

繫結照常執行。收聽者閉包裡的引數型別就是泛型列表裡指定的那一個(或者列表忽略時編譯器推斷出來)。例如:

products.bindAndFire {
  println("First product is \($0.first)")
}複製程式碼

這就是我們的繫結機制。很簡單,也很強大。它適用於任何型別,你可以繫結任何你需要的邏輯。並且你不需要經歷註冊和登出監聽的痛苦。你僅僅是繫結一個閉包。然而還是有個限制----你只能有一個收聽者。對於我們的MVVM例子和大多數情況來說這已經足夠了,但是你需要通過改進這個想法比如擁有一個收聽者陣列來支援多個收聽者嗎----這是可行的但是可能引入其他的一些後果。

最後,讓我們修復上一篇中的MVVM例子讓縮圖能夠傳遞給imageView。我們可以重新定義viewmodel協議,讓它的屬性支援繫結,也就是說Dynamic。我們可以把它們都這樣做,展示一下是怎麼完成的。

protocol ArticleViewViewModel {
  var title: Dynamic<String> { get }
  var body: Dynamic<String> { get }
  var date: Dynamic<String> { get }
  var thumbnail: Dynamic<UIImage?> { get }
}複製程式碼

記著那裡的可選型別。被包裹的型別是可選的,而不是Dynamic這個包裹!下面我們繼續修改viewmodel

class ArticleViewViewModelFromArticle: ArticleViewViewModel {
  let article: Article
  let title: Dynamic<String>
  let body: Dynamic<String>
  let date: Dynamic<String>
  let thumbnail: Dynamic<UIImage?>
  
  init(_ article: Article) {
    self.article = article
    
    self.title = Dynamic(article.title)
    self.body = Dynamic(article.body)
    
    let dateFormatter = NSDateFormatter()
    dateFormatter.dateStyle = NSDateFormatterStyle.ShortStyle
    self.date = Dynamic(dateFormatter.stringFromDate(article.date))
    
    self.thumbnail = Dynamic(nil)
    
    let downloadTask = NSURLSession.sharedSession()
                                   .downloadTaskWithURL(article.thumbnail) {
      [weak self] location, response, error in
      if let data = NSData(contentsOfURL: location) {
        if let image = UIImage(data: data) {
          self?.thumbnail.value = image
        }
      }
    }
    
    downloadTask.resume()
  }
}複製程式碼

這應該是很直觀的,但是請注意一些事情。所有的屬性仍然是常量(定義為let)。這很重要,因為我們一旦我們給它們賦一次值,就不能改變。Dynamic的值改變時,收聽者會收到通知,但是並不是Dynamic它自己改變的時候。這意味著我們必須在構造器裡(初始化方法)初始化它們所有。這裡有一條黃金法則:那些不能再構造器裡初始化為真實值的Dynamics必須包裹可選型別。就像thumbnail包裹了可選的UIImage。在這種情況下,我們用nil來初始化Dynamic,然後當真實值或者新值可用時再更新它----比如當thumbnail下載完成的時候。

接下來要做的就是在viewcontroller裡繫結所有的屬性:

class ArticleViewController {
  var bodyTextView: UITextView
  var titleLabel: UILabel
  var dateLabel: UILabel
  var thumbnailImageView: UIImageView
  
  var viewModel: ArticleViewViewModel {
    didSet {
      viewModel.title.bindAndFire {
        [unowned self] in
        self.titleLabel.text = $0
      }
      
      viewModel.body.bindAndFire {
        [unowned self] in
        self.bodyTextView.text = $0
      }
      
      viewModel.date.bindAndFire {
        [unowned self] in
        self.dateLabel.text = $0
      }
      
      viewModel.thumbnail.bindAndFire {
        [unowned self] in
        self.thumbnailImageView.image = $0
      }
    }
  }
}複製程式碼

就是這樣!我們的檢視會反射任何viewmodel的變化。注意使用閉包時不要迴圈引用。在閉包裡總是使用unowned或者weak self。我麼這裡使用unowned就行,因為viewcontrollerviewmodel的擁有者,viewmodel不會存活的比viewcontroller更長。



相關文章