将你的ViewModel建模成为函数

213 阅读9分钟

注意:这篇文章假设你有RxSwift的基础并且对MVVM有大体的理解。如果你没有,网上有数不清的入门教程先了解一下。

另注:我最近很关注Brandon WilliamsStephen Celis提出的Point-Free如果你还没有看过可以看一下。他们提出的内容对于Swift开发者来说绝对是必不可少的,订阅它们3可能是您今年最有价值的投资。

求大佬们点个关注,会定期写原创和翻译国外最新文章,跟大佬们一起学习进步,有问题或者建议欢迎加微信ruiwendelll,拉大家进技术交流群,一起探讨学习,谢谢了!

介绍

关于如何结合RxSwift和MVVM已经有了太多文章和讨论。在Grailed,我们一直热衷于与社区一起创新,并有动力改进我们的代码并创造更好的代码,为我们的客户提供更可靠的产品。基于这个目标,我们已经一直在使用一种MVVM的模式,它寻求函数式编程和RxSwift,以提供可靠性,可测试性和稳定性。我们喜爱它的很多房买你,但是我们遇到了很多MVVM开发者很熟悉的一系列问题。

问题

有时候如何组建你的代码会变得很不清晰。在网上的几十种MVVM架构变种中,似乎每一个对View层如何与ViewModel进行交互都不相同。缺乏明确的模式可能会使MVVm中的开发感到特殊和不一致,这将导致可维护性问题。

ViewModel可能会变得难以置信的啰嗦。这都是由于MVVM中结构的缺乏,有些选择将更明确的输入输出合约写在ViewModel里面,但根据传统这是以大量样板为代价来实现的。

同时,有些版本不阻止ViewModel的消费者错误使用它们的API。从你的View层订阅ViewModel的输入众所周知是违反设计模式的,但是我看到不同的生产代码库都存在这一现象。理想情况下,编译器会防止我们犯这个错误。

由于Swift类和结构体的初始化规则ViewModel可能很难去设置。比如,当你不得不在你的类或者结构体中设置输出Observable为属性时,那些输出取决于Subject的输入。你有时候会遇到你不能引用self直到属性初始化完成之前的情况,但是你不呢个初始化你的属性,因为你需要引用self。

忘记将你的ViewModel的输入和输出绑定而编译器也不会在我们出错的时候帮助我们也是很常见的。在它帮助我们不让我们犯这么愚蠢的错误时,编译是是我们的朋友。

解决方案

现在我们已经了解了使用RxSwift进行MVVM模式编程的一些难点。让我们通过用一种流行的MVVM方式写的简单例子来看看我们如何可以做得更好。

所有开发人员都知道编写纯函数可以是代码更具有可测试性和理解性,否则会变得难以理解,我们要实现它如果不是不可能。问题是我们写的太多类型的代码不能完全适合纯函数,因此我们独立将代码尽可能多的部分建模成为纯函数。这种见解促使很多MVVM开发人员创建他们的ViewModel,以便拥有一组明确的输入和输出,从而我们可能将它们看作更类似于函数的东西。

函数的输入叫做Subject,输出是callback,变量或者Observable。View的业务逻辑在ViewModel中被建模为输入和输出 的转换,多数情况下在ViewModel的初始化器中。Kickstarter open source app可能是第一个也是最著名的迭代。即使它使用RactiveSwift写的,但是这些想法非常相似。这些开源的应用程序打开了许多开发人员的眼界,关于如何使用MVVM来实现APP的可测试性和稳定性。

首先让我们来创建一个经典的RxSwift例子,一个简单的具有姓名和密码输入框的登陆界面,当然还有一个登陆按钮。我们想让姓名和密码输入框都被填充的时候登陆按钮才可用,当用户点击按钮时我们希望弹出类似登陆成功的提示(我们将在这里进行硬编码,并在以后的文章中讨论如何处理网络请求)。这是一个我们遵循Kickstarter 应用程序中列出的模式编写此代码的示例。

// ************** View Model **************
protocol LoginViewModelInputs {
    func usernameChanged(_ username: String)
    func passwordChanged(_ password: String)
    func loginTapped()
}

protocol LoginViewModelOutputs {
    var loginButtonEnabled: Observable<Bool> { get }
    var showSuccessMessage: Observable<String> { get }
}

protocol LoginViewModelType {
    var inputs: LoginViewModelInputs { get }
    var outputs: LoginViewModelOutputs { get }
}

class LoginViewModel: LoginViewModelInputs, LoginViewModelOutputs, LoginViewModelType {

    let loginButtonEnabled: Observable<Bool>
    let showSuccessMessage: Observable<String>

    init() {
        self.loginButtonEnabled = Observable.combineLatest(
            _usernameChanged,
            _passwordChanged
        ) { username, password in
            !username.isEmpty && !password.isEmpty
        }

        self.showSuccessMessage = _loginTapped
            .map { "Login Successful" }
    }

    private let _usernameChanged = PublishRelay<String>()
    func usernameChanged(_ username: String) {
        _usernameChanged.accept(username)
    }

    private let _passwordChanged = PublishRelay<String>()
    func passwordChanged(_ password: String) {
        _passwordChanged.accept(password)
    }

    private let _loginTapped = PublishRelay<Void>()
    func loginTapped() {
        _loginTapped.accept(())
    }

    var inputs: LoginViewModelInputs { return self }
    var outputs: LoginViewModelOutputs { return self }
}

// ************** View Controller **************
class LoginViewController: UIViewController {
    private let usernameTextField = UITextField()
    private let passwordTextField = UITextField()
    private let loginButton = UIButton()

    private let viewModel: LoginViewModelType
    private let disposeBag = DisposeBag()

    init() {
        self.viewModel = LoginViewModel()
        super.init(nibName: nil, bundle: nil)

        disposeBag.insert(
            // Inputs
            loginButton.rx.tap.asObservable()
                .subscribe(onNext: viewModel.inputs.loginTapped),

            usernameTextField.rx.text.orEmpty
                .subscribe(onNext: viewModel.inputs.usernameChanged),

            passwordTextField.rx.text.orEmpty
                .subscribe(onNext: viewModel.inputs.passwordChanged),

            // Outputs
            viewModel.outputs.loginButtonEnabled
                .bind(to: loginButton.rx.isEnabled),

            viewModel.outputs.showSuccessMessage
                .subscribe(onNext: { message in
                    print(message)
                    // Show some alert here
                })
        )
    }
}

对于如此小的屏幕,这里有很多内容,所以花一点时间来消化它。我真的很喜欢这种风格:

  1. ViewModel创建了一个非常明确的规定,关于它的功能以及应该如何使用它
  2. 很少有方法可以错误地使用ViewModel的API
  3. ViewModel没有副作用
  4. 从外部查看此ViewModel并了解如何测试它非常容易

总的来说,我认为这种权衡取舍是值得的。我们在一个小的额外样板上花了点时间并在我们的ViewModel中有些杂乱,从而得到了一个很清楚的关于ViweModel能的功能,它非常明确并且容易推理。这种易于推理使我们能够将ViewModel视为纯函数的抽象形式,我们传递输入和输出。

因为这些输入通常是用户操作,而输出是副作用。这对于你为一系列用户行为并确保它们产生的副作用能像你期望的那用触发而写出更高级的测试用例有好处。这允许您编写更广泛的高级“功能”测试集,测试应用程序除了更传统的单元测试之外用户将如何使用它。

革命

现在我怕们提升了代码的可测试性,并为我们的ViewModel提供了安全且容易理解的API,但是我们还有很多想做的。我们是否可以消除这些样板而不用牺牲明确性和安全性。

答案是yes!为了得到这个解决方案,回过头去看一下第一原则是值得的。我们希望想对待纯函数一样对待我们的ViewModel。我们希望有明确的输入和输出来方便测试。Swift中有一个简单又没什么代价的方式去接收输入返回输出:functions。所以我们可以使用函数作为我们的ViewModel而不是一个类或结构体。想想我们应该怎么做。

不是一个LoginViewModelInputs代理,我们可以把输入作为一个函数的参数进行传递。同样,不用LoginViewModelOutputs代理,我们可以从函数得到返回的输出Observable。这听起来很奇怪,让我们来看下例子。

// ************** View Model **************
func loginViewModel(
    usernameChanged: Observable<String>,
    passwordChanged: Observable<String>,
    loginTapped: Observable<Void>
) -> (
    loginButtonEnabled: Observable<Bool>,
    showSuccessMessage: Observable<String>
) {
    let loginButtonEnabled = Observable.combineLatest(
        usernameChanged,
        passwordChanged
    ) { username, password in
        !username.isEmpty && !password.isEmpty
    }

    let showSuccessMessage = loginTapped
        .map { "Login Successful!" }

    return (
        loginButtonEnabled,
        showSuccessMessage
    )
}

// ************** View Controller **************
class LoginViewController: UIViewController {
    private let usernameTextField = UITextField()
    private let passwordTextField = UITextField()
    private let loginButton = UIButton()

    private let disposeBag = DisposeBag()

    init() {
        super.init(nibName: nil, bundle: nil)

        let (
            loginButtonEnabled,
            showSuccessMessage
        ) = loginViewModel(
            usernameChanged: usernameTextField.rx.text.orEmpty.asObservable(),
            passwordChanged: passwordTextField.rx.text.orEmpty.asObservable(),
            loginTapped: loginButton.rx.tap.asObservable()
        )

        disposeBag.insert(
            loginButtonEnabled
                .bind(to: loginButton.rx.isEnabled),

            showSuccessMessage
                .subscribe(onNext: { message in
                    print(message)
                    // Show some alert here
                })
        )
    }
}

我们的ViewModel神奇的转换成了函数,而且ViewController不用再调LoginViewModel.init(),我们调用loginViewModel(usernameChanged:passwordChanged:loginTapped:)。我们看看做了什么修改。

  1. 我们完全消除了输入输出代理(还有很多这样的模板代码)有利于函数参数作为输入而命名参数元组作为输出。
  2. 我们不再需要把输入桥接为Observable因为View层已经给出了Observable甚至消除了样板。
  3. 我们在ViewController中做了更少的绑定/订阅操作,显著提高了信噪比。
  4. 如果我们忘记将输入传递到ViweModel我们会得到一个编译器报错因为Swift中函数必须提供所有的参数。
  5. 如果我们忘记使用输出,由于结构了我们的输出元组,我们会接收到编译器unused variable的警告。
  6. 如果在不同的界面中重用一个ViewModel,作为一个开发者我可以明确地选择使用_来无视一个确切的输出。这让review的人和未来的维护者明白这是故意的遗漏,而不是我们忘记绑定。
  7. 我们不用再处理类和结构体的初始化规则因为我们不再使用它们了。
  8. 我们将整体实现缩减了约30%,我们的ViewModel类代码量减少了约50%。

一旦我们克服了不适用对象的陌生感。基本上对于每种可衡量的方式这都是一个巨大的进步。在我们犯错时让编译器提醒我们是一个重大的进步,并且还消除了大部分模板代码。

结论

把ViewModel建模成为函数一开始看起来可能很疯狂,但是我们在应用程序中始终将业务逻辑作为函数进行编写,如果不是一大块业务逻辑,还有什么是ViewModel呢?当我抛弃对ViewModel的传统观点并拥抱新朋友--函数时,我们为编写反应式MVVM的许多困境发现了一个简单而优雅的解决方案。

通过代理实现的这一编码风格来自于Kickstarter pagination的逻辑,已经在生产中存在了三年多,并且已经在其代码库的近几十个地方重复使用。像上面示例所展示的一样,这种方法不是特定域ViewModel,而是在大规模生产代码库中经过实战测试,而不仅仅是像我们的登陆界面这样的demo

Swift和其他开发社区都都存在一种共同模式,即创建一个封装一些数据并提供该数据访问器的对象。无论何时我们东可以使用相同的转换,像把ViewModel转换为函数一样。