[譯] 實用的 MVVM 和 RxSwift

iWeslie發表於2019-04-19

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

如果你認為 RxSwift 很難或令人十分困惑,請不要擔心。它一開始看上去似乎很難,但通過例項和實踐,就會將變得簡單易懂?。


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

[譯] 實用的 MVVM 和 RxSwift

App 主頁面

UI 設定

子控制器

我希望在構建我們的 app 時遵循可重用性原則。因此,我們將會稍後在 app 的其他部分中重用這些 view,從而來實現我們的專輯的 CollectionView 和歌曲的 TableView。例如,假設我們想要顯示每張專輯中的歌曲,或者我們有一個部分用來顯示相似的專輯。如果我們不希望每次都重寫這些部分,那最好去重用它們。

那我們該怎麼做呢? 你正好可以嘗試一下子控制器。 為此,我們使用 ContainerView 將 UIViewController 分為兩部分:

  1. AlbumCollectionViewVC
  2. TrackTableViewVC

現在父控制器包含兩個子控制器(要了解子控制器,你可以閱讀 這篇文章)。

現在我們的 main ViewController 就變成了:

[譯] 實用的 MVVM 和 RxSwift

我們為 cell 使用 nib,這樣很容易就可以重用它們。

[譯] 實用的 MVVM 和 RxSwift

要註冊 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:

[譯] 實用的 MVVM 和 RxSwift

在 HomeViewModel 類中,我們應該從伺服器獲取資料,併為 view 需要展示的東西進行解析。然後 ViewModel 將它提供給父類,父控制器將這些資料傳遞給子控制器。這意味著父類從其 ViewModel 請求資料,並且 ViewModel 先傳送網路請求,再解析資料並傳給父類。

下圖可以讓你更好地理解:

[譯] 實用的 MVVM 和 RxSwift

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。這是不是很酷?

[譯] 實用的 MVVM 和 RxSwift

多虧了 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)
複製程式碼

我們的專案最終會變成下面這樣:

[譯] 實用的 MVVM 和 RxSwift

動態 demo

寫在最後

我們在 RxSwift 和 RxCocoa 的幫助下在 MVVM 中實現了一個簡單的 app,我希望你對這些概念更加熟悉。如果你有任何建議可以聯絡我們。

最終完成的專案可以在 GitHub 倉庫 下找到。

如果你喜歡這篇文章和專案,請不要忘記,你可以通過 Twitter 或通過電子郵件 mohammad_Z74@icloud.com 聯絡本文作者。

感謝你的閱讀!

如果發現譯文存在錯誤或其他需要改進的地方,歡迎到 掘金翻譯計劃 對譯文進行修改並 PR,也可獲得相應獎勵積分。文章開頭的 本文永久連結 即為本文在 GitHub 上的 MarkDown 連結。


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

相關文章