[译] 实用的 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前端后端区块链产品设计人工智能等领域,想要查看更多优质译文请持续关注 掘金翻译计划官方微博知乎专栏

相关文章
相关标签/搜索