MVVM+RxSwift

升級之路發表於2019-02-02

前言

以前對MVVM的理解和運用覺得很淺薄,在專案中用處只是對ViewController減負

  1. 沒有做資料與View的繫結,沒有做到真正的資料驅動檢視
  2. 沒有體現出MVVM易於測試的好處
  3. 對於RxSwift的運用也僅限於網路請求庫,RxCocoa的一些優點沒有運用到專案

所以是時候在專案中使用真正的MVVM了(整理出套路程式碼),介於專案中已經引入了RxSwift,所以就用它來實現了,在學習本文前可能會要求讀者對RxSwift有一定的瞭解和使用。

MVVM架構圖

*MVVM*架構圖.png
在ViewController 裡將資料來源繫結到對應的View,這裡只是單向繫結,在ViewModel進行網路請求等改變資料行為的操作更新Model,再由ViewModel通知View更新。至於怎麼實現資料繫結的,下面會詳細說明。

MVVM目錄結構

image.png

上圖是專案中的一個模組,使用MVVM架構後的檔案結構,Model被我集中的定義在一個公共的資料夾裡了,接下來我會詳細介紹。

ViewModel

查閱了許多資料,不同人對ViewModel的實現有很多種,我這裡總結了一下多數人也是我比較贊同的一種實現方法

image.png
將ViewModel理解為一個簡單的黑盒子,它接受輸入以產生輸出,這裡的輸入和輸出都是一個個序列。這樣就能實現MVVM的最大的好處,使業務邏輯可測試。ViewModel裡面主要進行網路請求、業務處理等操作。網路請求的框架我們用的是Moya,因為它可以使我們的請求得到一個序列,然後我們才可以進行資料繫結。 一般的ViewModel大概是長這樣的:

class ViewModel {
    // 輸入轉化輸出,這裡是真正的業務邏輯程式碼了
    func transform(input: Input) -> Output {
    }
}
extension ViewModel {
    // 輸入,型別是Driver,因為跟UI控制元件有關
    struct Input {
    }
    // 輸出,型別也是Driver
    struct Output {
    }
}
複製程式碼

Model

對於Model,它主要是定義一些資料模型,當然你也可以封裝一些資料轉換等公共的業務方法。

ViewController和View

ViewController的主要作用是管理檢視的生命週期,繫結資料和View的關係,資料繫結的實現主要是通過RxDataSources+RxSwift來實現的,所以說你的專案中要引入這兩個庫。RxCocoa給UI框架提供了Rx支援,讓我們能夠使用按鈕點選序列,這樣我們就可以給ViewModel提供輸入了,而RxDataSources能夠幫助你簡化書寫 TabelView或 CollectionView的資料來源這一過程,並且提供了通過序列更新TableView的方法,這時候我們只要把ViewModel的資料輸出序列繫結到TableView的資料來源序列就可以了。

Navigator

Navigator是從ViewController剝離出來用來控制檢視跳轉

上程式碼

下圖是上述目錄結構中一個頁面

291549013399_.pic.jpg

先分析下介面上的輸入和輸出

輸入:進入頁面時的請求,重新命名按鈕點選,刪除按鈕點選,新建分組按鈕點選

輸出:TableView資料來源,頁面Loading狀態

ViewModel核心程式碼:

class MenuSubGroupViewModel {
    func transform(input: Input) -> Output {
        let loadingTracker = ActivityIndicator()
        let createNewGroup = input.createNewGroup
            .flatMapLatest { _ in
                self.navigator.toMenuEditGroupVC()
                    .saveData
                    .asDriverOnErrorJustComplete()
            }
        let renameGroup = input.cellRenameButtonTap
            .flatMapLatest...
        let getMenusInfo = Driver.merge(createNewGroup, input.viewDidLoad, renameGroup)
            .flatMapLatest...
        let deleteSubGroups = input.cellDeleteButtonTap
            .flatMapLatest...
        let dataSource = Driver.merge(getMenusInfo, deleteSubGroups)
        let loading = loadingTracker.asDriver()
        return Output(dataSource: dataSource, loading: loading)
    }
}
extension MenuSubGroupViewModel {
    struct Input {
        let createNewGroup: Driver<Void>
        let viewDidLoad: Driver<Void>
        let cellDeleteButtonTap: Driver<IndexPath>
        let cellRenameButtonTap: Driver<IndexPath>
    }
    struct Output {
        let dataSource: Driver<[MenuSubGroupViewController.CellSectionModel]>
        let loading: Driver<Bool>
    }
}
複製程式碼

這裡可能會有人疑問為什麼會儲存頁面的資料呢,我們的資料不是直接通過網路請求生成一個序列繫結到TableView了嗎?因為在某些業務場景下我們需要儲存它,比如在網路請求錯誤的時候,我希望頁面還會繼續顯示之前有資料的狀態,這時候我們就可以在網路請求錯誤的序列中塞入我們之前儲存的資料,這樣頁面還是顯示原樣,還有你注意沒有這個屬性是private的。 ActivityIndicator:可以監聽網路請求的狀態從而改變loading的狀態,具體實現在下面程式碼中已經貼出。

createNewGroup :當點選頁面上的新建分組按鈕會傳送一個序列作為ViewModel輸入,通過flatMapLatest轉換操作進入到下一頁完成新建分組的操作,並將結果以序列的形式傳回來。這裡的saveData是一個PublishSubject型別,可接收也可傳送序列,因為Driver只能接收而不能傳送。如果成功就去重新整理頁面。

viewDidLoad:當ViewController呼叫viewDidLoad的方法的時候會傳送一個序列作為ViewModel輸入,通過transform轉化dataSource輸出去更新TableView。

cellDeleteButtonTap和cellRenameButtonTap: 點選cell中的按鈕,會發出一個序列作為ViewModel輸入,然後執行相應的業務程式碼,最後產生輸出。

dataSource:TableView資料來源序列,發生改變會去重新整理TableView。

loading:控制頁面loading狀態的序列

ActivityIndicator核心程式碼

public class ActivityIndicator: SharedSequenceConvertibleType {
    fileprivate func trackActivityOfObservable<O: ObservableConvertibleType>(_ source: O) -> Observable<O.E> {
        return Observable.using({ () -> ActivityToken<O.E> in
            self.increment()
            return ActivityToken(source: source.asObservable(), disposeAction: self.decrement)
        }) { activity in
            return activity.asObservable()
        }
    }
    private func increment() {
        lock.lock()
        value += 1
        subject.onNext(value)
        lock.unlock()
    }
    private func decrement() {
        lock.lock()
        value -= 1
        subject.onNext(value)
        lock.unlock()
    }
}
複製程式碼

ViewController中的核心程式碼

import UIKit
class MenuSubGroupViewController: UIViewController {
    private let cellDeleteButtonTap = PublishSubject<IndexPath>()  // 刪除分組序列,cell中刪除按鈕點選時呼叫onNext方法傳送序列
    private let cellRenameButtonTap = PublishSubject<IndexPath>() // 分組重新命名序列,cell中重新命名按鈕點選時呼叫onNext方法傳送序列

    // 初始化ViewModel的輸入序列並進行ViewModel的輸出序列繫結到View
    func bindViewModel() {
        let viewDidLoad = Driver<Void>.just(())
        let input = MenuSubGroupViewModel.Input(createNewGroup: createGroupButton.rx.tap.asDriver(),
                                                viewDidLoad: viewDidLoad,
                                                cellDeleteButtonTap: cellDeleteButtonTap.asDriverOnErrorJustComplete(),
                                                cellRenameButtonTap: cellRenameButtonTap.asDriverOnErrorJustComplete())
        
        let output = viewModel.transform(input: input)
        output.loading..
        output.dataSource
            .drive(tableView.rx.items(dataSource: dataSource))
            .disposed(by: disposeBag)
    }
  
    private lazy var dataSource: RxTableViewSectionedReloadDataSource<CellSectionModel> = {
        return RxTableViewSectionedReloadDataSource<CellSectionModel>(configureCell: { [weak self](_, tableView, indexPath, item) -> UITableViewCell in
            let cell: LabelButtonCell = tableView.dequeueReusableCell(LabelButtonCell.self)
            ...
            cell.rightButton1.rx.tap
                .subscribe(onNext: { [weak self] (_) in
                    self?.cellDeleteButtonTap.onNext(indexPath)
                })
                .disposed(by: cell.disposeBag)
            cell.rightButton2.rx.tap...
            return cell
        })
    }()
}
複製程式碼

在這裡RxDataSources的使用方法我就不再詳細敘述了,所以說我們主要關注bindViewModel的方法,裡面定義了頁面的各種輸入,並通過transform方法等得到輸出的序列,再對TableView的資料來源進行繫結。RxCocoa為我們提供了很多系統基礎控制元件的Rx呼叫,可以很方便的進行資料繫結。

Navigator中的核心程式碼

class MenuSubGroupNavigator: BaseNavigator {
    func toMenuEditGroupVC(menuUid: String, dishGroupsInfo: DishGroupInfo? = nil) -> MenuEditGroupViewController {
        let navigator = MenuEditGroupNavigator(navigationController: navigationController)
        let viewModel = MenuEditGroupViewModel(navigator: navigator)
        let vc = MenuEditGroupViewController()
        vc.viewModel = viewModel
        navigationController?.pushViewController(vc, animated: true)
        return vc
    }
}
複製程式碼

總結

  1. 要搭建一個上述的MVVM專案,RxSwift,RxDataSources,Moya是必不可少的,並且你要會用RxDataSource建立UITableView資料來源,對RxSwift要有一定的瞭解。
  2. 在專案中對cell中的點選事件的處理方式是在ViewController裡建立一個PublishSubject的序列,然後在事件回撥或監聽處主動呼叫onNext方法。
  3. 對於頁面loading,無資料,無網等狀態可以分別封裝ViewController的Rx屬性,然後通過ActivityIndicator可以監聽網路請求的狀態,傳送序列從而改變頁面狀態。
  4. 上述的MVVM專案的很多操作都是通過序列來完成的,發生錯誤時可能不好定位。

原始碼地址,不過大家可以參考下GitHub上的CleanArchitectureRxSwift

本文版權屬於再惠研發團隊,歡迎轉載,轉載請保留出處。@xqqlv