1. Practical MVVM + RxSwift

今天我们将使用RxSwift实现MVVM设计模式。对于那些刚接触RxSwift的人,我在这里做了一个介绍。

如果你认为RxSwift很难或者模棱两可,不要担心。乍一看可能很难,但通过实例和实践,它将变得简单易懂。👍


在使用RxSwift实现MVVM设计模式时,我们将在实际项目中使用这种方法的所有优点。我们将工作在一个简单的应用程序,显示在UICollectionView和UITableView (R.I.P切斯特🙏)林肯公园的专辑和歌曲的列表。让我们开始吧!

avatar

1.1. UI Setup

1.1.1. Child View Controllers

在构建我们的应用程序时我想遵循可重用性原则。我们将实现TableView歌曲 和 CollectionView专辑,在我们应用程序的其他部分我们稍后可以重用这些视图。例如,想象我们要显示每个专辑歌曲或我们有部分相似的专辑显示。如果我们不想每次都实现这些部分,最好让它们可重用。

那么我们能做什么呢? 子视图控制器来拯救。

为此,我们将使用ContainerView的UIViewController分为两部分:

  1. AlbumCollectionViewVC
  2. TrackTableViewVC

现在父视图控制器由两个childViewController组成(要了解childViewController你可以阅读这篇文章)。

avatar

为了注册nib文件的单元格,你应该把这段代码放在AlbumCollectionViewVC类的viewDidLoad方法中。所以UICollectionView知道它在使用什么类型的单元格:

//register 'AlbumsCollectionViewCell' to UICollectionView
albumsCollectionView.register(UINib(nibName: "AlbumsCollectionViewCell",bundle:nil),forCellWithReuseIdentifier:String(describing:AlbumsCollectionViewCell.Self))

考虑到这段代码应该在AlbumCollectionViewVC类中。这意味着父类的一个子类和父类暂时不需要处理其子类的对象。

对于TrackTableViewVC,我们做了相同的过程,不同的是它只是一个table view。现在我们要去父类,我们应该设置我们的两个子类。

正如你在故事板图片中看到的,子类的位置是我们的viewControllers被放置的两个视图。这些视图称为ContainerView。为了设置这些视图,我们可以使用以下代码:

IBOutlet weak var albumsVCView: UIView!
    
    private lazy var albumsViewController: AlbumsCollectionViewVC = {
        // Load Storyboard
        let storyboard = UIStoryboard(name: "Home", bundle: Bundle.main)
        
        // Instantiate View Controller
        var viewController = storyboard.instantiateViewController(withIdentifier: "AlbumsCollectionViewVC") as! AlbumsCollectionViewVC
        
        // Add View Controller as Child View Controller
        self.add(asChildViewController: viewController, to: albumsVCView)
        
        return viewController
    }()

1.2. View Model Setup

1.2.1. Basic View Model Architecture

所以我们的视图已经准备好了,现在我们进入ViewModel和RxSwift:

在主ViewModel类中,我们应该从服务器获取数据,并以视图想要的方式进行解析。然后viewModel将这些数据交给父类,父类将这些数据传递给子视图控制器。这意味着父类从视图模型请求数据,视图模型向网络层发送请求。然后视图模型解析数据并将其交给父类。

为了更好地理解,请看下面的图表:

avatar

The completed project in GitHub is implemented in RxSwift and without Rx. The implementation without Rx is in MVVMWithoutRx branch. In this article, we get through the RxSwift way. Please check without Rx way too, which implemented with closures.

1.2.2. Adding RxSwift

现在,RxSwift进入🚶‍♂️,这是令人兴奋的部分。在那之前,让我们来理解视图模型应该给我们的类提供什么:

  1. loading(Bool):当我们向服务器发送请求时,我们应该显示加载。这样用户就能理解,有东西正在加载。为此,我们需要布尔的可观察对象。当它为真时意味着它正在加载,当它为假时——它已经加载了。

  2. error(homeError):来自服务器的可能错误和任何其他错误。它可以是弹出窗口,Internet错误等等,这应该是可以观察到的错误类型,所以如果它有一个值,我们就会在屏幕上显示它。

The collection and table views data ,…

所以我们有三种类型的可观察对象,我们的父类应该注册给它们:

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 tracks: PublishSubject<homeError> = PublishSubject()
}

这些是我们的视图模型类变量。这四个都是可观察的,没有第一个初始值。现在您可能会问:什么是PublishSubject?

就像我们之前说的,一些变量是Observer,一些是Observable。我们还有另一个可以同时是观察者和被观察对象,它们被称为Subject

Subjects 本身分为4个部分(解释每个部分,需要另一篇文章)。但是我在这个项目中使用了PublishSubject,这是最流行的一个。如果你想了解更多的主题,我推荐你阅读这篇文章

使用PublishSubject的一个很好的原因是可以在没有初始值的情况下进行初始化。

1.2.3. UI to data binding (RxCocoa)

现在让我们进入代码,看看如何向视图提供数据:

在我们进入视图模型代码之前,我们需要准备HomeVC类来观察viewModel变量,并从视图模型数据中响应视图:

homeViewModel.loading.bind(to: self.rx.isAnimating).disposed(by:disposeBag)

在这段代码中,我们将loading绑定到isAnimating,这意味着每当viewModel改变加loading时,视图控制器的isAnimating值也会改变。你可能会问,我们是否正在用这段代码显示加载动画。答案是肯定的,但它需要一些扩展,稍后我会解释。

为了让我们的数据绑定到UIKit,为了支持RxCocoa,有很多属性可以从不同的视图中获得,你可以从rx property中访问这些属性。这些属性是binder,因此您可以轻松地进行绑定。这是什么意思?

这意味着当我们将一个Observable绑定到一个binder时,binder会对这个Observable的值做出反应。例如,假设您有一个Bool的PublishSubject,它产生真和假。如果您将该主题绑定到视图的isHidden属性,那么如果publishSubject生成true,该视图将被隐藏。如果publishSubject生成false,那么viewishidden属性将变为false,然后视图将不再被隐藏。很酷,不是吗?

avatar

尽管RxCocoa包含了很多UIKit属性,但也有一些属性(例如自定义的,在我们的例子中是animation)并不在RxCocoa中,但是你可以很容易地添加它们:

extension Reactive where Base: UIViewController {
    
    /// Bindable sink for `startAnimating()`, `stopAnimating()` methods.
    public var isAnimating: Binder<Bool> {
        return Binder(self.base, binding: { (vc, active) in
            if active {
                vc.startAnimating()
            } else {
                vc.stopAnimating()
            }
        })
    }
    
}

现在让我们来解释上面的代码:

  1. 首先,我们写了一个扩展的反应,这是在RxCocoa和影响RX属性的UIViewController。

  2. 我们为UIViewControllers实现了Binder类型的isAnimating变量,这样它就可以被绑定了。

  3. 接下来,我们创建Binder,对于Binder部分,闭包给了我们视图控制器(vc)和isAnimating (active)的值。所以我们能够在isAnimating的每个值中说viewController发生了什么,所以如果active为真,我们就用vc.startAnimating()来显示加载动画,当active为假时隐藏加载。

现在我们的loading已经准备好从ViewModel接收数据了。让我们来看看其他的binders:

// observing errors to show
        
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出现错误时,我们都会订阅它。您可以对错误做任何您想做的事情(我正在显示一个弹出窗口)。

那么.observeon(mainscheduler.instance)是什么? 这部分代码将发出的信号(在我们的例子中是错误)带到主线程,因为我们的ViewModel正在从后台线程发送值。因此,我们避免了后台线程造成的运行时崩溃。你只需在一行中将信号发送到主线程,而不是使用DispatchQueue.main.async{}方法。

1.3. Last touch

1.3.1. Connect Albums and Tracks properties

现在让我们绑定专辑UICollectionView和音轨的UITableView。因为tableView和collectionView属性在子ViewControllers中。现在,我们只是将ViewModel中的相册和音轨数组绑定到childviewcontroller的音轨和音轨属性,并让子视图负责显示它们(我将在文章的最后展示如何做到):

// binding albums to album container
        
homeViewModel
    .albums
    .observeOn(MainScheduler.instance)
    .bind(to: albumsViewController.albums)
    .disposed(by: disposeBag)

// binding tracks to track container

homeViewModel
    .tracks
    .observeOn(MainScheduler.instance)
    .bind(to: tracksViewController.tracks)
    .disposed(by: disposeBag)
       

1.3.2. Request data from View Model

现在让我们回到ViewModel,看看发生了什么:

public func requestData(){
        
    self.loading.onNext(true)
    APIManager.requestData(url: requestUrl, method: .get, parameters: nil, completion: { (result) in
        self.loading.onNext(false)
        switch result {
        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)
        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. line (13–19) 现在有了服务器的响应,如果我们遇到麻烦,我们发出错误值。同样,因为HomeVC已经订阅了错误,所以它们将显示给用户。

  5. (line 8–11) 如果响应成功,我们解析数据并发出专辑和歌曲的值。

avatar

现在我们的数据已经准备好了,并且我们传递给了我们的childViewControllers,最后我们应该在CollectionView和TableView中显示数据:

如果你还记得HomeVC的课程:

avatar

现在在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)

是的,你说对了,只有两行。不再设置委托或数据源,不再numberOfSections, numberOfRowsInSection和cellForRowAt。RxCocoa只需两行就可以处理所有内容。

你只需要传递模型(绑定模型到UITableView)并给它一个cellType。在闭包中,RxCocoa将为您提供与模型数组相对应的单元格、模型和行,这样您就可以为单元格提供相应的模型。在我们的单元格中,每当模型被didSet设置,单元格就会用模型设置属性。

avatar

当然,您可以在闭包内更改视图,但我更喜欢计算属性方式。

1.3.3. Adding bonus animation

在文章结束之前,让我们给我们的tableView和collectionView添加一些动画:

// animation to cells
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)