ReactiveSwift + MVVM 登录demo详解

Author Avatar
killua167 5月 22, 2017

最近在研究Swift下的MVVM模式,就尝试用ReactiveCocoa来便捷绑定VM,怎么知道和ReactiveObjc的API相差甚远,原来API在5.0后就大改了,目前中文资料又不多,只能自己在Github和Stackoverflow上看老外的提问,同时也感谢EvilNOP的文章:零基础上手ReactiveSwift & ReactiveCocoa教程!

VM

先上代码:

class LoginViewModel: NSObject {

    var userNameSignal : Signal<String?, NoError>//接收用户名的信号
    var passWordSignal : Signal<String?, NoError>//接收密码的信号
    var validSignal : Signal<Bool, NoError>//用户名和密码是否合法的信号
    var loginAction : Action<(String, String), Bool, NoError>//登录的action

    init(_ signal1 : Signal<String?, NoError> , _ signal2 : Signal<String?, NoError>) {
        //获取用户名输入框和密码输入框文字变化的信号
        userNameSignal = signal1
        passWordSignal = signal2

        //合并信号
        validSignal = Signal.combineLatest(userNameSignal, passWordSignal).map{ $0!.characters.count >= 5 && $1!.characters.count >= 6}

        //根据合并的信号,创建控制登录按钮enable的属性
        let loginEnable = Property(initial: false, then: validSignal)

        //通过.map对输入框变化的信号进行映射
        let colorSignal : Signal<UIColor, NoError> = signal1.map { text in
            return (text?.characters.count)! > 3 ? .white : .red
        }
        //根据信号创建textfield的颜色属性
        tfColor = Property(initial: .white, then: colorSignal)


        loginAction = Action(enabledIf: loginEnable) {
            username, password in

            return SignalProducer<Bool, NoError> { observer, disposable in
                let success = (username == "admin") && (password == "password")
                //网络请求
                observer.send(value: success)
                observer.sendCompleted()
            }
        }

    }

}

Signal

//获取用户名输入框和密码输入框文字变化的信号
userNameSignal = signal1
passWordSignal = signal2

这里的signal其实是userNameTF.reactive.continuousTextValues传过来的,下文会说到。这里的信号是String类型,大家也可以通过.map方法将text的值映射成其他类型,例如:

let colorSignal : Signal<UIColor, NoError> = userNameSignal.map { 
    text in
    return (text?.characters.count)! > 6 ? .white : .red
}

合并信号

validSignal = Signal.combineLatest(userNameSignal, passWordSignal).map{ 
    $0!.characters.count >= 5 && $1!.characters.count >= 6
}

上面的代码中我们用Signal.combineLatest方法将usernameSignal和passwordSignal两个信号结合在一起,再将它们映射成一个Bool信号来表明username text field和password text field是否同时合法。然后可以绑定给button,控制button的isEnable属性,后面会说到。

Property

let loginEnable = Property(initial: false, then: validSignal)

Property首先接收一个初始的值,我们设置成false,之后这个property会随着signUpActiveSignal里面的值变化而变化。

Action

loginAction = Action(enabledIf: loginEnable) {
            username, password in

            return SignalProducer<Bool, NoError> { observer, disposable in
                let success = (username == "admin") && (password == "password")
                //网络请求
                observer.send(value: success)
                observer.sendCompleted()

            }
        }

Action是一个泛型为Action<Input, Output, SwiftError>,Action就是动作的意思,比如当用户点击了signInButton后应该发生的动作。Action可以有输入和输出,也可以没有。

上面的代码中,我们的输入来自username text field和password text field,输出为Bool(登录是否成功)。

    /// Initializes an action that will be conditionally enabled, and creates a
    /// `SignalProducer` for each input.
    ///
    /// - parameters:
    ///   - enabledIf: Boolean property that shows whether the action is
    ///                enabled.
    ///   - execute: A closure that returns the signal producer returned by
    ///              calling `apply(Input)` on the action.
    public convenience init<P: PropertyProtocol>(enabledIf property: P, _ execute: @escaping (Input) -> SignalProducer<Output, Error>) where P.Value == Bool {
        self.init(state: property, enabledIf: { $0 }) { _, input in
            execute(input)
        }
    }

enabledIf这个参数是一个bool的property,这个参数用来控制这个action的启用/禁用。后面一个参数是一个closure,它的原型为(Input) -> SignalProducer<Output, Error>),这个closure的Input来自于我们的username text field和password text field的值。

我们还需要为它返回一个SignalProducer<Output, Error>以便于添加副作用,例如网络请求是否成功等。

VC

先上代码

class ViewController: UIViewController {

    @IBOutlet weak var userNameTF: UITextField!
    @IBOutlet weak var passWordTF: UITextField!
    @IBOutlet weak var loginButton: UIButton!

    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view, typically from a nib.

        bindViewModel()
    }

    func bindViewModel() -> () {
        //初始化vm
        let viewModel = LoginViewModel.init(userNameTF.reactive.continuousTextValues, passWordTF.reactive.continuousTextValues)
        //把信号绑定给登录button
        loginButton.reactive.isEnabled <~ viewModel.validSignal
        //把颜色属性绑定给Textfield
        userNameTF.reactive.backgroundColor <~ viewModel.tfColor
        //通过CocoaAction实现button的点击
        loginButton.reactive.pressed = CocoaAction<UIButton>(viewModel.loginAction){
            _ in
            return (self.userNameTF.text!, self.passWordTF.text!)
        }
        //观察登录是否成功
        viewModel.loginAction.values.observeValues({ success in
            if success {
                print("login : \(success)" )
                //VC跳转
            }
        })
    }

}

属性绑定

loginButton.reactive.isEnabled <~ viewModel.validSignal

userNameTF.reactive.backgroundColor <~ viewModel.tfColor

关于<~,官方文档是这样写的:
<~运算符是提供了几种不同的绑定属性的方式。注意这里绑定的属性必须是 MutablePropertyType类型的。

property <~ signal将一个属性和信号绑定在一起,属性的值会根据信号送过来的值刷新。
property <~ producer 会启动这个producer,并且属性的值也会随着这个产生的信号送过来的值刷新。
property <~ otherProperty将一个属性和另一个属性绑定在一起,这样这个属性的值会随着源属性的值变化而变化。

留意XXX.reactive.xxx 如果这个属性基本上是BindingTarget类型,可以作为绑定目标类型,写在<~的左边

CocoaAction

loginButton.reactive.pressed = CocoaAction<UIButton>(viewModel.loginAction){
            _ in
            return (self.userNameTF.text!, self.passWordTF.text!)
        }

viewModel.loginAction.values.observeValues({ success in
            if success {
                print("login : \(success)" )
                //VC跳转
            }
        })

loginButton.reactive.pressed是一个CocoaAction,当用户点击了signInButton就会触发这个CocoaAction,CocoaAction同时会帮你控制loginButton的启用/禁用状态。
最后通过action.values.observeValues我们可以观察到loginAction事件,也就是我们登录成功与否的值。

最后

MVVM中VM和C就大概这样,一个基本的登录逻辑就完成,代码非常少。ReactiveSwiftReactiveCocoa是好东西,就是作者写的文档太少和太混乱了。。。
上面有什么错漏,欢迎指正哈!