RXSwift — 使用MVVM实现一个歌单列表

今天我们将用RxSwift加上MVVM设计模式来开发一个简单的小Demo,在UICollectionView和UITableView中显示林肯公园的专辑和歌曲列表。Let's go!

UI 部分

第一步,用CollectionView搭建专辑九宫格,用TableView搭建歌曲列表。

这两部分我们可以分成两个控制器来做,主要为了能重复利用,然后使用 childViewController 来添加。
我们的主控制器就划分为两个控制器:

  1. AlbumCollectionViewVC
  2. TrackTableViewVC

所以我们的主控制器就会像这样:

第二步,使用nib创建cells,以便后面能够重用它们:

记得在AlbumCollectionViewVCviewDidLoad方法中注册nib文件:

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

第三步,关联两个view作为AlbumCollectionViewVC和TrackTableViewVC的容器view。

@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
    }()

我们已经把视图部分搭建好了,接下来创建ViewModel。

View Model 部分

我们创建一个HomeViewModel类,在该类的作用是:

  1. 从服务器获取数据,并按照视图需要展现的方式来解析请求到的数据。
  2. 将解析到的数据传递给父类控制器,父类控制器将这些数据再传递给子视图控制器。

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

所以整个过程就是:父类控制器从它的视图模型请求数据,视图模型向网络层发送请求。然后视图模型解析数据并将其提供给父类控制器。

HomeViewModel中还提供了以下几个属性:

  1. Loading(Bool):当我们向服务器发送请求时,我们应该显示Loading提示弹框以示加载中。这样用户就明白,有些东西正在加载。为此,我们需要定义一个PublishSubject<Bool>类型的可观察对象。当它为true时,它将意味着正在加载,当它为false时,意味着已经加载完成。
  2. Error(homeError):来自服务器的错误和任何其他错误。如果它有值,我们将它在屏幕上显示出来。
  3. albums和tracks:集合和表视图数据。
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()

上面四个属性都是用PublishSubject定义的observable对象,问题来了,PublishSubject是什么,怎么用?

Subjects 和 PublishSubject

再了解PublishSubject之前,先来聊聊Subjects。

Subjects既是可观察的observable对象又是observer观察者。它们可以接收事件,也可以订阅。subject接收.next事件,并且每次接收到一个事件,它都会返回并将其发送给它的订阅者。

RxSwift有4种subject类型:

  • PublishSubject。
  • BehaviorSubject。
  • ReplaySubject。
  • Variable。

PublishSubject是Subject的一种。我们用一张图来展示一下:

从图中看出,如果在事件1之后订阅,我们就不能接收到事件1,而可以接收到事件2和事件3。如果在事件2之后订阅,那么我们只能接收到事件3,而接收不到事件1和事件2。

PublishSubject 如何使用?

我们来举个例子。

第一步,创建一个类型为String的PublishSubject:

let subject = PublishSubject<String>()

第二步,通过onNext发出事件:

subject.onNext(“No event emitted??”)

此时不会有任何打印,因为没有人订阅这个它。

第三步,我们来订阅它:

let subscriptionOne = subject
                          .subscribe(onNext: { string in
                              print("First Subscription: ", string)
                          })

第四步,使用subject通过onNext发出事件:

subject.onNext("1")
subject.onNext("2")

只输出了订阅后的事件数据:

First Subscription:  1
First Subscription:  2

第五步,再创建另一个订阅:

let subscriptionTwo = subject
                         .subscribe({ (event) in
                             print("Second Subscription: \(event)"))
                          })

第六步,并发射事件:

subject.onNext("3")

这是在subscription1和subscription2被发出后订阅的,所以此时的订阅者只能监听到事件3,又增加了以下的打印的结果:

First Subscription:  3
Second Subscription: next(3)

第七步,我们尝试释放掉subscriptionOne并发出事件4:

subscriptionOne.dispose()
subject.onNext("4")

这时只有观察者subscriptionTwo可以监听事件,并执行打印操作,因为subscriptionOne资源已被释放。

Second Subscription: next(4)

第八步,尝试发出complete event,同时释放掉subscriptionTwo。

subject.onCompleted()
subscriptionTwo.dispose()
subject.onNext("Any event emitted??")

Complete event 会被打印出来:

Second Subscription: completed

整个代码如下:

let subject = PublishSubject<String>()
subject.onNext(“No event emitted??”)

let subscriptionOne = subject
                          .subscribe(onNext: { string in
                              print("First Subscription: ", string)
                          })

subject.onNext("1")
subject.onNext("2")

let subscriptionTwo = subject
                         .subscribe({ (event) in
                             print("Second Subscription: \(event)"))
                          })

subject.onNext("3")
subscriptionOne.dispose()

subject.onNext("4")
subject.onCompleted()

subscriptionTwo.dispose()
subject.onNext("Any event emitted??")

整个输出如下:

First Subscription:  1
First Subscription:  2
First Subscription:  3
Second Subscription: next(3)
Second Subscription: next(4)
Second Subscription: completed

PublishSubject相当于热信号,只会接收到后面的事件。我们这个Demo只会用到PublishSubject,也是因为它的初始化不用给初始值,比较方便。

将数据绑定到UI上

现在,我们来看看如何给我们的视图提供数据。

首先,我们将homeViewModel中的loading绑定给HomeVC 的isAnimating,这意味着每当viewModel的loading值发生改变时,视图控制器的isAnimating值也会同时改变。

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

看到上面的.rx了吗?类似的我们可以通过.rx的形式访问到很多视图的属性,从而能够将数据绑定到UIKit中。

但是要注意了,对于自定义的属性,RxCocoa并不支持.rx,我们可以使用拓展来让它支持:

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. 对Reactive进行了扩展,目的想对自定义的属性进行.rx调用。
  2. 设置isAnimating变量的类型为Binder<Bool>,并对它进行实现。
  3. 实现代码里,返回了一个Binder,self.base就是viewController本身,后面的闭包提供两个参数:视图控制器(vc)和isAnimating值(active)。如果active为true,我们就用vc.startAnimating()显示正在加载动画,如果active为false,就用vc.stopAnimating()隐藏加载动画。

现在我们的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)又是什么呢?它的作用是回到主线程发送错误信号。

绑定Albums 和 Tracks

现在我们将Albums 和 Tracks分别绑定给UICollectionView和UITableView:

 // 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)

数据请求

现在我们再回到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类中绑定了loading,这样我们的viewController就会显示正在加载动画。
  2. 接下来,发送一个数据请求,得到响应后,在回调闭包里我们应该结束加载动画,所以又发送了一个false值。
  3. 对于请求结果result我们做了区分,如果为success,就解析数据并发出albums和albums的值,如果为false,就会发出错误值,根据错误不同的情况我们再做相应的处理。

现在我们的数据准备好了,我们传递给了我们的子视图控制器,最后我们应该在CollectionView和TableView中显示数据了。
TracksTableViewVC中:

public var tracks = PublishSubject<[Track]>()

AlbumsCollectionViewVC中:

public var albums = PublishSubject<[Album]>()

现在在trackTableViewVC的ViewDidLoad方法里,将数据源tracks绑定给UITableView:

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

仅仅两行代码就搞定了,不需要想以前那样设置delegate和datasource,也不用写一大堆代理方法,RxCocoa搞定了一切!

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
        }
    }

给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)

最后完成效果:

小结

我们使用RxSwift和MVVM实现了一个小Demo,其中只是涉及到部分概念,后续会通过实践来学习其他的用法。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,324评论 6 498
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,356评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 162,328评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,147评论 1 292
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,160评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,115评论 1 296
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,025评论 3 417
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,867评论 0 274
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,307评论 1 310
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,528评论 2 332
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,688评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,409评论 5 343
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,001评论 3 325
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,657评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,811评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,685评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,573评论 2 353