[譯] 實用的 MVVM 和 RxSwift

今天咱們將使用 RxSwift 實現 MVVM 設計模式。對於那些剛接觸 RxSwift 的人,我 在這裏 專門作了一個部分來介紹。前端

若是你認爲 RxSwift 很難或使人十分困惑,請不要擔憂。它一開始看上去彷佛很難,但經過實例和實踐,就會將變得簡單易懂👍。android


在使用 RxSwift 實現 MVVM 設計模式時,咱們將在實際項目中檢驗此方案的全部優勢。咱們將開發一個簡單的應用程序,在 UICollectionView 和 UITableView 中顯示林肯公園(RIP Chester🙏)的專輯和歌曲列表。讓咱們開始吧!ios

App 主頁面git

UI 設置

子控制器

我但願在構建咱們的 app 時遵循可重用性原則。所以,咱們將會稍後在 app 的其餘部分中重用這些 view,從而來實現咱們的專輯的 CollectionView 和歌曲的 TableView。例如,假設咱們想要顯示每張專輯中的歌曲,或者咱們有一個部分用來顯示類似的專輯。若是咱們不但願每次都重寫這些部分,那最好去重用它們。github

那咱們該怎麼作呢? 你正好能夠嘗試一會兒控制器。 爲此,咱們使用 ContainerView 將 UIViewController 分爲兩部分:swift

  1. AlbumCollectionViewVC
  2. TrackTableViewVC

如今父控制器包含兩個子控制器(要了解子控制器,你能夠閱讀 這篇文章)。後端

如今咱們的 main ViewController 就變成了:設計模式

咱們爲 cell 使用 nib,這樣很容易就能夠重用它們。數組

要註冊 nib 的 cell,你應該將此代碼放在 AlbumCollectionViewVC 類的 viewDidLoad 方法中。這樣 UICollectionView 才能知道它正在使用 cell 的類型:服務器

// 爲 UICollectionView 註冊 'AlbumsCollectionViewCell'
albumsCollectionView.register(UINib(nibName: "AlbumsCollectionViewCell", bundle: nil), forCellWithReuseIdentifier: String(describing: AlbumsCollectionViewCell.self))
複製代碼

請看在 AlbumCollectionViewVC 中的這些代碼。這意味着父類對象暫時沒必要處理其子類。

對於 TrackTableViewVC,咱們執行相同的操做,不一樣之處在於它只是一個 tableView。如今咱們要去父類裏設置咱們的兩個子類。

正如你在 storyboard 中看到的那樣,子類所在的地方的是放置了兩個 viewController 的 view。這些 view 稱爲 ContainerView。咱們可使用如下代碼設置它們:

@IBOutlet weak var albumsVCView: UIView!

    private lazy var albumsViewController: AlbumsCollectionViewVC = {
        // 加載 Storyboard
        let storyboard = UIStoryboard(name: "Home", bundle: Bundle.main)

        // 實例化 View Controller
        var viewController = storyboard.instantiateViewController(withIdentifier: "AlbumsCollectionViewVC") as! AlbumsCollectionViewVC

        // 把 View Controller 做爲子控添加
        self.add(asChildViewController: viewController, to: albumsVCView)

        return viewController
    }()
複製代碼

View Model 設置

基礎 View Model 架構

如今咱們的 view 已經準備好了,咱們接下來須要 ViewModel 和 RxSwift:

在 HomeViewModel 類中,咱們應該從服務器獲取數據,併爲 view 須要展現的東西進行解析。而後 ViewModel 將它提供給父類,父控制器將這些數據傳遞給子控制器。這意味着父類從其 ViewModel 請求數據,而且 ViewModel 先發送網絡請求,再解析數據並傳給父類。

下圖可讓你更好地理解:

GitHub 中有個在 RxSwift 不包含 Rx 已完成的項目。在 MVVMWithoutRx 分之上沒有實現 Rx。在本文中,咱們將介紹 RxSwift 的方案。請看不包含 Rx 的部分,那是經過閉包實現的。

添加 RxSwift

如今是激動人心的添加 RxSwift 部分🚶‍♂️。在這以前,讓咱們瞭解一下 ViewModel 應該爲咱們的類提供什麼:

  1. loading(Bool):當咱們請求服務器時咱們應該展現加載狀態,以便用戶理解正在加載內容。爲此,咱們須要 Bool 類型的 Observable。若是它爲 true 就意味着它正在加載,不然就已經加載完成(若是你不知道什麼是 Observable 請參考 part1)。
  2. Error(homeError):服務器可能出現的錯誤以及任何其餘錯誤。它多是彈出窗口,網絡錯誤等等,這個應該是 Error 類型的 Observable,因此一旦它有值了,咱們就在屏幕上展現出來。
  3. CollectionView 和 TableView 的數據。

所以父類有三種須要註冊的 Observable。

public enum homeError {
    case internetError(String)
    case serverMessage(String)
}

public let albums : publishSubject<[Album]> = publishSubject()
public let tracks : publishSubject<[Track]> = publishSubject()
public let loading : publishSubject<Bool> = publishSubject()
public let error : publishSubject<[homeError]> = publishSubject()
複製代碼

這些是咱們的 ViewModel 類的成員變量。全部這四個都是沒有默認值的 Observable。如今你可能會問什麼是 PublishSubject 呢?

正如咱們以前在 這篇文章 裏說起的,有些變量是 Observer,有些變量是 Observable。還有一種變量既是 Observer 又是 Observable,這種變量被稱爲 Subject

Subject 自己分爲 4 個部分(若是單獨解釋每一個部分,那可能須要另外一篇文章)。但我在這個項目中使用了 PublishSubject,這是最受歡迎的一個項目。若是你想了解更多關於 Subject 的信息,我建議你閱讀 這篇文章

使用 PublishSubject 的一個很好的理由是你能夠在沒有初始值的狀況下進行初始化。

對 UI 進行數據綁定(RxCocoa)

如今讓咱們看看具體代碼,如何才能將數據提供給咱們的 view:

在咱們看 ViewModel 的代碼以前,咱們須要讓 HomeVC 監聽 ViewModel 並在其改變時更新 view:

homeViewModel.loading.bind(to: self.rx.isAnimating).disposed(by: disposeBag)
複製代碼

在這段代碼中,咱們將 loading 綁定到 isAnimating,這意味着每當 ViewModel 改變 loading 的值時,咱們 ViewController 的 isAnimating 值也會改變。你可能會問是否僅使用該代碼顯示加載動畫。答案是確定的,但須要一些延遲,我稍後會解釋。

爲了把咱們的數據綁定到 UIKit,這有利於 RxCocoa,能夠從不一樣的 View 中得到不少屬性,你能夠經過 rx 訪問這些屬性。這些屬性是 Binder,所以你能夠輕鬆地進行綁定。那這又是什麼意思呢?

這意味着每當咱們將 Observable 綁定到 Binder 時,Binder 就會對 Observable 的值做出反應。例如,假設你有一個 Bool 的 PublishSubject,它只有 true 和 false。若是將此 subject 綁定到 view 的 isHidden 屬性,則在 publishSubject 爲 true 時將隱藏 view。若是 publishSubject 爲 false,則 view 的 isHidden 屬性將變爲 false,而後將再也不隱藏 view。這是否是很酷?

多虧了 Rx 團隊的 RxCocoa 包含了許多 UIKit 的屬性,可是有些屬性(例如自定義屬性,在咱們的例子中是 Animating)是不在 RxCocoa 中的,但你能夠輕鬆添加它們:

extension Reactive where Base: UIViewController {
    /// 用於 `startAnimating()` 和 `stopAnimating()` 方法的 binder
    public var isAnimating: Binder<Bool> {
        return Binder(self.base, binding: { (vc, active) in
            if active {
                vc.startAnimating()
            } else {
                vc.stopAnimating()
            }
        })
    }
}
複製代碼

如今讓咱們解釋一下上面的代碼:

  1. 首先咱們爲 RxCocoa 中的 Reactive 寫了一個 extension,用來拓展 UIViewController 中的 RX 屬性
  2. 咱們將 isAnimating 變量實現爲類型 Binder<Bool> 的 UIViewController,以即可以綁定。
  3. 接下來咱們建立 Binder,對於 Binder 部分,用閉包給咱們的控制器(vc)和 isAnimating (active)傳值。因此咱們能夠在 isAnimating 的每一個值中說明 viewController 會發生什麼變化,因此若是 active 爲 true,咱們用 vc.startAnimating() 顯示加載動畫,並在 active 爲 false 時隱藏。

如今咱們的加載已準備好從 ViewModel 接收數據了。那麼讓咱們看看其餘的 Binder:

// 監聽顯示 error
homeViewModel.error.observeOn(MainScheduler.instance).subscribe(onNext: { (error) in
    switch error {
    case .internetError(let message):
        MessageView.sharedInstance.showOnView(message: message, theme: .error)
    case .serverMessage(let message):
        MessageView.sharedInstance.showOnView(message: message, theme: .warning)
    }
}).disposed(by: disposeBag)
複製代碼

在上面的代碼中,當 ViewModel 每產生一個 error 時,咱們都會監聽到它。你能夠用 error 作任何你想作的事情(我正在彈出一個窗口)。

什麼是 .observeOn(MainScheduler.instance) 呢?🤔這部分代碼將發出的信號(在咱們的例子中是 error)帶到主線程,由於咱們的 ViewModel 正在從後臺線程發送值。所以咱們能夠防止因爲後臺線程而致使的運行時崩潰。你只需將信號帶到主線程中,而不是執行 DispatchQueue.main.async {}

最後一步

綁定 Album 和 Track 的屬性

如今讓咱們爲 UICollectionView 和 UITableView 的專輯和曲目進行綁定。由於咱們的 tableView 和 collectionView 屬性在咱們的子控中。如今,咱們只是將 ViewModel 中的專輯和曲目數組綁定到子控的曲目和專輯屬性,並讓子控負責顯示它們(我將在文章末尾展現它是如何完成的):

// 把專輯綁定到 album 容器

homeViewModel
    .albums
    .observeOn(MainScheduler.instance)
    .bind(to: albumsViewController.albums)
    .disposed(by: disposeBag)

// 把曲目綁定到 track 容器

homeViewModel
    .tracks
    .observeOn(MainScheduler.instance)
    .bind(to: tracksViewController.tracks)
    .disposed(by: disposeBag)
複製代碼

從 ViewModel 請求數據

如今讓咱們回到 ViewModel 看看發生了什麼:

public func requestData(){
    // 1
    self.loading.onNext(true)
    // 2
    APIManager.requestData(url: requestUrl, method: .get, parameters: nil, completion: { (result) in
        // 3
        self.loading.onNext(false)
        switch result {
        // 4
        case .success(let returnJson) :
            let albums = returnJson["Albums"].arrayValue.compactMap {return Album(data: try! $0.rawData())}
            let tracks = returnJson["Tracks"].arrayValue.compactMap {return Track(data: try! $0.rawData())}
            self.albums.onNext(albums)
            self.tracks.onNext(tracks)
        // 5
        case .failure(let failure) :
            switch failure {
            case .connectionError:
                self.error.onNext(.internetError("Check your Internet connection."))
            case .authorizationError(let errorJson):
                self.error.onNext(.serverMessage(errorJson["message"].stringValue))
            default:
                self.error.onNext(.serverMessage("Unknown Error"))
            }
        }
    })
}
複製代碼
  1. 咱們向 loading 發送 true,由於咱們已經在 HomeVC 類中進行了綁定,咱們的 viewController 如今顯示了加載動畫。
  2. 接下來,咱們只是向網絡層(Alamofire 或你擁有的任何網絡層)發送請求。
  3. 以後,咱們獲得了服務器的響應,咱們應該經過向 loading 發送 false 來結束加載動畫。
  4. 如今拿到了服務器的響應,若是它爲 success,咱們將解析數據併發送專輯和曲目的值。
  5. 若是遇到錯誤,咱們會發出 failure 值。一樣地,由於 HomeVC 已經監聽了 error,因此它們會向用戶顯示。
let albums = returnJson["Albums"].arrayValue.compactMap { return Album(data: try! $0.rawData()) }
let tracks = returnJson["Tracks"].arrayValue.compactMap { return Album(data: try! $0.rawData()) }
self.albums.append(albums)
self.tracks.append(tracks)
複製代碼

如今咱們的數據準備好了,咱們傳遞給子控,最後該在 CollectionView 和 TableView 中顯示數據了:

若是你還記得 HomeVC:

public var tracks = publishSubject<[Track]>()
複製代碼

如今在 trackTableViewVC 的 viewDidLoad 方法中,咱們應該將曲目綁定到 UITableView,這能夠只用兩三行代碼行中完成。感謝 RxCocoa!

tracks.bind(to: tracksTableView.rx.items(cellIdentifier: "TracksTableViewCell", cellType: TracksTableViewCell.self)) { (row,track,cell) in
    cell.cellTrack = track
}.disposed(by: disposeBag)
複製代碼

是的你只須要三行,事實上是一行,你不須要再設置 delegate 或 dataSource,再也不有 numberOfSections,numberOfRowsInSection 和 cellForRowAt。RxCocoa 一次性可處理全部內容。

你只須要將 Model 傳遞給 UITableView 併爲其指定一個 cellType。在閉包中,RxCocoa 將爲你提供與模型數組對應的單元格,model 和 row,以便你可使用相應的 model 爲 cell 提供信息。在咱們的 cell 中,每當調用 didSet 時,cell 將使用 model 設置屬性。

public var cellTrack: Track! {
    didSet {
        self.trackImage.clipsToBounds = true
        self.trackImage.layer.cornerRadius = 3
        self.trackImage.loadImage(fromURL: cellTrack.trackArtWork)
        self.trackTitle.text = cellTrack.name
        self.trackArtist.text = cellTrack.artist
    }
}
複製代碼

固然,你能夠在閉包內更改 view,但我更喜歡用 didSet。

添加彈性動畫

在本文結束以前,讓咱們經過添加一些動畫給咱們的 tableView 和 collectionView 煥發活力:

// cell 的動畫
tracksTableView.rx.willDisplayCell.subscribe(onNext: ({ (cell,indexPath) in
    cell.alpha = 0
    let transform = CATransform3DTranslate(CATransform3DIdentity, -250, 0, 0)
    cell.layer.transform = transform
    UIView.animate(withDuration: 1, delay: 0, usingSpringWithDamping: 0.7, initialSpringVelocity: 0.5, options: .curveEaseOut, animations: {
        cell.alpha = 1
        cell.layer.transform = CATransform3DIdentity
    }, completion: nil)
})).disposed(by: disposeBag)
複製代碼

咱們的項目最終會變成下面這樣:

動態 demo

寫在最後

咱們在 RxSwift 和 RxCocoa 的幫助下在 MVVM 中實現了一個簡單的 app,我但願你對這些概念更加熟悉。若是你有任何建議能夠聯繫咱們。

最終完成的項目能夠在 GitHub 倉庫 下找到。

若是你喜歡這篇文章和項目,請不要忘記,你能夠經過 Twitter 或經過電子郵件 mohammad_Z74@icloud.com 聯繫本文做者。

感謝你的閱讀!

若是發現譯文存在錯誤或其餘須要改進的地方,歡迎到 掘金翻譯計劃 對譯文進行修改並 PR,也可得到相應獎勵積分。文章開頭的 本文永久連接 即爲本文在 GitHub 上的 MarkDown 連接。


掘金翻譯計劃 是一個翻譯優質互聯網技術文章的社區,文章來源爲 掘金 上的英文分享文章。內容覆蓋 AndroidiOS前端後端區塊鏈產品設計人工智能等領域,想要查看更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章
相關標籤/搜索