iOS RxSwift + MVVM 如何構(gòu)建ViewModel牡彻?




ViewModel需要具備以下特性:

  • 可插拔;
  • 可測(cè)試出爹;
  • 采用綁定機(jī)制的MVVM模式會(huì)更加強(qiáng)大庄吼,所以ViewModel要充分利用RxSwift缎除;

把ViewModel當(dāng)做黑箱,它可以接收輸入总寻,并產(chǎn)生輸出器罐,這就是定義ViewModel最好的原則。


方案一 (不采用Subjects)



定義ViewModelType協(xié)議

protocol ViewModelType {
  associatedtype Input
  associatedtype Output

  func transform(input: Input) -> Output
}

這種方案簡(jiǎn)單易行渐行,只需要一次性提供Input給ViewModel轰坊,然后ViewModel即可給出Output。



讓我們創(chuàng)建示例Demo:
輸入內(nèi)容殊轴,然后點(diǎn)擊Validate按鈕衰倦。最后袒炉,顯示校驗(yàn)結(jié)果旁理。



創(chuàng)建SayHelloViewModel,它需要知道輸入的文本以及按鈕點(diǎn)擊事件我磁,這就是Input孽文。
然后Output是文本內(nèi)容。

final class SayHelloViewModel: ViewModelType {
  struct Input {
    let name: Observable<String>
    let validate: Observable<Void>
  }

  struct Output {
    let greeting: Driver<String>
  }

  func transform(input: Input) -> Output {
    let greeting = input.validate
      .withLatestFrom(input.name)
      .map { name in
        return "Hello \(name)!"
      }
      .startWith("")
      .asDriver(onErrorJustReturn: ":-(")

    return Output(greeting: greeting)
  }
}



創(chuàng)建SayHelloViewController:

final class SayHelloViewController: UIViewController {
  
  @IBOutlet weak var nameTextField: UITextField!
  @IBOutlet weak var validateButton: UIButton!
  @IBOutlet weak var greetingLabel: UILabel!
  
  private let viewModel = SayHelloViewModel()
  private let bag = DisposeBag()
  
  override func viewDidLoad() {
    super.viewDidLoad()
    bindViewModel()
  }
  
  private func bindViewModel() {
    let inputs = SayHelloViewModel.Input(name: nameTextField.rx.text.orEmpty.asObservable(),
                                         validate: validateButton.rx.tap.asObservable())
    let outputs = viewModel.transform(input: inputs)
    outputs.greeting
      .drive(greetingLabel.rx.text)
      .disposed(by: bag)
  }
}



ViewModel應(yīng)該是可插拔的夺艰,那么我們可以把之前定義的ViewModel用于其他View嗎芋哭?
現(xiàn)在,如果我們嘗試將之前的ViewModel用于帶有TableView的View郁副,會(huì)發(fā)生什么事情减牺?


/// TableViewCells
final class TextFieldCell: UITableViewCell {
  @IBOutlet weak var nameTextField: UITextField!
}

final class ButtonCell: UITableViewCell {
  @IBOutlet weak var validateButton: UIButton!
}

final class GreetingCell: UITableViewCell {
  @IBOutlet weak var greetingLabel: UILabel!
}

/// ViewController
final class SayHelloViewController: UIViewController, UITableViewDataSource {
    static let cellIdentifiers = [
      "TextFieldCell",
      "ButtonCell",
      "GreetingCell"
    ]

    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
      return TableViewController.cellIdentifiers.count
    }

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
      // Classic dequeue work
    }

    private let viewModel = SayHelloViewModel()
    private let bag = DisposeBag()

    override func viewDidLoad() {
      super.viewDidLoad()
      bindViewModel()
    }

    private func bindViewModel() {
      // Let's discuss about this
      let inputs = SayHelloViewModel.Input(name: ????, validate: ????)
    }
}

然而,我們甚至無(wú)法為ViewModel提供Input存谎。
因?yàn)槲覀儾荒茉趧?chuàng)建ViewModel時(shí)就獲取到UITableView的內(nèi)容拔疚。
所以,使用這種方案有一個(gè)前提條件:在創(chuàng)建ViewModel的Input時(shí)既荚,可以獲得全部所需的資源稚失。

這時(shí),你就需要采用第二種方案了恰聘!


第二種方案 (采用Subjects)



定義ViewModelType協(xié)議:

protocol ViewModelType {
    associatedtype Input
    associatedtype Output

    var input: Input { get }
    var output: Output { get }
}

這樣句各,我們就可以完全自由地選擇何時(shí)提供輸入、何時(shí)訂閱輸出了晴叨。
Subject可以同時(shí)充當(dāng)ObserverObservable凿宾,把命令式的編程變?yōu)镽x的函數(shù)式編程。



定義采用Subject的ViewModel:

final class SayHelloViewModel: ViewModelType {
    let input: Input
    let output: Output

    struct Input {
        let name: AnyObserver<String>
        let validate: AnyObserver<Void>
    }

    struct Output {
        let greeting: Driver<String>
    }

    private let nameSubject = ReplaySubject<String>.create(bufferSize: 1)
    private let validateSubject = PublishSubject<Void>()

    init() {
        let greeting = validateSubject
            .withLatestFrom(nameSubject)
            .map { name in
                return "Hello \(name)!"
            }
            .asDriver(onErrorJustReturn: ":-(")

        self.output = Output(greeting: greeting)
        self.input = Input(name: nameSubject.asObserver(), validate: validateSubject.asObserver())
    }
}

這里有幾點(diǎn)值得注意的內(nèi)容:

  • ViewModel的任務(wù)還是輸入Input產(chǎn)出Output兼蕊;
  • Subjectsprivate的初厚,所以你只能通過(guò)input和output屬性與ViewModel交互;
  • 兼具可插拔遍略、可測(cè)試的特性惧所,并且充分利用了RxSwift的綁定機(jī)制骤坐;



View部分的實(shí)現(xiàn):


/// Every view interacting with a `SayHelloViewModel` instance can conform to this.
protocol SayHelloViewModelBindable {
    var disposeBag: DisposeBag? { get }
    func bind(to viewModel: SayHelloViewModel)
}

/// TableViewCells
final class TextFieldCell: UITableViewCell, SayHelloViewModelBindable {
    @IBOutlet weak var nameTextField: UITextField!
    var disposeBag: DisposeBag?

    override func prepareForReuse() {
        super.prepareForReuse()

        // Clean Rx subscriptions
        disposeBag = nil
    }

    func bind(to viewModel: SayHelloViewModel) {
        let bag = DisposeBag()
        nameTextField.rx
            .text
            .orEmpty
            .bind(to: viewModel.input.name)
            .disposed(by: bag)
        disposeBag = bag
    }
}

final class ButtonCell: UITableViewCell, SayHelloViewModelBindable {
    @IBOutlet weak var validateButton: UIButton!
    var disposeBag: DisposeBag?

    override func prepareForReuse() {
        super.prepareForReuse()
        disposeBag = nil
    }

    func bind(to viewModel: SayHelloViewModel) {
        let bag = DisposeBag()
        validateButton.rx
            .tap
            .bind(to: viewModel.input.validate)
            .disposed(by: bag)
        disposeBag = bag
    }
}

final class GreetingCell: UITableViewCell, SayHelloViewModelBindable {
    @IBOutlet weak var greetingLabel: UILabel!
    var disposeBag: DisposeBag?

    override func prepareForReuse() {
        super.prepareForReuse()
        disposeBag = nil
    }

    func bind(to viewModel: SayHelloViewModel) {
        let bag = DisposeBag()
        viewModel.output.greeting
            .drive(greetingLabel.rx.text)
            .disposed(by: bag)
        disposeBag = bag
    }
}

/// View
class TableViewController: UIViewController, UITableViewDataSource {
    static let cellIdentifiers = [
        "TextFieldCell",
        "ButtonCell",
        "GreetingCell"
    ]

    @IBOutlet weak var tableView: UITableView!

    private let viewModel = SayHelloViewModel()
    private let bag = DisposeBag()

    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return TableViewController.cellIdentifiers.count
    }

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: TableViewController.cellIdentifiers[indexPath.row])
        (cell as? SayHelloViewModelBindable)?.bind(to: viewModel)
        return cell!
    }
}



你需要根據(jù)自己的需要來(lái)決定采用哪一種方案。
第一種方案簡(jiǎn)單易行下愈,但是有一定的局限性纽绍。
第二種方案兼容性強(qiáng),但是定義及使用都略顯繁瑣势似。





參考文章:
RxSwift + MVVM: how to feed ViewModels



轉(zhuǎn)載請(qǐng)注明出處拌夏,謝謝~

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個(gè)濱河市履因,隨后出現(xiàn)的幾起案子障簿,更是在濱河造成了極大的恐慌,老刑警劉巖栅迄,帶你破解...
    沈念sama閱讀 218,941評(píng)論 6 508
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件站故,死亡現(xiàn)場(chǎng)離奇詭異,居然都是意外死亡毅舆,警方通過(guò)查閱死者的電腦和手機(jī)西篓,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 93,397評(píng)論 3 395
  • 文/潘曉璐 我一進(jìn)店門(mén),熙熙樓的掌柜王于貴愁眉苦臉地迎上來(lái)憋活,“玉大人岂津,你說(shuō)我怎么就攤上這事≡眉矗” “怎么了吮成?”我有些...
    開(kāi)封第一講書(shū)人閱讀 165,345評(píng)論 0 356
  • 文/不壞的土叔 我叫張陵,是天一觀的道長(zhǎng)辜梳。 經(jīng)常有香客問(wèn)我粱甫,道長(zhǎng),這世上最難降的妖魔是什么冗美? 我笑而不...
    開(kāi)封第一講書(shū)人閱讀 58,851評(píng)論 1 295
  • 正文 為了忘掉前任魔种,我火速辦了婚禮,結(jié)果婚禮上粉洼,老公的妹妹穿的比我還像新娘节预。我一直安慰自己,他們只是感情好属韧,可當(dāng)我...
    茶點(diǎn)故事閱讀 67,868評(píng)論 6 392
  • 文/花漫 我一把揭開(kāi)白布安拟。 她就那樣靜靜地躺著,像睡著了一般宵喂。 火紅的嫁衣襯著肌膚如雪糠赦。 梳的紋絲不亂的頭發(fā)上,一...
    開(kāi)封第一講書(shū)人閱讀 51,688評(píng)論 1 305
  • 那天,我揣著相機(jī)與錄音拙泽,去河邊找鬼淌山。 笑死,一個(gè)胖子當(dāng)著我的面吹牛顾瞻,可吹牛的內(nèi)容都是我干的泼疑。 我是一名探鬼主播,決...
    沈念sama閱讀 40,414評(píng)論 3 418
  • 文/蒼蘭香墨 我猛地睜開(kāi)眼荷荤,長(zhǎng)吁一口氣:“原來(lái)是場(chǎng)噩夢(mèng)啊……” “哼退渗!你這毒婦竟也來(lái)了?” 一聲冷哼從身側(cè)響起蕴纳,我...
    開(kāi)封第一講書(shū)人閱讀 39,319評(píng)論 0 276
  • 序言:老撾萬(wàn)榮一對(duì)情侶失蹤会油,失蹤者是張志新(化名)和其女友劉穎,沒(méi)想到半個(gè)月后古毛,有當(dāng)?shù)厝嗽跇?shù)林里發(fā)現(xiàn)了一具尸體翻翩,經(jīng)...
    沈念sama閱讀 45,775評(píng)論 1 315
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 37,945評(píng)論 3 336
  • 正文 我和宋清朗相戀三年喇潘,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了体斩。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 40,096評(píng)論 1 350
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡颖低,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出弧烤,到底是詐尸還是另有隱情忱屑,我是刑警寧澤,帶...
    沈念sama閱讀 35,789評(píng)論 5 346
  • 正文 年R本政府宣布暇昂,位于F島的核電站莺戒,受9級(jí)特大地震影響,放射性物質(zhì)發(fā)生泄漏急波。R本人自食惡果不足惜从铲,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,437評(píng)論 3 331
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望澄暮。 院中可真熱鬧名段,春花似錦、人聲如沸泣懊。這莊子的主人今日做“春日...
    開(kāi)封第一講書(shū)人閱讀 31,993評(píng)論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽(yáng)馍刮。三九已至信夫,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背静稻。 一陣腳步聲響...
    開(kāi)封第一講書(shū)人閱讀 33,107評(píng)論 1 271
  • 我被黑心中介騙來(lái)泰國(guó)打工警没, 沒(méi)想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人振湾。 一個(gè)月前我還...
    沈念sama閱讀 48,308評(píng)論 3 372
  • 正文 我出身青樓惠奸,卻偏偏與公主長(zhǎng)得像,于是被迫代替她去往敵國(guó)和親恰梢。 傳聞我的和親對(duì)象是個(gè)殘疾皇子佛南,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 45,037評(píng)論 2 355