RxSwift MVVM实操-从一个注册demo说起

背景

在学习了RxSwift官方的demo以及各种操作符后,对RxSwift会有一个大致的了解,但在实际开发过程中并不是有很多机会去使用,主要是因为使用生疏的开发技能会带来开发时间上与产品质量上的风险,为了避免”不熟悉->不敢用->用的少->不熟悉->不敢用->用的少…”的恶性循环,个人觉得一种比较好的方法是在业余时间选择一些常见的功能使用RxSwift实现一遍,一方面加深对RxSwift的理解,另一方面,在实际项目中遇到类似的业务场景时,如果打算使用RxSwift的话则不再会心中没底。

本文选择了一个常见的注册功能作为例子,采用RxSwift+MVVM的形式去实现。文中会详细说明从需求分析到代码实现过程中的每个步骤。

Ok, Let’s go.

Demo 项目地址

需求说明

提供一个注册页面,页面中有三个输入框,分别用于输入用户名,密码,确认密码。用户在相应的输入框内输入时App需要对输入的值进行校验,校验失败时,需要在相应的输入框下方提示失败原因。
页面中还有一个注册按钮,当用户名,密码,确认密码校验全部通过后,注册按钮启用,用户点击注册按钮
App执行注册操作,注册成功后,则自动进行登录操作,登录成功后退出注册页面,如果注册或登录操作有任何一个失败,则给出提示。

用户名校验逻辑
  • 长度不能小于6个字符
  • 不能包含!@#$%^&*()这些特殊字符
  • 在用户输入后停顿时长达到0.5s时才进行校验
  • 在校验中时,需要在输入框下方展示 ‘校验中…’,当校验失败时,需要在输入框下方展示失败原因
密码校验逻辑
  • 长度不能小于6个字符
  • 不能包含!@#$%^&*()这些特殊字符
  • 如果确认密码输入框有值,则需要与确认密码保持一致
  • 在用户输入后停顿时长达到0.5s时才进行校验
  • 在校验中时,需要在输入框下方展示 ‘校验中…’,当校验失败时,需要在输入框下方展示失败原因
确认密码校验逻辑
  • 必须与密码输入框的值相同,如果密码输入框没有内容则不进行此项校验
  • 在用户输入后停顿时长达到0.5s时才进行校验
  • 在校验中时,需要在输入框下方展示 ‘校验中…’,当校验失败时,需要在输入框下方展示失败原因

界面UI

MVVM 各层功能划分

View
  • 提供注册界面UI,包含3个输入框以及注册按钮。
  • 接受用户输入和按钮点击事件并将其传递给 ViewModel
  • 处理ViewModel反馈的信息。
ViewModel

接收并处理View中用户的输入以及按钮点击事件,并将结果反馈给 View

Model

业务服务层,提供用户名校验、密码校验、注册、登录服务。

数据流分析

在MVVM中,ViewModel扮演着一个非常重要的角色,其作为View与Model之间的纽带,接收View传递过来的事件,然后调用Model中的服务进行处理,最后将处理结果反馈至View,View再根据反馈信息进行处理。简单点说,ViewModel实现了View与Model之间的双向绑定。在实际开发过程中,最重要的就是要分析出View中有哪些事件流需要处理,ViewModel中又会有哪些输出流需要View处理,不管是view中产生的事件还是ViewModel产生的处理结果,最终都是抽象成数据流, 下面是针对当前demo的一个数据流的分析:

sequenceDiagram
View->>ViewModel: '用户名' 用户输入数据流
View->>ViewModel: '密码' 用户输入数据流
View->>ViewModel: '确认密码' 用户输入数据流
View->>ViewModel: '注册按钮' 用户点击事件流

ViewModel->>View: '用户名'验证结果输出流
ViewModel->>View: '密码'验证结果输出流
ViewModel->>View: '确认密码'验证结果输出流
ViewModel->>View: '登录'结果输出流
ViewModel->>View: '注册按钮'可用性输出流

==注:上图中的’输入’与’输出’是相对于ViewModel而言的==

开始编码

首先,根据MVVM 各层功能划分,定义下面几个Class:
- SignUpVC 注册页面(View)
- SignUpVM 注册页面的 ViewModel
- SignUpService 注册相关服务(Model)

SignUpService: 负责提供业务服务接口,当前提供的接口如下:

class SignUpService{
    //校验'用户名'
    static func validateUsername(username: String) -> Observable<ValidateResult>
    //校验'密码'
    static func validatePsd(username: String) -> Observable<ValidateResult>
    //执行注册操作
    static func signUp(username: String, psd: String) -> Observable<Bool>
    //执行登录操作
    static func signIn(username: String, psd: String) -> Observable<Bool>
}

==注:异步操作的结果在Rx中都是可以用数据流表示的,因为异步操作的结果就和数据流中的数据一样,是不定时的产生的。只是有的异步操作只有会产生一个结果就结束,比如网络请求,而有的异步操作则会持续不断输出结果,比入网络状态监听。==

其中,ValidateResult定义如下:

enum ValidateFailReason{
    case emptyInput
    case other(String)
}
enum ValidateResult {
    case validating
    case ok

    case failed(ValidateFailReason)
    var isOk: Bool {
        if case ValidateResult.ok = self {
            return true
        }else{
            return false
        }
    }
}

**接着根据数据流向分析,我们
SignUpVM中定义输入数据流与输出数据流:**

class SignUpVM {
    struct Input {
        //'用户名'输入流
        let username: Observable<String>
        //'密码'输入流
        let psd: Observable<String>
        //'确认密码'输入流
        let confirmPsd: Observable<String>
        //'注册按钮点击事件'输入流
        let signUpBtnTaps: Observable<Void>
    }
    struct Output {
        //'用户名验证结果'输出流
        var usernameValidateResult: Observable<ValidateResult>!
        //'密码验证结果'输出流
        var psdValidateResult: Observable<ValidateResult>!
        //'确认密码验证结果'输出流
        var confirmPsdValidateResult: Observable<ValidateResult>!
        //'注册按钮enable设置'输出流
        var signUpEnable: Observable<Bool>!
        //'登录结果'输出流
        var signInResult: Observable<Bool>!
    }
}

==注:下面代码中的outputinput分别表示struct Outputstruct Input的实例==

这里说明一下每个输入数据流中的数据是什么:
- input.username ‘用户名’输入流: 用户在’用户名’输入框输入的字符串
- input.psd ‘密码’输入流: 用户在’密码’输入框输入的字符串
- input.confirmPsd ‘确认密码’输入流: 用户在’确认密码’输入框输入的字符串
- input.signUpBtnTaps ‘注册按钮点击事件’输入流: ‘注册按钮’点击事件

然后我们看一下在SignUpVC中每个输入流是如何产生的,下面列出了SignUpVC中SignUpVM.Input初始化代码片段:

==注:SignUpVC是MVVM中的View层,负责将View中产生的事件传递给相应的ViewModel。==

SignUpVM.Input(
            username: _usernameTf.rx.value
                                    .orEmpty
                                    .asObservable()
                                    .distinctUntilChanged()
                                    .debounce(0.5, scheduler: MainScheduler.instance),

            psd: _psdTf.rx.value
                          .orEmpty
                          .asObservable()
                          .distinctUntilChanged()
                          .debounce(0.5, scheduler: MainScheduler.instance),

            confirmPsd: _confirmPsdTf.rx.value
                                        .orEmpty
                                        .asObservable()
                                        .distinctUntilChanged()
                                        .debounce(0.5, scheduler: MainScheduler.instance),

            signUpBtnTaps: _signUpBtn.rx.tap.asObservable()
        ))

代码有点长,但是很简单,我们一个个看,首先看username参数的值, 它的构成有点长,我们一段段分析:_usernameTf.rx.value 表示’用户名输入框’的值所组成的数据流,当输入框内容变化、失去焦点、初始化时其值会被数据流发射。orEmpty是为了将_usernameTf.rx.value中的nil转换空字符串,distinctUntilChanged则是为了过滤掉与上一次相同的值。debounce作用是当用户在输入框内输入字符后0.5内未再有输入,则此时输入框内的值会被数据流中发射。简单点说其作用就是为了在用户输入时降低实时校验的频率。

psdconfirmPsdusername类似,此处就不多说了。

signUpBtnTaps参数的值很简单:_signUpBtn.rx.tap.asObservable()表示点击事件数据流。

接着我们分析一下SignUpVM中每个输出流是如何定义的:

==注:ViewModel在接收到View的数据流后,会去执行一些业务逻辑,
产生的结果则会做为输出流再传递给View,用一个函数表达式表示就是:
outputStream = f (inputStream)==

  • output.usernameValidateResult: ‘用户名验证结果’输出流。

SignUpVM接收到 input.username(‘用户名’输入数据流)的数据时,SignUpVM会调用SignUpService提供的接口进行验证,验证结果作为output.usernameValidateResult输出数据流的数据,代码如下:

output.usernameValidateResult = input.username
            .flatMapLatest { (username) -> Observable<ValidateResult> in
                return SignUpService.validateUsername(username: username)
            }
            .share(replay: 1)

flatMapLatest 可以简单的理解其作用为: 用’链式调用’的形式串联异步操作。这样就无需通过回调嵌套的形式处理异步操作的串联。

图解:
首先看下如果用 flatmap 是什么结果:
image

下面则是 flatmapLatest的:
image

由上图可见,flatMap不管校验结果什么时候返回,都会被output.usernameValidateResult数据流发射,若是flatMapLatest,output.usernameValidateResult 只会将最近一次的校验结果发射。’最近一次’是指触发校验行为时,如果先前的校验行为还未产生结果,那么先前的校验行为的结果将会被丢弃,只有此次触发的校验行为的结果才有机会被output.usernameValidateResult输出(当然,如果下次校验行为触发时,此次校验行为还未完成,那么此次校验行为的结果则同样会被丢弃,以此往复)。

  • output.psdValidateResultoutput.usernameValidateResult类似,这里就直接列出代码:
output.psdValidateResult = input.psd
            .flatMapLatest { (psd) -> Observable<ValidateResult> in
                return SignUpService.validatePsd(psd: psd)
            }
            .share(replay: 1)
  • confirmPsdValidateResult: ‘确认密码验证结果’输出流。
    SignUpVM会在用户输入密码确认密码时对两个密码进行比对,一致则通过校验,不一致则校验失败,代码如下:
output.confirmPsdValidateResult = Observable<ValidateResult>
            .combineLatest(input.psd,
                           input.confirmPsd,
                           resultSelector: { (psd: String, confirmPsd: String) -> ValidateResult in
                                if(psd.isEmpty || confirmPsd.isEmpty){
                                    return .failed(.emptyInput)
                                }else if(psd != confirmPsd){
                                    return .failed(.other("两次密码不一致"))
                                }else{
                                    return .ok
                                }
                        })
        .share(replay: 1)

因为要进行比对,所以无论此时用户是在输入密码确认密码SignUpVM都需要能够从相应的输入流(input.psdinput.confirmPsd)中获取这个两个字段当前最新的值,
combineLatest操作符则是实现该目的的关键,该操作符会从指定的多个数据流中获取最近一次发射的数据,将这些数据传递给一个回调函数,该函数进行处理并返回一个值,这个值会被combineLatest所生成的那个数据流发射。
combineLatest图解
- output.signUpEnable:’注册按钮enable设置’输出流。
用户名,密码,确认密码都通过验证后,该数据流发射’启用’信号,反之则发射’禁用’信号,代码如下:

        output.signUpEnable = Observable<Bool>
                            .combineLatest(output.usernameValidateResult,
                                           output.psdValidateResult,
                                           output.confirmPsdValidateResult,
                                           resultSelector: { (
                                            usernameValidateResult: ValidateResult,
                                            psdValidateResult: ValidateResult,
                                            confirmPsdValidateResult: ValidateResult) -> Bool in

                                            return usernameValidateResult.isOk
                                                && psdValidateResult.isOk
                                                && confirmPsdValidateResult.isOk

        })

一眼看上去有点复杂,我们仔细分析一下:启用/禁用’注册’按钮是取决于用户名,密码,确认密码的验证结果的,这三个字段验的证结果数据流已经定义过了,所以此处只需要用combineLatest将三者组合,当任何一个字段有验证结果产生时,则会进行一次判断以决定启用或禁用’注册’按钮。

  • signInResult:’登录结果’输出流。
struct UsernameAndPsd{
    let username: String
    let psd: String
}

let usernameAndPsdSeq: Observable<UsernameAndPsd>  = Observable.combineLatest(input.username, input.psd) { (username, psd) -> UsernameAndPsd in
    return UsernameAndPsd(username: username, psd: psd)
}

output.signInResult = input.signUpBtnTaps
                            .withLatestFrom(usernameAndPsdSeq)
                            .flatMapLatest {(unamePsd: UsernameAndPsd) -> Observable<(Bool,UsernameAndPsd)> in
                                return SignUpService.signUp(username: unamePsd.username,
                                                            psd: unamePsd.psd)
                                                     .map{ (isSignSuccess) -> (Bool,UsernameAndPsd) in
                                                        return (isSignSuccess, UsernameAndPsd(username: unamePsd.username,psd: unamePsd.psd))
                                                     }
                            }.flatMapLatest{ (e: (isSignUpSuccess: Bool,unameAndPsd: UsernameAndPsd )) -> Observable<Bool> in
                                if e.isSignUpSuccess{
                                    return SignUpService.signIn(username: e.unameAndPsd.username, psd: e.unameAndPsd.psd)
                                }else{
                                    return Observable<Bool>.of(false)
                                }
                            }

恩,又是一大串,但实际上逻辑是比较清晰的,分析前先说一下UsernameAndPsd,很简单,它就是为了方便同时传递username 和 psd而做的一个封装。

下面分析逻辑:’注册’按钮被点击后,SignUpVM通过input.signUpBtnTaps拿到点击事件,之后则是要进行’注册’操作,我们看到,’注册’的接口是需要用户名密码的,而input.signUpBtnTaps传递点击的事件并不带有任何上下文信息,所以通过input.signUpBtnTaps是无法拿到当前界面上用户输入的用户名密码,但是SignUpVM中是拥有用户名密码的输入流的,所以还是那个套路,使用combineLatest用户名密码输入流组合一下,然后每次当用户名密码的输入流产生数据时都会被发送一份到那个组合数据流中,这样,’注册’按钮点击事件发生时,我们就可以去那个组合数据流中拿用户名密码,而'拿'这个操作则是由withLatestFrom操作符实现。

目前为止,我们完成了’注册’按钮点击事件处理以及获取用户名密码这两个步骤,接下来则是使用获取到的用户名密码进行注册,注册接口是个异步操作,所以使用flatMapLatest进行串联。接着进行’登录’操作,注意,在登录操作前对’注册’操作是否成功进行了判断,成功才会继续执行’登录‘,失败则直接抛出错误信号。

至此,整个注册功能的核心已经完成,需要注意的是,SignUpVM初始化时的这一系列输入流到输出流的转换,并没有产生任何side effect,我们只是定义了该如何转换,换句话说,我们只是定义了 outputStream = **f** (inputStream), 而 f则是需要等到inputStream有数据产生时才会执行。

总结

在用响应式的思维实现业务时需要关注三个点:
- 要处理哪些事件(比如:网络状态变更、页面滚动、页面关闭、动画执行完毕、接口请求出错等等)
- 怎么处理
- 处理结果怎么返回

以上三点分别抽象为:输入流(要处理的事件),变换函数(怎么处理),输出流(处理结果)。
所以归根结底,响应式编程就是面向数据流的编程。
难点在于,在开始编码前就需要能够根据业务需求精确的分析出各种数据流,对于不熟悉的业务场景,确实难以下手。

再谈RxSwift,其实RxSwift本质上就一个功能—-回调,但是通过采用’订阅数据流’的形式,能够将回调行为衍生出的各种复杂问题以一种可视化的符合人类思维逻辑的形式进行解决。

RxSwift和响应式编程又是什么关系?
吃饭与筷子的关系吧,你可以不用筷子吃饭,但是用筷子会更方便。

最后,以上只是个人在学习RxSwift过程中的一些感悟,难免会有些理解错误的地方,如有发现,还望斧正。

  • 1
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
好的,下面是一个简单的MVVM Demo示例,主要实现了数据绑定和视图模型。 首先,我们需要创建一个模型类和视图模型类。 模型类: ``` class User { String name; int age; User(this.name, this.age); } ``` 视图模型类: ``` import 'package:flutter/material.dart'; import 'user.dart'; class UserViewModel extends ChangeNotifier { User _user; UserViewModel(User user) { _user = user; } String get name => _user.name; set name(String value) { _user.name = value; notifyListeners(); } int get age => _user.age; set age(int value) { _user.age = value; notifyListeners(); } } ``` 在视图上,我们可以将数据绑定到`UserViewModel`, ``` import 'package:flutter/material.dart'; import 'package:mvvm_demo/user.dart'; import 'package:mvvm_demo/user_view_model.dart'; import 'package:provider/provider.dart'; void main() { runApp(MyApp()); } class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return MaterialApp( title: 'MVVM Demo', home: ChangeNotifierProvider<UserViewModel>( create: (context) => UserViewModel(User('User Name', 20)), child: HomePage(), ), ); } } class HomePage extends StatelessWidget { @override Widget build(BuildContext context) { final userViewModel = Provider.of<UserViewModel>(context); return Scaffold( appBar: AppBar( title: Text('MVVM Demo Home'), ), body: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Container( margin: EdgeInsets.all(16.0), child: TextField( decoration: InputDecoration( border: OutlineInputBorder(), labelText: 'Name', ), onChanged: (value) { userViewModel.name = value; }, ), ), Container( margin: EdgeInsets.all(16.0), child: TextField( decoration: InputDecoration( border: OutlineInputBorder(), labelText: 'Age', ), onChanged: (value) { userViewModel.age = int.parse(value); }, ), ), Container( margin: EdgeInsets.all(16.0), child: Text( 'User Name: ${userViewModel.name} \nUser Age: ${userViewModel.age}'), ), ], ), ); } } ``` 在这个示例中,我们使用`provider`库来管理视图模型的状态,当视图模型中的数据发生变化时,我们可以通过`notifyListeners()`方法通知视图重新渲染。 希望这个简单的MVVM Demo示例对你有所帮助。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值