将视图模型建模为函数
注意:本文假设您了解RxSwift的基础知识或对MVVM有了一般的了解。 如果您不这样做,Internet上有大量令人难以置信的资源(包括下面链接的资源)可以帮助您入门。
另一个注意事项: Point-Free的 Brandon Williams 和 Stephen Celis 最初对这个想法睁开了眼睛 。 如果您尚未签出,则应该这样做。 他们发布的内容对于Swift开发人员来说绝对是必不可少的,订阅可能是您今年做出的最有价值的投资。
介绍
关于如何将RxSwift与MVVM配对,已经写了成千上万的单词,并进行了数小时的讨论。 在Grailed,我们一直热衷于与社区进行创新,以改善我们的代码并为我们的消费者创造更好,更可靠的产品。 出于这个目标,我们一直在使用一种MVVM形式,该形式通过函数式编程和RxSwift来提供可靠性,可测试性和稳定性。 我们喜欢它的许多方面,但是遇到了大多数MVVM开发人员都会熟悉的一系列问题。
问题所在
有时可能不清楚如何组织代码。 在互联网上漂浮的数十种MVVM变体中,每个视图似乎对视图层如何与其视图模型进行交互都有不同的看法。 缺少清晰的模式可能会使MVVM中的开发感到特别和不一致,从而可能导致可维护性问题。
视图模型也可能变得非常冗长。 由于许多版本的MVVM中缺乏结构,其他人选择为视图模型编写更为明确的输入和输出合同,但传统上这是以大量的样板为代价的。
同时,其他版本也不能防止视图模型的使用者错误地使用其API。 从视图层订阅视图模型的输入众所周知是一种反模式,但这是我在多个生产代码库中看到的。 理想情况下,编译器将阻止我们完全犯此错误。
由于Swift的类和结构初始化规则,视图模型也可能变得难以设置。 例如,当您必须将输出Observable
设置为类或结构的属性时,这些输出取决于输入Subject
。 有时您会遇到这样的情况:在完成所有属性的初始化之前,您不能引用self
,但是由于需要引用self
,因此无法初始化属性。
忘记绑定视图模型的输入或输出也很容易,而且编译器在做错事情时也不会帮助我们找出问题。 编译器是我们的朋友,如果它可以帮助我们,那就太好了,这样我们就不会犯这种愚蠢的错误。
一种解决方案
现在,我们已经解决了RxSwift带来的MVVM的一些痛点,让我们以一个流行的MVVM风格编写一个简单的代码示例,并研究如何改进它。
所有笔触的开发人员都知道,编写纯函数可以解锁可测试性和可理解性的级别,否则,即使不是不可能,也很难实现。 问题在于我们编写的许多类型的代码无法完全适合纯函数,因此我们努力为尽可能多的模型建模纯函数。 这种见解驱使许多MVVM开发人员创建他们的视图模型,以具有一组明确的输入和输出,以便我们可以更类似于功能地对待它们。
输入是函数调用或Subject
,输出是回调,可变变量或Observable
,并且视图的业务逻辑在视图模型内部建模为输入到输出的转换,主要是在视图模型的初始化程序中。 Kickstarter开源应用可能是第一个,也是最知名的迭代。 尽管它是用ReactiveSwift编写的,但它们的想法几乎是相同的。 这个开源的应用程序使许多开发人员(包括我自己)对我们如何使用MVVM实现应用程序的可测试性和稳定性打开了眼睛。
让我们以经典的RxSwift为例,使用用户名和密码字段以及登录按钮的简单登录表单。 我们只希望在用户名和密码均填写后才启用该按钮,并且当用户单击该按钮时,我们希望显示一些有关其成功登录的消息(我们将在此处对其进行硬编码,在以后的文章中将详细介绍如何处理网络)。 这是一个示例,说明如果遵循Kickstarter应用程序中列出的模式,该如何编写。
如此小的屏幕在这里有很多事情要做,所以花一点时间来消化它。 我非常喜欢这种风格的几件事:
- 视图模型创建了一个非常明确的契约,关于它的功能以及应如何使用它
- 很少有错误使用视图模型API的方法
- 视图模型没有副作用
- 从外部查看此视图模型并了解如何对其进行测试非常容易
尽管有这些好处,它也有一些相当明显的缺点:
- 为了获得明确的契约,我们非常享受,我们必须编写大量的样板:两个协议复制视图模型的接口,一个协议仅擦除接口以暴露输入和输出,以及一堆私有属性和公共接口给他们
- 信噪比非常低,这使得在将来再次阅读时很难准确地确定我们关心的部分(我们的业务逻辑几乎完全存在于视图模型的
init
) - 在实践中,很容易忘记订阅其中一个绑定,这会减慢开发速度甚至导致生产错误
总的来说,我认为这种权衡是值得的。 我们已经在视图模型中投资了一些额外的样板和一些混乱,以换取非常明确且易于理解的外部API,并清楚地了解了视图模型的功能。 这种轻松的推理使我们几乎可以将视图模型视为纯函数的抽象形式,在其中我们传入输入并接收输出。
因为这些输入通常是用户操作,而输出是副作用,所以它还为您提供了编写高级测试的好处,其中您可以浏览一系列用户操作/输入并确保引起副作用的操作被触发以您期望的方式。 这使您可以编写更广泛的高级“功能”测试集,除了更传统的单元测试外,还可以测试应用程序用户的体验。
进化
因此,我们改善了代码的可测试性,并为视图模型提供了安全且易于理解的API,但仍然让我们有更多需求。 我们可以在不牺牲明确性和安全性的前提下消除大部分或全部样板吗?
听到答案是肯定的 ,您可能会感到震惊! 想想这个解决方案,值得回到这种风格的首要原理。 我们希望将我们的视图模型与纯函数一样对待,并使用可以轻松测试其正确性的显式输入和输出。 Swift中有一个简单的低成本构造,它接受输入并返回输出: functions 。 那么我们可以使用函数而不是类或结构作为视图模型吗? 让我们考虑一下我们如何能够做到这一点。
可以使用我们的输入作为函数的参数,而不是LoginViewModelOutputs
协议,而不是LoginViewModelOutputs
协议,我们可以从函数中返回输出Observable
的集合。 这听起来很激进,但让我们看一下我们的示例,看看它可能是什么样。
我们的视图模型已经神奇地转换为一个函数,现在在视图控制器内部,而不是调用LoginViewModel.init()
,而是调用loginViewModel(usernameChanged:passwordChanged:loginTapped:)
。 让我们看一下我们所做的一些改进:
- 我们已经完全消除了输入和输出协议(因此也取消了很多样板),转而使用函数参数作为输入,并使用命名参数元组作为输出
- 我们不再需要将输入桥接到
Observable
因为我们的视图层直接为我们提供了Observable
,从而消除了更多样板 - 我们在视图控制器中的绑定/订阅量减少了,这大大改善了信噪比
- 如果我们忘记将输入传递给视图模型,则会收到编译器错误,因为我们必须提供所有参数来调用Swift函数
- 如果我们忘记使用输出,由于我们正在销毁输出元组,我们现在将收到“未使用的变量”编译器警告。
- 如果我们在不同的屏幕上重用视图模型,那么作为开发人员,我可以使用
_
显式选择忽略特定的输出,这使审阅者和将来的维护者可以清楚地知道这是故意遗漏的,而不是我们忘记绑定的内容。 - 我们不再需要绕开结构和类的初始化规则,因为我们不再使用结构或类!
- 我们将整体实现缩减了约30%,并将视图模型的尺寸缩减了约50%
一旦我们能够克服不使用对象的最初的怪异,这基本上是每种可测量方式的巨大改进。 这是朝着让编译器在犯错时提醒我们的方向迈出的重要一步,而我们已消除了大部分样板。
结论
起初将视图模型表示为函数似乎很疯狂,但是我们始终在应用程序中编写大量业务逻辑作为函数,如果不是大量业务逻辑,那么视图模型又是什么呢? 当我们抛弃传统的关于视图模型是什么的概念并拥抱我们的好朋友功能时 ,我们发现了一种简单而优雅的解决方案,可解决编写反应式MVVM的许多麻烦。
这种样式的原型版本来自Kickstarter分页逻辑,该逻辑已投入生产3年以上,并已在其代码库的近十个地方重复使用。 如此证明,这种方法并非特定于视图模型,并且已在大规模生产代码库中经过了实战测试,而不仅仅是诸如登录屏幕之类的玩具应用。
在Swift和其他社区中,创建对象可以封装一些数据并提供对该数据的访问器,这是一种常见的模式。 每当我们看到此内容时,我们都可以使用与视图模型相同的转换并将其转换为函数。
在后续文章中,我将讨论如何将网络和副作用合并到此结构中,如果视图模型太大,该怎么办,如何测试视图模型,以及如何在整个架构中利用这种共享架构和其他共享架构模式iOS(Swift)和Android(Kotlin)都可以使我们的移动开发人员跨平台工作。
如果您没有耐心等待这些文章发表,Stephen Celis和Danny Hertz最近都在极好的Functional Swift Conference上就这种模式进行了演讲,如果您想看一个稍微复杂的例子,则值得一看。在行动。
如果您发现其中任何一件有趣的事情,您可能还会发现与我们合作很有趣! Grailed总部位于纽约,我们正在招聘! 为了跟上该系列的下一篇文章的最新动态,请在Twitter上关注我。
我创建 了一个简单的演示项目 ,用来演示我们在此处讨论的登录屏幕的实现。