- 原文地址:Practical MVVM + RxSwift
- 原文作者:Mohammad Zakizadeh
- 譯文出自:掘金翻譯計劃
- 本文永久連結:github.com/xitu/gold-m…
- 譯者:iWeslie
- 校對者:swants
今天我們將使用 RxSwift 實現 MVVM 設計模式。對於那些剛接觸 RxSwift 的人,我 在這裡 專門做了一個部分來介紹。
如果你認為 RxSwift 很難或令人十分困惑,請不要擔心。它一開始看上去似乎很難,但通過例項和實踐,就會將變得簡單易懂?。
在使用 RxSwift 實現 MVVM 設計模式時,我們將在實際專案中檢驗此方案的所有優點。我們將開發一個簡單的應用程式,在 UICollectionView 和 UITableView 中顯示林肯公園(RIP Chester?)的專輯和歌曲列表。讓我們開始吧!
App 主頁面
UI 設定
子控制器
我希望在構建我們的 app 時遵循可重用性原則。因此,我們將會稍後在 app 的其他部分中重用這些 view,從而來實現我們的專輯的 CollectionView 和歌曲的 TableView。例如,假設我們想要顯示每張專輯中的歌曲,或者我們有一個部分用來顯示相似的專輯。如果我們不希望每次都重寫這些部分,那最好去重用它們。
那我們該怎麼做呢? 你正好可以嘗試一下子控制器。 為此,我們使用 ContainerView 將 UIViewController 分為兩部分:
- AlbumCollectionViewVC
- 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 應該為我們的類提供什麼:
- loading(Bool):當我們請求伺服器時我們應該展示載入狀態,以便使用者理解正在載入內容。為此,我們需要 Bool 型別的 Observable。如果它為 true 就意味著它正在載入,否則就已經載入完成(如果你不知道什麼是 Observable 請參考 part1)。
- Error(homeError):伺服器可能出現的錯誤以及任何其他錯誤。它可能是彈出視窗,網路錯誤等等,這個應該是 Error 型別的 Observable,所以一旦它有值了,我們就在螢幕上展示出來。
- 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()
}
})
}
}
複製程式碼
現在讓我們解釋一下上面的程式碼:
- 首先我們為 RxCocoa 中的 Reactive 寫了一個 extension,用來擴充 UIViewController 中的 RX 屬性
- 我們將 isAnimating 變數實現為型別
Binder<Bool>
的 UIViewController,以便可以繫結。 - 接下來我們建立 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"))
}
}
})
}
複製程式碼
- 我們向
loading
傳送 true,因為我們已經在 HomeVC 類中進行了繫結,我們的 viewController 現在顯示了載入動畫。 - 接下來,我們只是向網路層(Alamofire 或你擁有的任何網路層)傳送請求。
- 之後,我們得到了伺服器的響應,我們應該通過向
loading
傳送 false 來結束載入動畫。 - 現在拿到了伺服器的響應,如果它為 success,我們將解析資料併傳送專輯和曲目的值。
- 如果遇到錯誤,我們會發出 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 連結。
掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 Android、iOS、前端、後端、區塊鏈、產品、設計、人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃、官方微博、知乎專欄。