如果您对 SwiftRex 或 Redux 以及通用函数式编程有任何问题,请 加入我们的 Slack 频道。
介绍
SwiftRex 是一个框架,它结合了单向数据流架构和响应式编程(Combine、RxSwift 或 ReactiveSwift),为整个应用状态提供了一个中心状态 Store,您的 SwiftUI 视图或 UIViewControllers 可以观察和响应它,以及分发来自用户交互的事件。
这种模式也称为 "Redux",它允许我们将应用看作一个单一生成函数,该函数接收用户事件作为输入并返回 UI 变更作为响应。这种工作流程的优点有望很快变得明显。
快速指南
赶时间?已经熟悉其他 redux 实现?
没问题,我们有一个 快速指南(TL;DR),以一种非常实用的方式显示您关于 SwiftRex 需要知道的最少内容。
我们仍然建议阅读完整的 README 文件以深入了解 SwiftRex 概念。
目标
如今,许多针对移动开发的架构和设计模式都提出了解决问题的方案,这些问题与单一职责原则相关(例如,大量视图控制器),或是为了提高可测试性和依赖管理能力。对于移动开发者而言,其他一些常见的挑战,如状态管理、竞态条件、模块化/组件化、线程安全或正确处理UI生命周期和所有权问题,虽然探讨较少,但同样可能对应用程序造成严重影响。
管理所有这些问题可能听起来是一项不可能完成的任务,需要大量模式和极其复杂的测试场景。毕竟,如何重现只在你的一些用户设备上发生的罕见但关键错误?这可能非常令人沮丧,我们中的大多数人可能都曾一度面临这样的问题。
这正是SwiftRex大放异彩的场合,因为它
强制应用单一职责原则 [点击展开]
某些架构非常灵活,允许我们在任何地方添加任何代码。这对于只由一个人开发的大多数小型应用程序来说应该没问题,但随着项目和团队开始成长,某些层将会变得非常大,承载过多的责任,具有隐式的副作用、竞态条件和其他错误。在这种情况下,测试能力也会受损,应用程序不同部分的连贯性也会受损,因此寻找和修复错误的难度会大大增加。
SwiftRex通过制定非常严格的政策来防止这种情况,即指出代码应该在哪里,以及这个层有多小,这个政策通常由编译器强制执行。听起来这可能很困难,很复杂,但实际上,它比传统模式要简单,因为一旦你理解了这个架构,你就会确切地知道该做什么,确切地知道根据其职责在哪里找到某行代码,确切地知道如何测试每个组件,并且非常清楚每个层的边界。
为每个层提供清晰的测试策略 (也请查看TestingExtensions) [点击展开]
我们认为,架构不仅要非常易于测试,还必须提供清晰的指南,说明如何测试其每一层。如果一个层只做一项工作,并且可以在任何时候根据给定的输入的预期输出进行断言验证这一工作,则测试将更加有意义和广泛,因此在创建新功能时不会引入回归。
SwiftRex架构中的大多数层将都是纯函数,这意味着所有计算都仅从输入参数完成,所有结果都将暴露在输出中,没有隐式的效果或对全局作用域的访问。测试将无需使用模拟、存根、依赖注入或任何其他类型的准备,你只需要调用一个函数并检查结果即可。
这适用于UI层、表示层、reducers和状态发布者,因为整个链都是纯函数的组合。唯一需要依赖注入(因此也需要模拟)的是中间件,因为它是唯一依赖于服务并对外部世界触发副作用的层。幸运的是,因为中间件是可组合的,我们可以将它们拆分成只做一项工作的非常小的部分,这使得测试变得更加愉悦和容易,因为在不需要模拟数百个组件的情况下,你只需注入一个。
我们还提供了TestingExtensions,它允许我们使用一个DSL语法来测试整个用例,这将验证所有SwiftRex层,确保没有意外的副作用或动作发生,并且状态是按预期的逐步修改的。这是一种强大而有趣的方式来测试整个应用程序,只需要几行简单易写的代码。
将所有副作用隔离在可组合/可重用中间件容器中,这些容器不会修改状态 [点击展开]
如果某一层要同时处理多个服务并异步响应时修改状态,保持这种状态一致性并防止竞态条件就很困难。测试起来也更难,因为一个效应可能会干扰另一个。
多年来,苹果和社区都创造了惊人的框架来访问网络或设备中的服务和传感器。不幸的是,一些框架依赖于代理模式,一些使用闭包/回调,一些使用通知中心,KVO 或响应式流。这种通知形式的组合将需要布尔标志、计数器和其他隐式状态,由于竞态条件,最终会导致问题。
响应式框架有助于使这一切更加一致和可组合,特别是当与它们的 Cocoa 扩展一起使用时。事实上,苹果也意识到了这一点,在 WWDC 2019(观看视频)的显著部分集中在演示和修复这个问题上,新引入的框架 Combine 和 SwiftUI 大有帮助。
但在响应式管道中组合大量服务并不总是容易,有其自身的陷阱,如完整管道取消,因为一个流排放了错误,事件重新进入,最后但并非最不重要的是,掌握多个操作符的陡峭的学习曲线。
SwiftRex 严重使用了响应式编程,允许您尽可能多地使用它。然而,我们还提供了一种更一致的方法来使用仅 1 种数据类型和 2 个操作符组合不同的服务:中间件,“<>” 操作符和 “lift” 操作符。所有其他操作都可以通过触发对自身、其他中间件或状态减少器的操作来简化。您仍然可以选择创建较大的中间件以传统响应式流的方式处理多个源,如果喜欢这样做,但这可能对无经验的开发者来说太复杂,难以测试,并且难以在不同的应用程序中重用。
由于这个主题非常广泛,最好在中间件文档中进行详细解释。
最小化对 ViewControllers/Presenters/Interactors/SwiftUI 视图的依赖 [点击展开]
在浏览应用程序时传递依赖项从来都不是一件容易的任务:ViewControllers 初始化器非常棘手,您必须始终考虑类是在从 NIB/XIB、程序化还是故事板中创建时,然后编写正确的初始化方法。这不仅需要传递本类所需的依赖,还需要传递其子视图控制器和后续将要推压的视图控制器所需的依赖。因此,您必须在整个视图路由过程中发送数十个依赖。如果未使用初始化器而更喜欢属性赋值,则这些属性必须隐式解包,这并非最佳。
当然,协调器/框架模式有助于解决这个问题,但是您似乎把问题转移到了路由器上,路由器也需要保持询问比它们实际上使用的更多依赖项,因为下一个路由器将使用。您可以使用服务定位器模式,例如流行的 "Environment" 方法,这确实是一个处理问题的简单方法。然而,测试这个单例可能会有点棘手,因为它就是单例。还有一些人不喜欢隐式注入,更愿意添加图层需要的显式依赖。
所以,不可能解决这个问题并让每个人都满意,对吧?但实际上并非如此。如果你的视图控制器只需要一个名为“Store”的单个依赖项,从其中获取它需要的状态并发射所有用户事件而不实际执行任何工作呢?在这种情况下,无论您使用显式注入还是服务定位器,注入存储都要容易得多。
好的,但还有人需要做工作,这正是中间件执行的任务。在SwiftRex中,中间件应该在应用程序的入口点创建,即在配置并准备好依赖项之后立即创建。然后您创建所有中间件,注入它们执行工作所需的一切(希望每个中间件不超过2个依赖项,这样您就知道它们不会承担太多的责任)。最后将它们组合起来,然后开始您的存储。中间件可以具有计时器或者纯粹对来自UI的动作做出反应,但它们是唯一具有副作用的一层,因此是需要服务依赖的唯一一层。
最后,您可以将区域设置、语言和界面特性添加到全局状态中,这样即使您需要在状态中创建数字和日期格式化程序,您仍然可以做到,而不需要依赖注入,甚至当用户决定更改iOS设置时,还可以适当做出反应。
将状态、服务、变异和其它副作用完全从UI生命周期及其所有权树中分离出来[点击展开]
UIViewControllers有一个非常独特的所有权模型:您不能控制它。视图控制器在处于导航堆栈中时被保留在内存中,或在呈现选项卡时,或当显示模态视图时,但它们可以在任何时刻被释放,以及随之而来的任何在视图控制器下拥有权的事物。我们一直使用并热爱的所有这些[weak self],有时实际上可能是弱引用,而且当我们“守门否则返回”时,很容易不去考虑这一点。任何重要的任务,无论您的视图是否显示,都必须在视图控制器生命周期之外完成,因为用户可以很容易地取消显示您的模态或弹出视图。虽然SwiftUI改善了这一点,但仍然可以从视图的闭包中启动异步任务,尽管现在视图是值类型,但仍然很难犯这些错误,但仍然有可能出现。
SwiftRex通过强制所有和每一个副作用或异步任务都由中间件而不是视图来执行来解决此问题。中间件的生命周期由存储拥有,所以我们不应该期望在存储存在期间有应用程序生活期间出现任何不幸的惊喜。
您仍然可以从不从您的视图中发出“viewDidLoad”、“onAppear”、“onDisappear”事件,以执行任务取消,因此您获得更多的控制,而不是更少。
有关更多信息,请点击此链接
消除竞争条件[点击展开]
当应用程序必须处理来自不同服务和来源的信息时,通常需要在这里和那里放置小的布尔标志,以检查某些事情是否已完成或失败。通常这是由于一些服务通过代理、一些通过闭包和一些其他创造性的方式返回。使用标志同步这些多个来源,或从并发任务中变异相同的变量或数组,可能会导致真正奇怪的错误和崩溃,通常是最难捕捉、理解和修复的错误之一。
处理锁和派遣队列可能会有所帮助,但以这种方式不断重复进行可能会变得乏味且危险,必须编写考虑所有可能路径和时间的测试,而且其中一些测试将最终变得烦人,如果有竞争条件仍然存在。
通过强制所有应用事件通过同一队列进行操作,最终以一致的方式均匀地变更全局状态,SwiftRex可以防止竞争条件。首先,因为只把中间件和异步任务作为副作用和副作用的唯一来源,可以简化对竞争条件的测试,特别是如果你的中间件保持小而专注于单一任务的话。在这种情况下,你的响应将按照先进先出的顺序在队列中到来,并由所有reducer同时处理。其次,因为reducers是状态变更的守门人,确保它们没有副作用对于成功和持续性的变更至关重要。最后但同样重要的是,所有的事情都是在响应动作时发生的,动作可以轻松地记录在案或放入你的崩溃报告中,包括谁调用了该动作,因此,如果你仍发现存在竞争条件,你可以很容易地理解哪些动作正在变更状态,以及这些动作从何而来。
允许更安全的类型编码风格 [点击展开]
Swift泛型难以学习,而且还有相关协议的类型。SwiftRex不需要你掌握泛型,理解协变或类型擦除,但当你更深入地研究这个世界时,你一定会写出编译器验证而不是单元测试验证的应用程序。将运行时错误从运行时带到编译时是一个我们所有人作为优秀开发者都应该拥抱的重要目标。与努力克服Swift类型系统相比,在应用程序发布到野外后检查崩溃报告可能更好。这正是Swift作为静态类型语言带来的心态,一种即使nullability也是类型安全的语言,多亏了Optional,我们现在可以安心地知道,除非我们不安全地——并且明确地——选择这样做,否则我们不会访问空指针。
SwiftRex在所有地方都强制使用严格类型的事件/动作和状态:存储的动作分配器、中间件的动作处理器、中间件的动作输出、reducer的动作和状态输入输出以及最终存储的状态观察,整个流程都是严格类型化的,这样编译器就可以防止错误或运行时错误。
此外,中间件、reducers和store都可以从局部状态和动作“提升”到全局状态和动作。这意味着你可以编写一个严格类型化的模块,在特定的领域操作,比如网络可达性。你的中间件和reducer将“说”网络领域的状态和动作,比如是否连接,是Wi-Fi还是LTE,发生了连接变更动作等。然后,通过提供两个映射函数——一个用于提升状态,另一个用于提升动作——你可以将这两个组件——中间件和reducer——提升到应用程序的全局状态中。多亏了泛型,整个操作完全类型安全。同样,也可以通过“导出”一个商店投射从主商店来实现这一点。商店投射实现了商店必须有的两种方法(输入动作和输出状态),但它不是一个真实的商店,只是将全局状态和动作投影到一个更本地化的领域中,这意味着,将视图事件转换为动作,并将视图状态转换为领域状态。
有了这些工具,我们相信你可以写出,如果愿意,一个从边缘到边缘都是类型安全的应用程序。
有助于实现模块化、组件化和项目间代码复用 [点击展开]
中间件应该集中在非常非常小的领域,只执行一种类型的工作,并以动作的形式返回。Reducers应该集中在非常小的一组动作和状态上。视图应该能够访问非常非常小的状态的一部分,或者理想情况下,访问视图状态,这是一个全局状态的全局表示,它使用到的基本方法可以直接映射到文本字段字符串、切换布尔值、进度条从0.0到1.0的双精度浮点数等等。
然后,您可以"提升"这三部分 - 中间件、reducer、store投影 - 到您应用实际需要的全局状态和全局操作。
SwiftRex允许我们创建只有在需要时才能提升到全局域的小型工作单元,这样我们就可以拥有在非常特定域中运行的Swift框架,并配备测试和Playgrounds/SwiftUI预览,以便在没有启动完整应用的情况下使用。一旦这个框架准备就绪,我们只需将我们的应用连接到它,或者更好的是,多个应用。专注于小型域将解锁更好的抽象,当这些从中间件(副作用)到视图时,您将拥有一个强大的工具来定义您的构建块。
强制执行唯一真相源和适当的智力管理 [点击展开]
SwiftRex可以实现一个可信的唯一真相源,该来源在屏幕之间永远不会不一致或不同步。认为所有的状态都在一个地方可能会让您感到恐惧,一个树形的单棵树会保存一切。看到您需要多少状态可能会有些可怕,一旦您将所有东西放在一个地方。但不用担心,这不是您以前没有的东西,它已经在那里了,在ViewController中,在一个用于控制服务结果的标志中,但因为分布得如此广泛,您看不到它有多大多小。更糟糕的是,这导致重复,因为当您需要来自两个不同位置的信息时,更容易复制并希望您能正确同步它们。
实际上,当您将整个应用状态聚集在一个统一的树中时,您开始摆脱很多不再需要的东西,而您的最终状态将比混乱状态更小。
正确编写全局状态和全局动作树具有挑战性,但这是应用域,对其思考可能是工程师必须完成的最重要的任务。
更多信息请点击此链接
提供开发、测试和调试工具 [点击展开]
多个项目提供了SwiftRex工具来帮助开发者编写应用、测试、调试它或评估崩溃报告。
所有这些工具都已经完成,并将很快发布,未来还有更多。
说实话,这确实是一种完全不同的写应用程序的方式,就像大多数响应式方法一样;但一旦你习惯了,它就更有意义,并使你能够在项目之间重用更多代码,为你提供更好的软件开发、测试、调试、日志记录工具,并最终以你从未有过的想法考虑事件、状态和突变。我保证,这将是一条没有退路的道路,一次单向之旅。
响应式框架库
SwiftRex目前支持3个主要的响应式框架
可以在稍后通过实现可以在ReactiveWrappers.swift
文件中找到的一些抽象桥梁轻松添加更多。为了避免将不必要的文件添加到您的应用程序中,SwiftRex分为4个包
- SwiftRex:核心库
- CombineRex:针对Combine框架的实现
- RxSwiftRex:针对RxSwift框架的实现
- ReactiveSwiftRex:针对ReactiveSwift框架的实现
SwiftRex本身是不够的,所以您必须选择这三个实现中的一个。
组成部分
我们将通过将它们分为3个部分来理解SwiftRex的组成部分
概念部分
动作
动作代表了由外部(或有时是内部)参与者通知您应用程序的事件。它关于相关的输入事件。
SwiftRex中没有“动作”协议或类型。然而,动作会被当作大多数核心数据结构的泛型参数,这意味着定义根动作类型取决于您。
概念上,我们可以认为动作代表从应用程序的外部参与者发生的事情,意味着用户交互、计时器回调、Web服务响应、CoreLocation和其他框架的回调。一些内部参与者也可以启动动作。例如,当UIKit完成加载您的视图时,我们可以认为viewDidLoad
是动作,如果我们对这个事件感兴趣的话。同样适用于SwiftUI视图(.onAppear
、.onDisappear
、.onTap
)或手势(.onEnded
、.onChanged
、updating
)修改器,这些都可以被认为是动作。当URLSession回复的Data能够解析为结构体时,这可以是一个成功的动作,但如果响应是404或JSONDecoder无法理解负载,则应也成为失败动作。 NotificationCenter 除了通知来自系统各地的动作(如键盘关闭或设备旋转)没有做其他事情。CoreData和其他实时数据库都有在变化时通知机制的机制,这应该是动作。
动作关于对应用程序相关的输入事件。
要在您的应用程序中表示动作,您可以使用结构体、类或枚举,并根据您认为更好的方式组织可能动作的列表。但我们有一种推荐的方式,这将使您能够充分利用类型安全并避免问题,这种方式是通过使用枚举和相关值创建的树结构。
enum AppAction {
case started
case movie(MovieAction)
case cast(CastAction)
}
enum MovieAction {
case requestMovieList
case gotMovieList(movies: [Movie])
case movieListResponseError(MovieResponseError)
case selectMovie(id: UUID)
}
enum CastAction {
case requestCastList(movieId: UUID)
case gotCastList(movieId: UUID, cast: [Person])
case castListResponseError(CastResponseError)
case selectPerson(id: UUID)
}
您的应用程序中所有可能的事件都应列在这些枚举中,并按您认为更相关的方式进行分组。在分组这些枚举时,要注意模块化:如果您想要模块之间的严格边界或在不同应用程序之间重用一组动作,可以将一些或所有这些枚举拆分到不同的框架中。
例如,所有应用程序都将有代表任何iOS应用程序生命周期的公共动作,例如 willResignActive
、didBecomeActive
、didEnterBackground
、willEnterForeground
。如果多个应用程序需要知道这个生命周期,可能方便创建一个用于此特定域的枚举。 对于网络可达性,我们应该考虑创建一个枚举来表示我们在连接状态变化时从系统获得的所有可能事件,这可以在各种应用中使用。
重要:由于Swift中的枚举没有与结构体相同的KeyPath,我们强烈建议您阅读动作枚举属性文档,并为每个情况手动或使用代码生成器实现属性,以避免以后编写大量容易出错的条件语句。我们也提供了一些模板来帮助您。
状态
状态表示应用打开时持有的全部知识,通常在内存中且可变。它关乎相关的输出属性。
SwiftRex中不存在"状态"协议或类型。然而,状态会作为大多数核心数据结构的泛型参数存在,这意味着定义根状态类型由你自行决定。
从概念上讲,我们可以认为,状态表示应用打开时持有的全部知识,通常在内存中且可变;它就像一张纸,你在上面写下一些值,对于收到的每个动作,你擦去一个值并替换为另一个值。另一种思考状态的方式是函数式编程:状态没有被持久化,但它是一个函数的结果,该函数接受应用的初始状态以及自启动以来收到的所有动作,通过按照时间顺序对所有动作变更应用在初始状态上以计算当前值。这被称为事件源设计模式,最近在许多网络后端服务中变得流行,如Kafka事件源。
在电池和内存有限的设备上,我们不能实施真正的事件源模式,因为这会因每次请求一个简单的布尔值时都需要重建应用的全部历史而变得过于昂贵。因此,我们会在收到动作时"缓存"新的状态,而这个内存缓存正是SwiftRex中我们所说的"状态"。因此,也许我们可以混合两种思考状态的方式,并提出一个更好的通用状态定义。
状态是一个函数的结果,该函数接受两个参数:先前的(或初始)状态和发生的某些动作,以确定新的状态。当更多的动作到达时,这个过程会增量地进行。状态对向用户输出数据很有用。
然而,请注意,一些看似状态的事物实际上并不是。假设你的应用向用户展示物品价格。这个价格在美国可能显示为"美元3.00
",在德国显示为"美元3,00
",或者这个产品可以以英镑列出,因此在美国我们应该显示"英镑3.00
",而在德国则是"英镑3,00
"。在这个例子中,我们有
- 货币类型("
英镑
" 或 "美元
") - 数值("
3
") - 地区("
en_US
" 或 "de_DE
") - 格式化字符串("
美元3.00
","美元3,00
","英镑3.00
" 或 "英镑3,00
")
格式化字符串本身不是状态,因为它可以通过其他属性来计算。这可以称为"派生状态",持有它可能导致不一致性。我们不得不记住在任何一个其他属性改变时都更新这个值。因此,最好是将其表示为计算属性或其他三个值的一个函数。此类派生状态的最佳位置是在呈现器或控制器中,除非重新计算它的成本很高,在这种情况下你可以在状态中存储并且非常小心地做。幸运的是,正如我们将在reducer部分将要看到的,SwiftRex有助于保持状态的一致性,但还是最好不要重复信息,这些信息可以轻易且成本低廉地计算。
为了表示应用的状态,我们建议使用值类型:结构体或枚举。元组也可以接受,但遗憾的是,Swift 当前不允许我们将元组符合协议,而我们通常希望整个状态都是 Equatable 的,有时还希望是 Codable 的。
struct AppState: Equatable {
var appLifecycle: AppLifecycle
var movies: Loadable<[Movie]> = .neverLoaded
var currentFilter: MovieFilter
var selectedMovie: UUID?
}
struct MovieFilter: Equatable {
var stringFilter: String?
var yearMin: Int?
var yearMax: Int?
var ratingMin: Int?
var ratingMax: Int?
}
enum AppLifecycle: Equatable {
case backgroundActive
case backgroundInactive
case foregroundActive
case foregroundInactive
}
enum Loadable<T> {
case neverLoaded
case loading
case loaded(T)
}
extension Loadable: Equatable where T: Equatable {}
一些属性代表状态机,例如 Loadable
枚举最终将从 .neverLoaded
变为 .loading
,然后到 .loaded([Movie])
在我们的 movies
属性中。学习何时以及如何以这种形状表示属性是一件不断通过 SwiftRex 进行实验的事情,并且习惯这种架构。最终这会变得自然,您可以从写自己的数据结构来表示此类状态机开始,这在无数情况下都将非常有用。
将整个状态标记为 Equatable 有助于我们在不使用视图模型的情况下减少 UI 更新,但这不是一个强要求,还有其他方法也可以避免这种情况,尽管我们仍然推荐这样做。将状态标记为 Codable 可以用于记录、调试、崩溃报告、监控等,如果可能的话也推荐这样做。
核心部分
存储
存储类型
这是一个协议,它定义了“存储”的两个预期角色:接收/分发操作(ActionHandler
);以及将当前应用状态的变化(StateProvider
)发布给可能的订阅者。它可以是一个真正的存储(如 ReduxStoreBase
)或只是代表真正存储的“代理”,例如在 StoreProjection
的情况下。
存储类型是一个 ActionHandler
,这意味着演员可以调度操作(ActionHandler/dispatch(_:)
),这些操作将由该存储处理。这些操作最终将启动副作用或更改状态。这些操作还可以由副作用的结果调度,如 API 调用的回调或 CLLocation 新坐标。如何处理这个操作将取决于 StoreType
的不同实现。
存储类型也是一个 StateProvider
,这意味着它了解某些状态,并且可以通过其发布者(StateProvider/statePublisher
)通知可能的订阅者关于变化的情况。如果此 StoreType
拥有状态(唯一真相来源)或仅从另一个存储代理它,将取决于协议的不同实现。
┌──────────┐
│ UIButton │────────┐
└──────────┘ │
┌───────────────────┐ │ dispatch<Action>(_ action: Action)
│UIGestureRecognizer│───┼──────────────────────────────────────────────┐
└───────────────────┘ │ │
┌───────────┐ │ ▼
│viewDidLoad│───────┘ ┏━━━━━━━━━━━━━━━━━━━━┓
└───────────┘ ┃ ┃░
┃ ┃░
┃ ┃░
┌───────┐ ┃ ┃░
│UILabel│◀─ ─ ─ ─ ┐ ┃ ┃░
└───────┘ Combine, RxSwift ┌ ─ ─ ┻ ─ ┐ ┃░
│ or ReactiveSwift State Store ┃░
┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─│Publisher│ ┃░
▼ │ subscribe(onNext:) ┃░
┌─────────────┐ ▼ sink(receiveValue:) └ ─ ─ ┳ ─ ┘ ┃░
│ Diffable │ ┌─────────────┐ assign(to:on:) ┃ ┃░
│ DataSource │ │RxDataSources│ ┃ ┃░
└─────────────┘ └─────────────┘ ┃ ┃░
│ │ ┃ ┃░
┌──────▼───────────────▼───────────┐ ┗━━━━━━━━━━━━━━━━━━━━┛░
│ │ ░░░░░░░░░░░░░░░░░░░░░░
│ │
│ │
│ │
│ UICollectionView │
│ │
│ │
│ │
│ │
└──────────────────────────────────┘
存在实现将成为实际的存储(Store),这是唯一一个将成为整个 Redux 架构中心枢纽的实例。其他实现可以是投影或其他主 Store,因此它们通过执行相同的角色来模拟 Store,但它们并不是拥有全局状态或直接处理动作,这些投影只是在链中应用一些小的(纯)转换,并将任务委托给真正的 Store。这在您希望在视图中拥有本地“存储”但不想它们重复数据或拥有任何类型的状态,而只是在幕后使用中央存储时很有用。
有关真实存储的更多信息,请参阅 ReduxStoreBase
和 ReduxStoreProtocol
,有关投影的更多信息,请参阅 StoreProjection
和 StoreType/projection(action:state:)
。
真实存储是什么?
真实存储是一个您希望在应用程序整个执行期间创建和保持存在的类,因为它的唯一责任是作为单向数据流生命周期协调者。这也是为什么我们要一个 Store 实例,因此您可以选择创建一个静态的实例单例,或者在 AppDelegate 中保留它。如果您的应用程序支持多个窗口并且您希望在多个应用程序实例之间共享状态,请小心处理 SceneDelegate。通常情况下,推荐使用 AppDelegate、单例或全局变量作为 Store,而不是 SceneDelegate。在 SwiftUI 中,您可以在应用程序协议中创建一个作为 Combine/StateObject
的存储。
@main
struct MyApp: App {
@StateObject var store = Store.createStore(dependencyInjection: World.default).asObservableViewModel(initialState: .initial)
var body: some Scene {
WindowGroup {
ContentView(viewModel: ContentViewModel(store: store))
}
}
}
SwiftRex 将提供一个协议(ReduxStoreProtocol
)和一个基类(ReduxStoreBase
),帮助您创建自己的存储。
class Store: ReduxStoreBase<AppAction, AppState> {
static func createStore(dependencyInjection: World) -> Store {
let store = Store(
subject: .combine(initialValue: .initial),
reducer: AppModule.appReducer,
middleware: AppModule.appMiddleware(dependencyInjection: dependencyInjection)
emitsValue: .whenDifferent
)
store.dispatch(AppAction.lifecycle(LifeCycleAction.start))
return store
}
}
什么是 Store 投影?
很多时候,您不希望您的视图能够访问整个应用程序状态或分派任何可能的全球应用程序动作。这不仅可能导致 UI 过于频繁地刷新,而且还会增加错误倾向,将更复杂的代码放在视图层,最后降低模块化,使视图与全局模型耦合。
然而,您不想将状态分割成多个部分,因为将其放在中央且唯一的位置可以确保一致性。同时,您也不希望有多个单独的位置来处理动作,因为这可能会潜在的创建竞争条件。真正的存储是唯一拥有全局状态并有效地处理动作的地方,这就是它的样子。
为了解决这两个问题,我们提供了一个StoreProjection
,它符合StoreType
协议,因此在所有目的上它表现得像真实存储,但实际上它只是使用自定义状态和动作类型对真实存储进行投影,即要么是您的模型的子集(例如状态树的分支),要么是完全不同的实体,如View State。一个StoreProjection
有两个闭包,允许它转换全局动作和状态与视图使用的状态之间的动作和状态。这样,视图就不与整个全局模型耦合,而只与其中的一小部分耦合,而StoreProjection
中的闭包将负责提取/映射视图感兴趣的部分。这也提高了性能,因为视图只有在全局状态中的相关属性发生变化时才会刷新,而不是刷新任何属性。另一方面,视图只能分配一组有限的动作,这些动作将通过StoreProjection
中的闭包映射为全局动作。
可以从任何其他StoreType
创建存储投影,甚至可以从另一个StoreProjection
创建。这就像调用StoreType/projection(action:state:)
一样简单,并提供动作和状态映射闭包。
let storeProjection = store.projection(
action: { viewAction in viewAction.toAppAction() } ,
state: { globalState in MyViewState.from(globalState: globalState) }
).asObservableViewModel(initialState: .empty)
全部一起
将所有这些放在一起,我们可以有
@main
struct MyApp: App {
@StateObject var store = Store.createStore(dependencyInjection: World.default).asObservableViewModel(initialState: .initial)
var body: some Scene {
WindowGroup {
ContentView(
viewModel: store.projection(
action: { (viewAction: ContentViewAction) -> AppAction? in
viewAction.toAppAction()
},
state: { (globalState: AppState) -> ContentViewState in
ContentViewState.from(globalState: globalState)
}
).asObservableViewModel(initialState: .empty)
)
}
}
}
struct ContentViewState: Equatable {
let title: String
static func from(globalState: AppState) -> ContentViewState {
ContentViewState(title: "\(L10n.goodMorning), \(globalState.foo.bar.title)")
}
}
enum ContentViewAction {
case onAppear
func toAppAction() -> AppAction? {
switch self {
case .onAppear: return AppAction.foo(.bar(.startTimer))
}
}
}
在上面的例子中,我们可以看到ContentView
不知道全局模型,它仅限于ContentViewAction
和ContentViewState
。它也只在globalState.foo.bar.title
改变时刷新。任何其他在AppState
中的更改将被忽略,因为其他属性没有被映射到ContentViewState
中的任何内容。此外,ContentViewAction
只有一个案例,即onAppear
,这是视图可以分配的唯一内容,而不知道这最终将启动一个计时器(AppAction.foo(.bar(.startTimer))
)。视图不应该知道领域逻辑,并且其动作应仅限于buttonTapped
、onAppear
、didScroll
、toggle(enabled: Bool)
和其他仅表示UI交互的名称。如何将这些映射到App操作是其他部分的职责,在我们的例子中,是ContentViewAction
自己,但它可以是 Présenter层、ViewModel层,或者你决定创造来组织代码的任何结构。
使用这种方法,测试也变得更简单,因为视图不包含任何逻辑,并且投影转换是纯净函数。
中间件
MiddlewareProtocol
是一个插件,或几个插件的组合,它被分配到应用全局StoreType
管道中,以处理接收到的每个动作(InputActionType
),在响应中执行副作用,并在过程中最终派发更多动作(OutputActionType
)。它还可以在处理传入的动作时访问最新的StateType
。
我们可以将中间件想象成一个对象,它将动作转换为同步或异步任务,并在这些副作用完成时创建更多动作,同时还能在处理动作的同时检查当前状态。
动作是一种轻量级结构,通常是一个枚举类型,被派发到ActionHandler
(通常是StoreType
)中。
像ReduxStoreProtocol
这样的存储会将到达的新动作排队,并将其提交给一系列中间件。换句话说,一个MiddlewareProtocol
是一个处理动作的类,它具有派发更多动作的能力,这些动作可以立即派发,也可以在异步任务的回调后派发。中间件还可以简单地忽略动作,或者可以执行响应的副作用,例如将日志写入文件或通过网络,或者执行HTTP请求等。对于这些异步任务,当它们完成时,中间件可以派发包含响应有效载荷(例如JSON文件、电影数组、凭证等)的新动作。其他中间件将处理这些动作,或者可能是将来的同一中间件,或者也许某个Reducer
将使用此动作来改变状态,因为MiddlewareProtocol
本身无法改变状态,只能读取它。
MiddlewareProtocol/handle(action:from:state:)
将在Reducer之前被调用,所以如果你在那个点读取状态,它仍然是未更改的版本。在实现此函数时,期望你返回一个IO
对象,这是一个可以执行副作用和派发新动作的闭包。在这个闭包内,状态将在reducers处理当前动作后的新值上,所以如果你复制了旧状态,你可以比较它们,记录、审计、执行分析跟踪、遥测或与外部设备的状态同步,如Apple Watch。通过网络的远程调试也是中间件的一个很好的用法。
每个派发的动作都附带其动作来源,即该动作的主要派发器。中间件可以访问文件名、行号、函数名以及有关负责创建和派发该动作的实体的其他信息,这是一种非常有用的调试信息,可以帮助开发者跟踪信息如何在应用程序中流动。
理想情况下,一个MiddlewareProtocol
应该是一个小型且可重用的盒子,只处理一个非常有限的动作集,并结合其他小型中间件来创建更复杂的应用程序。例如,同一个CoreLocation
中间件可以用于iOS应用程序、它的扩展、Apple Watch扩展,甚至不同应用程序,只要它们共享一些子动作树和子状态结构。
中间件的一些建议
- 运行定时器,定期更新某些外部资源或本地状态
- 订阅
CoreData
、Realm
、Firebase Realtime Database
或类似数据库的变化 - 成为
CoreLocation
代理,以检查重大位置变化或信标范围,并触发更新状态的动作 - 成为
HealthKit
代理以跟踪活动,甚至将其与CoreLocation
观察结合以跟踪活动路线 - 记录器、遥测、审计、分析跟踪器、崩溃报告面包屑
- 监控或调试工具,如外部的应用程序用于远程监控状态和动作
WatchConnectivity
同步,保持iOS和watchOS状态同步- API调用和其他“冷可观测”项
- 网络可达性
- 应用程序中的导航(Redux协调器模式)
CoreBluetooth
中心或外设管理器CoreNFC
管理器和代理NotificationCenter
和其他代理- WebSocket、TCP Socket、多对等以及许多其他连接协议
RxSwift
可观测项、ReactiveSwift
信号生产者、Combine
发布者- 观察特性变化、设备旋转、语言/地区、暗黑模式、动态字体、背景/前景状态
- 希望抽象的任何副作用、I/O、网络、传感器和第三方库
┌────────┐
IO closure ┌─▶│ View 1 │
┌─────┐ (don't run yet) ┌─────┐ │ └────────┘
│ │ handle ┌──────────┐ ┌───────────────────────────────────────▶│ │ send │ ┌────────┐
│ ├────────▶│Middleware│──┘ │ │────────────▶├─▶│ View 2 │
│ │ Action │ Pipeline │──┐ ┌─────┐ reduce ┌──────────┐ │ │ New state │ └────────┘
│ │ └──────────┘ └─▶│ │───────▶│ Reducer │──────────▶│ │ │ ┌────────┐
┌──────┐ dispatch │ │ │Store│ Action │ Pipeline │ New state │ │ └─▶│ View 3 │
│Button│─────────▶│Store│ │ │ + └──────────┘ │Store│ └────────┘
└──────┘ Action │ │ └─────┘ State │ │ dispatch ┌─────┐
│ │ │ │ ┌─────────────────────────┐ New Action │ │
│ │ │ │─run──▶│ IO closure ├────────────▶│Store│─ ─ ▶ ...
│ │ │ │ │ │ │ │
│ │ │ │ └─┬───────────────────────┘ └─────┘
└─────┘ └─────┘ │ ▲
request│ side-effects │side-effects
▼ response
┌ ─ ─ ─ ─ ─ │
External │─ ─ async ─ ─ ─
│ World
─ ─ ─ ─ ─ ┘
泛型
中间件协议泛化3个关联类型
-
InputActionType
:此
MiddlewareProtocol
知道如何处理的操作类型,因此存储将转发此类型的操作到该中间件。在大多数时候,中间件不需要处理整个全局操作树中所有可能的操作,因此我们可以决定允许它只关注操作的子集。
在这种情况下,此操作类型可以是提升到全局操作类型的子集,以便与在其他全局操作上的中间件一起组合。请参阅提升获取更多详细信息。
-
OutputActionType
:此
MiddlewareProtocol
最终在响应副作用时将触发回存储的操作类型。这可以与InputActionType
相同,或者不同,如果你想在请求和响应中分开你的枚举。在大多数情况下,中间件不需要分发整个全局操作树中所有可能的操作,因此我们可以决定允许它只分发操作的子集,或者根本不分发任何操作,因此可以将
OutputActionType
安全地设置为Never
。在这种情况下,此操作类型可以是提升到全局操作类型的子集,以便与在其他全局操作上的中间件一起组合。请参阅提升获取更多详细信息。
-
StateType
:本
MiddlewareProtocol
需要读取以做出决策的状态部分。在处理传入操作时,此中间件将能够从存储中读取最新的StateType
,但永远不会写入或修改它。在大多数情况下,中间件不需要读取整个全局状态,因此我们可以决定允许它只读取状态的子集,或者可能这个中间件不需要读取任何状态,因此可以将
StateType
安全地设置为Void
。在这种情况下,此状态类型可以是提升到全局状态的子集,以便与在其他全局状态上的中间件一起组合。请参阅提升获取更多详细信息。
返回IO和执行副作用
在其最重要的功能中,MiddlewareProtocol/handle(action:from:state:)
中间件被期望返回一个IO
对象,它是一个闭包,在其中执行副作用并且可以分发新操作。
在某些情况下,我们可能不希望执行任何副作用或运行任何代码,那么函数可以返回一个简单的代码IO/pure()
。
否则,返回一个接受output
的闭包(类型为ActionHandler
,接受ActionHandler/dispatch(_:)
调用)
public func handle(action: InputActionType, from dispatcher: ActionSource, state: @escaping GetState<StateType>) -> IO<OutputActionType> {
if action != .myExpectedAction { return .pure() }
return IO { output in
output.dispatch(.showPopup)
DispatchQueue.global().asyncAfter(.now() + .seconds(3)) {
output.dispatch(.hidePopup)
}
}
}
依赖注入
测试性是软件开发时需考虑最重要的方面之一。在 Redux 架构中,MiddlewareProtocol
是唯一允许执行副作用的对象类型,因此测试性可能会很具挑战性。
为了提高测试性,中间件应尽可能少地使用外部依赖。如果开始使用太多,可以考虑将其拆分为更小的中间件,这还将帮助您防止竞争条件和其他问题,使测试更容易,并使中间件具有更高的可重用性。
此外,所有外部依赖都应该在初始化器中注入,这样在测试期间您可以替换它们为模拟对象。如果您的中间件仅从一个非常复杂的对象中调用一次,请考虑创建一个只有一个函数要求的协议,或者注入一个类似于 @escaping (URLRequest) -> AnyPublisher<(Data, URLResponse), Error>
的闭包。对于此创建模拟会更加容易。
最后,考虑使用 MiddlewareReader
将此中间件包装在依赖注入容器中。
中间件示例
当实现您的中间件时,您只需处理传入的操作即可
public final class LoggerMiddleware: MiddlewareProtocol {
public typealias InputActionType = AppGlobalAction // It wants to receive all possible app actions
public typealias OutputActionType = Never // No action is generated from this Middleware
public typealias StateType = AppGlobalState // It wants to read the whole app state
private let logger: Logger
private let now: () -> Date
// Dependency Injection
public init(logger: Logger = Logger.default, now: @escaping () -> Date) {
self.logger = logger
self.now = now
}
// inputAction: AppGlobalAction state: AppGlobalState output action: Never
public func handle(action: InputActionType, from dispatcher: ActionSource, state: @escaping GetState<StateType>) -> IO<OutputActionType> {
let stateBefore: AppGlobalState = state()
let dateBefore = now()
return IO { [weak self] output in
guard let self = self else { return }
let stateAfter = state()
let dateAfter = self.now()
let source = "\(dispatcher.file):\(dispatcher.line) - \(dispatcher.function) | \(dispatcher.info ?? "")"
self.logger.log(action: action, from: source, before: stateBefore, after: stateAfter, dateBefore: dateBefore, dateAfter: dateAfter)
}
}
}
public final class FavoritesAPIMiddleware: MiddlewareProtocol {
public typealias InputActionType = FavoritesAction // It wants to receive only actions related to Favorites
public typealias OutputActionType = FavoritesAction // It wants to also dispatch actions related to Favorites
public typealias StateType = FavoritesModel // It wants to read the app state that manages favorites
private let api: API
// Dependency Injection
public init(api: API) {
self.api = api
}
// inputAction: FavoritesAction state: FavoritesModel output action: FavoritesAction
public func handle(action: InputActionType, from dispatcher: ActionSource, state: @escaping GetState<StateType>) -> IO<OutputActionType> {
guard case let .toggleFavorite(movieId) = action else { return .pure() }
let favoritesList = state() // state before reducer
let makeFavorite = !favoritesList.contains(where: { $0.id == movieId })
return IO { [weak self] output in
guard let self = self else { return }
self.api.changeFavorite(id: movieId, makeFavorite: makeFavorite) (completion: { result in
switch result {
case let .success(value):
output.dispatch(.changedFavorite(movieId, isFavorite: makeFavorite), info: "API.changeFavorite callback")
case let .failure(error):
output.dispatch(.changedFavoriteHasFailed(movieId, isFavorite: !makeFavorite, error: error), info: "api.changeFavorite callback")
}
})
}
}
}
EffectMiddleware
这是旨在保持简单同时非常强大的中间件实现。对于每个传入的操作,您必须返回一个效果,它只是一个针对您最喜欢的响应式库的响应式 Observable、Publisher 或 SignalProducer 的包装器。唯一条件是您的响应式流的输出(元素)必须是 DispatchedAction,错误必须是 Never。DispatchedAction 是包含操作本身和派发器(操作来源)的结构,因此它是通用的 Action,并且与 EffectMiddleware 的 OutputAction 匹配。错误必须是 Never,因为中间件预计将解决所有副作用,包括错误。因此,如果您想处理错误,您可以在中间件中进行处理;如果您想提醒用户关于错误,那么请在您的响应式流中捕获错误并将其转换为如 .somethingWentWrong(messageToTheUser: String)
的操作,以便发送并最终缩减到状态应用。
可选的,EffectMiddleware 也可以处理依赖。这有助于将依赖注入中。如果你的依赖泛型参数是 Void,那么中间件可以立即创建,无需传递任何依赖,但是处理操作时不能使用任何外部依赖。如果依赖泛型参数有某些类型或元组,那么在处理操作时可以使用它们,但是为了创建效果中间件,你需要提供该类型或元组。
重要:由于预期你在执行 Effect 时才“访问”外部世界,因此依赖将仅在 Effect 闭包内部可用。
static let favouritesMiddleware = EffectMiddleware<FavoritesAction /* input action */, FavoritesAction /* output action */, AppState, FavouritesAPI /* dependencies */>.onAction { incomingAction, dispatcher, getState in
switch incomingAction {
case let .toggleFavorite(movieId):
return Effect(token: "Any Hashable. Use this to cancel tasks, or to avoid two tasks of the same type") { context -> AnyPublisher<DispatchedAction<FavoritesAction>, Never> in
let favoritesList = getState()
let makeFavorite = !favoritesList.contains(where: { $0.id == movieId })
let api = context.dependencies
return api.changeFavoritePublisher(id: movieId, makeFavorite: makeFavorite)
.catch { error in DispatchedAction(.somethingWentWrong("Got an error: \(error)") }
.eraseToAnyPublisher()
}
default:
return .doNothing // Special type of Effect that, well, does nothing.
}
}
Effect 有一些有用的构造函数,例如 .doNothing
、.fireAndForget
、.just
、.sequence
、.promise
、.toCancel
等。此外,只要它们匹配所需泛型参数,你还可以将任何 Publisher、Observable 或 SignalProducer 提升到 Effect 中,为此你可以简单使用 .asEffect()
函数。
Reducer
Reducer
是包装在 monoid 容器中的纯函数,它接受一个操作和当前状态,以计算新状态。
MiddlewareProtocol
管道可以做两件事:分发外出操作和处理传入操作。但是,它们不能改变应用程序状态。中间件以只读方式访问我们应用程序的最新状态,但是在需要突变时,我们使用 MutableReduceFunction
函数
(ActionType, inout StateType) -> Void
它具有与旧 ReduceFunction
相同的语义(但性能更好)。
(ActionType, StateType) -> StateType
给定一个操作和当前状态(作为可变输入输出),它计算新状态并改变它。
initial state is 42
action: increment
reducer: increment 42 => new state 43
current state is 43
action: decrement
reducer: decrement 43 => new state 42
current state is 42
action: half
reducer: half 42 => new state 21
该函数在缓存的状态中减少所有操作,并且这针对每个新的传入操作增量发生。
重要的是要理解 reducer 是一个同步操作,它计算新状态而不产生任何副作用(包括非明显的副作用,如创建 Date()
、使用 DispatchQueue 或 Locale.current
),因此永远不要将属性添加到 Reducer
结构体中或调用任何外部函数。如果你有这种诱惑,请创建中间件并在其中分发带有日期或区域设置的日期的操作。
Reducers 还负责维护状态的一致性,所以在更改状态之前进行最终的合理性检查始终是件好事,比如检查必须一起更改的其他依赖属性。
一旦 reducer 函数执行,存储将更新其单一事实源的新计算状态,并将其传播到所有订户,他们将对新状态做出反应并更新视图,例如。
将此函数包装在结构体中是为了克服一些 Swift 限制,例如,允许我们将多个 reducer 组合成一个(monoid 操作,其中两个或多个 reducer 合并成一个)或将 reducer 从本地类型提升到全局类型。
提升 reducer 的能力允许我们编写细粒度的“子 reducer”,它将仅处理状态的子集和/或操作,将其放置在不同的框架和模块中,然后通过提供在全局和本地之间映射状态和操作的方法,将其插入到更大的状态和操作处理程序中。有关更多信息,请参阅提升。
Reducer 的一个可能实现如下
let volumeReducer = Reducer<VolumeAction, VolumeState>.reduce { action, currentState in
switch action {
case .louder:
currentState = VolumeState(
isMute: false, // When increasing the volume, always unmute it.
volume: min(100, currentState.volume + 5)
)
case .quieter:
currentState = VolumeState(
isMute: currentState.isMute,
volume: max(0, currentState.volume - 5)
)
case .toggleMute:
currentState = VolumeState(
isMute: !currentState.isMute,
volume: currentState.volume
)
}
}
请注意上述示例中的以下良好做法
- 没有
DispatchQueue
、线程、操作队列、承诺或响应式代码。 - 实现此功能所需的全部参数均由
action
和currentState
提供,不得使用来自全局作用域的任何其他变量,即使是读取目的也不行。如果需要其他东西,这些应该要么在状态中,要么在 action 负载数组中。 - 不要启动副作用、请求、I/O、数据库调用。
- 编写
switch
/case
语句时避免使用default
。这样编译器将为您提供更多帮助。 - 尽可能将操作和状态参数化,使其尽可能专业化。如果状态体积是更大状态的一部分,您不应倾向于将整个大状态传递给这个还原器。使它简短、简洁且专业化,这也有助于防止出现
default
情况或不得不重新分配此还原器不会更改的属性。
┌────────┐
IO closure ┌─▶│ View 1 │
┌─────┐ (don't run yet) ┌─────┐ │ └────────┘
│ │ handle ┌──────────┐ ┌───────────────────────────────────────▶│ │ send │ ┌────────┐
│ ├────────▶│Middleware│──┘ │ │────────────▶├─▶│ View 2 │
│ │ Action │ Pipeline │──┐ ┌─────┐ reduce ┌──────────┐ │ │ New state │ └────────┘
│ │ └──────────┘ └─▶│ │───────▶│ Reducer │──────────▶│ │ │ ┌────────┐
┌──────┐ dispatch │ │ │Store│ Action │ Pipeline │ New state │ │ └─▶│ View 3 │
│Button│─────────▶│Store│ │ │ + └──────────┘ │Store│ └────────┘
└──────┘ Action │ │ └─────┘ State │ │ dispatch ┌─────┐
│ │ │ │ ┌─────────────────────────┐ New Action │ │
│ │ │ │─run──▶│ IO closure ├────────────▶│Store│─ ─ ▶ ...
│ │ │ │ │ │ │ │
│ │ │ │ └─┬───────────────────────┘ └─────┘
└─────┘ └─────┘ │ ▲
request│ side-effects │side-effects
▼ response
┌ ─ ─ ─ ─ ─ │
External │─ ─ async ─ ─ ─
│ World
─ ─ ─ ─ ─ ┘
投影和提升
存储投影
应用程序应该有一个单个的真实存储,其中包含单一事实来源。然而,我们可以“派生”这个存储到小集合中,被称为存储投影,将处理更小的状态部分或动作树的一部分,甚至可以不同类型的动作和状态,只要我们能映射回原始存储类型。它不会存储任何东西,只是投影原始存储。例如,一个视图可以定义一个完全自定义的视图状态和视图动作,并且我们可以创建一个用于这些类型的 StoreProjection
,只要它后面有一个真实的存储,其状态和动作类型可以被映射到视图状态和视图动作类型。存储投影将负责转换这些实体。
很多时候,您不希望您的视图能够访问整个应用程序状态或分派任何可能的全球应用程序动作。这不仅可能导致 UI 过于频繁地刷新,而且还会增加错误倾向,将更复杂的代码放在视图层,最后降低模块化,使视图与全局模型耦合。
然而,您不想将状态分割成多个部分,因为将其放在中央且唯一的位置可以确保一致性。同时,您也不希望有多个单独的位置来处理动作,因为这可能会潜在的创建竞争条件。真正的存储是唯一拥有全局状态并有效地处理动作的地方,这就是它的样子。
为了解决这两个问题,我们提供了一个StoreProjection
,它符合StoreType
协议,因此在所有目的上它表现得像真实存储,但实际上它只是使用自定义状态和动作类型对真实存储进行投影,即要么是您的模型的子集(例如状态树的分支),要么是完全不同的实体,如View State。一个StoreProjection
有两个闭包,允许它转换全局动作和状态与视图使用的状态之间的动作和状态。这样,视图就不与整个全局模型耦合,而只与其中的一小部分耦合,而StoreProjection
中的闭包将负责提取/映射视图感兴趣的部分。这也提高了性能,因为视图只有在全局状态中的相关属性发生变化时才会刷新,而不是刷新任何属性。另一方面,视图只能分配一组有限的动作,这些动作将通过StoreProjection
中的闭包映射为全局动作。
可以从任何其他StoreType
创建存储投影,甚至可以从另一个StoreProjection
创建。这就像调用StoreType/projection(action:state:)
一样简单,并提供动作和状态映射闭包。
let storeProjection = store.projection(
action: { viewAction in viewAction.toAppAction() } ,
state: { globalState in MyViewState.from(globalState: globalState) }
).asObservableViewModel(initialState: .empty)
有关真实存储与存储投影的更多信息,以及完整的代码示例,请查看 StoreType
的文档。
提升
应用程序可能是一个复杂的产品,执行的活动很多,不一定相关。例如,同一个应用可能需要进行天气 API 的请求,使用 CLLocation 检查当前用户的位置,并从 UserDefaults 读取首选项。
虽然这些活动组合起来创建完整的体验,但可以将它们相互隔离,以避免在相同位置存在URLSession逻辑和CLLocation逻辑,竞争相同资源并可能造成竞争条件。此外,单独测试这些部分通常更容易,并导致更有意义的测试。
理想情况下,我们应该将我们的AppState
和AppAction
组织成独立的树,以考虑这些部分。在上面的例子中,我们可以有3个不同的属性在AppState中,以及3个不同的enum情况在AppAction中,以分组与天气API、用户位置和UserDefaults访问相关的状态和动作。
如果我们把我们的应用分成3种类型的Reducer
和3种类型的MiddlewareProtocol
,这尤其有用,每种类型的工作不是在完整的AppState
和AppAction
上,而是在我们模型中分组的3个路径上。第一对Reducer
和MiddlewareProtocol
是泛型化的WeatherState
和WeatherAction
,第二对是泛型化的LocationState
和LocationAction
,第三对是泛型化的RepositoryState
和RepositoryAction
。它们甚至可以位于不同的框架中,因此编译器将禁止我们耦合天气API代码和CLLocation代码,这对强制更好的实践和解耦代码重用很有帮助。也许我们的CLLocation中间件/Reducer在完全不同的检查公共交通路线的应用中也有用。
但有一天我们想要将这些3种不同类型的实体组合起来,而我们的应用“存储类型”(StoreType)说的不是由特定处理程序使用的子集AppAction
和AppState
,而是AppAction
和AppState
。
enum AppAction {
case weather(WeatherAction)
case location(LocationAction)
case repository(RepositoryAction)
}
struct AppState {
let weather: WeatherState
let location: LocationState
let repository: RepositoryState
}
对于泛型于WeatherAction
和WeatherState
的reducer,我们可以通过告诉这个reducer如何在全球树中找到它需要的属性,将其提升到全局类型AppAction
和AppState
。那将是\AppAction.weather
和\AppState.weather
。对于中间件也可以做类似的事情,以及我们应用中的其他2个reducer和中间件。
当所有这些都提升到公共类型时,可以使用菱形操作符(<>
)将它们组合在一起,并设置为存储处理程序。
重要:由于Swift中的枚举没有与结构体相同的KeyPath,我们强烈建议您阅读动作枚举属性文档,并为每个情况手动或使用代码生成器实现属性,以避免以后编写大量容易出错的条件语句。我们也提供了一些模板来帮助您。
让我们探讨如何提升reducer和中间件。
提升Reducer
Reducer
具有AppAction输入、AppState输入和AppState输出,因为它只能处理操作(永远不发送它们),读取状态并写入状态。
因此,提升方向应该是
Reducer:
- ReducerAction? ← AppAction
- ReducerState ←→ AppState
给定
// type 1 type 2
Reducer<ReducerAction, ReducerState>
转换
╔═══════════════════╗
║ ║
╔═══════════════╗ ║ ║
║ Reducer ║ .lift ║ Store ║
╚═══════════════╝ ║ ║
│ ║ ║
╚═══════════════════╝
│ │
│ │
┌───────────┐
┌─────┴─────┐ (AppAction) -> ReducerAction? │ │
┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐ │ Reducer │ { $0.case?.reducerAction } │ │
Input Action │ Action │◀──────────────────────────────────────────────│ AppAction │
└ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘ │ │ KeyPath<AppAction, ReducerAction?> │ │
└─────┬─────┘ \AppAction.case?.reducerAction │ │
└───────────┘
│ │
│ get: (AppState) -> ReducerState │
{ $0.reducerState } ┌───────────┐
┌─────┴─────┐ set: (inout AppState, ReducerState) -> Void │ │
┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐ │ Reducer │ { $0.reducerState = $1 } │ │
State │ State │◀─────────────────────────────────────────────▶│ AppState │
└ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘ │ │ WritableKeyPath<AppState, ReducerState> │ │
└─────┬─────┘ \AppState.reducerState │ │
└───────────┘
│ │
使用闭包提升Reducer
.lift(
actionGetter: { (action: AppAction) -> ReducerAction? /* type 1 */ in
// prism3 has associated value of ReducerAction,
// and whole thing is Optional because Prism is always optional
action.prism1?.prism2?.prism3
},
stateGetter: { (state: AppState) -> ReducerState /* type 2 */ in
// property2: ReducerState
state.property1.property2
},
stateSetter: { (state: inout AppState, newValue: ReducerState /* type 2 */) -> Void in
// property2: ReducerState
state.property1.property2 = newValue
}
)
步骤
- 从Reducer中开始将2个类型插入到3个闭包标题中。
- 对于类型 1,找到一个棱镜,将 AppAction 解析为匹配的类型。 务必要运行 SOURCERY 并且确保所有枚举情况都通过棱镜得到覆盖
- 对于状态获取器闭包上的类型 2,找到将 AppState 解析为匹配类型的透镜(属性获取器)。
- 对于状态设置器闭包上的类型 2,找到可以将全局状态接收修改为新值接收的透镜(属性设置器)。请确保一切都是可写入的。
.lift(
action: \AppAction.prism1?.prism2?.prism3,
state: \AppState.property1.property2
)
步骤
- 从上面的闭包示例开始
- 对于动作,我们可以使用来自
\AppAction
的 KeyPath 遍历棱镜树 - 对于状态,我们可以使用来自
\AppState
的 WritableKeyPath 遍历属性,只要所有属性都声明为var
,而不是let
。
MiddlewareProtocol
有 AppAction 输入、AppAction 输出和 AppState 输入,因为它可以处理动作、分发动作,并且只读取状态(决不写入)。
因此,提升方向应该是
Middleware:
- MiddlewareInputAction? ← AppAction
- MiddlewareOutputAction → AppAction
- MiddlewareState ← AppState
给定
// type 1 type 2 type 3
MyMiddleware<MiddlewareInputAction, MiddlewareOutputAction, MiddlewareState>
转换
╔═══════════════════╗
║ ║
╔═══════════════╗ ║ ║
║ Middleware ║ .lift ║ Store ║
╚═══════════════╝ ║ ║
│ ║ ║
╚═══════════════════╝
│ │
│ │
┌───────────┐
┌─────┴─────┐ (AppAction) -> MiddlewareInputAction? │ │
┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐ │Middleware │ { $0.case?.middlewareInputAction } │ │
Input Action │ Input │◀──────────────────────────────────────────────│ AppAction │
└ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘ │ Action │ KeyPath<AppAction, MiddlewareInputAction?> │ │
└─────┬─────┘ \AppAction.case?.middlewareInputAction │ │
└───────────┘
│ ┌─────┴─────┐
┌───────────┐ (MiddlewareOutputAction) -> AppAction │ │
┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐ │Middleware │ { AppAction.case($0) } │ │
Output Action │ Output │──────────────────────────────────────────────▶│ AppAction │
└ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘ │ Action │ AppAction.case │ │
└───────────┘ │ │
│ └─────┬─────┘
┌───────────┐
┌─────┴─────┐ (AppState) -> MiddlewareState │ │
┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐ │Middleware │ { $0.middlewareState } │ │
State │ State │◀──────────────────────────────────────────────│ AppState │
└ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘ │ │ KeyPath<AppState, MiddlewareState> │ │
└─────┬─────┘ \AppState.middlewareState │ │
└───────────┘
│ │
.lift(
inputAction: { (action: AppAction) -> MiddlewareInputAction? /* type 1 */ in
// prism3 has associated value of MiddlewareInputAction,
// and whole thing is Optional because Prism is always optional
action.prism1?.prism2?.prism3
},
outputAction: { (local: MiddlewareOutputAction /* type 2 */) -> AppAction in
// local is MiddlewareOutputAction,
// an associated value for .prism3
AppAction.prism1(.prism2(.prism3(local)))
},
state: { (state: AppState) -> MiddlewareState /* type 3 */ in
// property2: MiddlewareState
state.property1.property2
}
)
步骤
- 开始将 MyMiddleware 的 3 种类型插入闭包头部。
- 对于类型 1,找到一个棱镜,将 AppAction 解析为匹配的类型。 务必要运行 SOURCERY 并且确保所有枚举情况都通过棱镜得到覆盖
- 对于类型 2,从内部到外部包裹直到达到 AppAction,在这个例子中,我们使用 .prism3 包裹“它”(“它” = 局部),然后用 .prism2 包裹它,然后是 .prism1,最终达到 AppAction。
- 对于类型 3,找到将 AppState 解析为匹配类型的透镜(属性获取器)。
.lift(
inputAction: \AppAction.prism1?.prism2?.prism3,
outputAction: Prism2.prism3,
state: \AppState.property1.property2
)
.lift(outputAction: Prism1.prism2)
.lift(outputAction: AppAction.prism1)
步骤
- 从上面的闭包示例开始
- 对于输入动作,我们可以使用来自
\AppAction
的 KeyPath 遍历棱镜树 - 对于输出动作它不是 KeyPath,而是一种包裹。因为我们不能同时包裹多层,所以要么我们
- 用闭包版本处理这个
- 分层上提,从内到外,在此情况下,遵循将本地封装到Prism2(案例 .prism3)的步骤,然后将结果封装到Prism1(案例 .prism2),最后将结果封装到AppAction(案例 .prism1)
- 当只有1层时,无需担心
- 对于状态,我们可以使用来自
\AppState
的KeyPath遍历属性。
可选转换
当某些操作正在通过存储器运行时,一些reducer和中间件可能会选择忽略它。例如,如果操作树与那个中间件或reducer没有任何关系。这就是为什么,每个进入的动作(对于中间件来说是InputAction,对于reducer来说只是Action)都是从 AppAction → Optional<Subset>
的转换。返回nil表示将忽略该动作。
对于其他方向的动作用于中间件分发时,它们必须成为AppAction,我们不能忽略中间件的观点。
箭头方向
Reducer 接收动作(输入动作)并且能够读取和写入状态。
中间件 接收动作(输入动作),分发动作(输出动作)并且只读取状态(输入状态)。
在进行提升时,我们必须记住这一点,因为这定义了转换的方差(协变/逆变),即是 map 或 contramap。
一个特殊情况是reducer的状态,因为它需要一个读取和写入访问,换句话说,你被给定了一个 inout Whole
和一个用于 Part
的新值,你使用这个新值来设置inout Whole中正确路径。这正是WritableKeyPaths的意义,我们现在将更详细地看到这一点。
KeyPath的使用
KeyPath与 Global -> Part
转换相同,其中你按照以下方式给出树的描述:\Global.parent.part
。
WritableKeyPath有类似的用法语法,但它的功能更强大,允许我们执行 (Global, Part) -> Global
或 (inout Global, Part) -> Void
转换,这是相同的。
因此,我们需要理解的是,只有在箭头的方向从 AppElement -> ReducerOrMiddlewareElement
来时,KeyPath才是可能的,那就是
Reducer:
- ReducerAction? ← AppAction // Keypath is possible
- ReducerState ←→ AppState // WritableKeyPath is possible
Middleware:
- MiddlewareInputAction? ← AppAction // KeyPath is possible
- MiddlewareOutputAction → AppAction // NOT POSSIBLE
- MiddlewareState ← AppState // KeyPath is possible
对于 ReducerAction? ← AppAction
和 MiddlewareInputAction? ← AppAction
,我们可以使用解析为 Optional
的 KeyPaths。
{ (globalAction: AppAction) -> ReducerOrMiddlewareAction? in
globalAction.parent?.reducerOrMiddlewareAction
}
// or
// KeyPath<AppAction, ReducerOrMiddlewareAction?>
\AppAction.parent?.reducerOrMiddlewareAction
对于 ReducerState ←→ AppState
和 MiddlewareState ← AppState
的转换,我们可以使用类似的语法,尽管 Reducer 是 inout(可写 KeyPath)。这意味着我们的整个树必须由 var
属性组成,而不能是 let
。在这种情况下,除非中间件或 Reducer 接受 Optional,否则转换不应该为 Optional。
{ (globalState: AppState) -> PartState in
globalState.something.thatsThePieceWeWant
}
{ (globalState: inout AppState, newValue: PartState) -> Void in
globalState.something.thatsThePieceWeWant = newValue
}
// or
// KeyPath<AppState, PartState> or WritableKeyPath<AppState, PartState>
\AppState.something.thatsThePieceWeWant // where:
// var something
// var thatsThePieceWeWant
对于 MiddlewareOutputAction → AppAction
,我们不能使用 keypath,这是没有意义的,因为方向与我们想要的方向相反。在这种情况下,我们不是从全局值中解包/提取部分,而是从某个中间件得到了一个特定的操作,我们需要将它包装到 AppAction 中。这可以通过两种方式实现
{ (middlewareAction: MiddlewareAction) -> AppAction in
AppAction.treeForMiddlewareAction(middlewareAction)
}
// or simply
AppAction.treeForMiddlewareAction // please notice, not KeyPath, it doesn't start by \
但是简写形式不能同时遍历 2 个层级。
{ (middlewareAction: MiddlewareAction) -> AppAction in
AppAction.firstLevel( FirstLevel.secondLevel(middlewareAction) )
}
// this will NOT compile (although a better Prism could solve that, probably):
AppAction.firstLevel.secondLevel
// You could try, however, to lift twice:
.lift(outputAction: FirstLevel.secondLevel) // Notice that first we wrap the middleware value in the second level
.lift(outputAction: AppAction.firstLevel) // And then we wrap the first level in the AppAction
// The order must be from inside to outside, always.
身份、忽略和荒谬
无
- 当中间件不需要状态时,它可以是 Void
- 使用
ignore
提升 Void,它是{ (_: Anything) -> Void in }
永不
- 当中间件不需要分发操作时,它可以是 Never
- 使用
absurd
提升 Never,它是{ (never: Never) -> Anything in }
身份
- 当你的转换的某些部分应该保持不变,因为它们已经处于预期的类型时
- 使用
identity
提升它,它是{ $0 }
理论背景:Void 和 Never 是对偶的
- 任何东西都可以变成 Void(终端对象)
- Never(初始对象)可以变成任何东西
- Void 有 1 个可能的实例(它是一个单例)
- Never 有 0 个可能的实例
- 因为没有人为你提供 Never,所以你可以承诺任何东西作为挑战。这就是为什么函数被称为 absurd,因为它无法被调用。
Xcode 段落
// Reducer expanded
.lift(
actionGetter: { (action: AppAction) -> <#LocalAction#>? in action.<#something?.child#> },
stateGetter: { (state: AppState) -> <#LocalState#> in state.<#something.child#> },
stateSetter: { (state: inout AppState, newValue: <#LocalState#>) -> Void in state.<#something.child#> = newValue }
)
// Reducer KeyPath:
.lift(
action: \AppAction.<#something?.child#>,
state: \AppState.<#something.child#>
)
// Middleware expanded
.lift(
inputAction: { (action: AppAction) -> <#LocalAction#>? in action.<#something?.child#> },
outputAction: { (local: <#LocalAction#>) -> AppAction in AppAction.<#something(.child(local))#> },
state: { (state: AppState) -> <#LocalState#> in state.<#something.child#> }
)
// Middleware KeyPath
.lift(
inputAction: \AppAction.<#local#>,
outputAction: AppAction.<#local#>, // not more than 1 level
state: \AppState.<#local#>
)
架构
此数据流在某种程度上是一种MVC的实现,与苹果的MVC有显著不同,它提供了一个非常严格且具有意见描述的层责任,并通过更好地定义其实现方式强制Model层的增长:在这种场景下,Model是Store。所有您的Controller需要做的就是将视图操作转发到Store,并在需要时订阅状态变化,更新视图。如果这种流程听起来不像MVC,那就看看我们从苹果网站上截图的图片吧。
一个重要的区别是关于用户操作:在SwiftRex中,它由Controller转发,达到Store,因此更新状态的责任现在成为了Store的责任。其余部分基本上是相同的,但对Model如何运行有了更好的定义。
╼━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╾
╱░░░░░░░░░░░░░░░░░◉░░░░░░░░░░░░░░░░░░╲
╱░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░╲
┃░░░░░░░░░░░░░◉░░◖■■■■■■■◗░░░░░░░░░░░░░░░░░┃
┃░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░┃
╭┃░╭━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╮░┃
│┃░┃ ┌──────────┐ ┃░┃
╰┃░┃ │ UIButton │────────┐ ┃░┃
┃░┃ └──────────┘ │ ┃░┃
╭┃░┃ ┌───────────────────┐ │ ┃░┃╮ dispatch<Action>(_ action: Action)
│┃░┃ │UIGestureRecognizer│───┼──────────────────────────────────────────────┐
│┃░┃ └───────────────────┘ │ ┃░┃│ │
╰┃░┃ ┌───────────┐ │ ┃░┃│ ▼
╭┃░┃ │viewDidLoad│───────┘ ┃░┃╯ ┏━━━━━━━━━━━━━━━━━━━━┓
│┃░┃ └───────────┘ ┃░┃ ┃ ┃░
│┃░┃ ┃░┃ ┃ ┃░
╰┃░┃ ┃░┃ ┃ ┃░
┃░┃ ┌───────┐ ┃░┃ ┃ ┃░
┃░┃ │UILabel│◀─ ─ ─ ─ ┐ ┃░┃ ┃ ┃░
┃░┃ └───────┘ ┃░┃ Combine, RxSwift ┌ ─ ─ ┻ ─ ┐ ┃░
┃░┃ │ ┃░┃ or ReactiveSwift State Store ┃░
┃░┃ ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╋░─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─│Publisher│ ┃░
┃░┃ ▼ │ ┃░┃ subscribe(onNext:) ┃░
┃░┃ ┌─────────────┐ ▼ ┃░┃ sink(receiveValue:) └ ─ ─ ┳ ─ ┘ ┃░
┃░┃ │ Diffable │ ┌─────────────┐ ┃░┃ assign(to:on:) ┃ ┃░
┃░┃ │ DataSource │ │RxDataSources│ ┃░┃ ┃ ┃░
┃░┃ └─────────────┘ └─────────────┘ ┃░┃ ┃ ┃░
┃░┃ │ │ ┃░┃ ┃ ┃░
┃░┃ ┌──────▼───────────────▼───────────┐ ┃░┃ ┗━━━━━━━━━━━━━━━━━━━━┛░
┃░┃ │ │ ┃░┃ ░░░░░░░░░░░░░░░░░░░░░░
┃░┃ │ │ ┃░┃
┃░┃ │ │ ┃░┃
┃░┃ │ │ ┃░┃
┃░┃ │ UICollectionView │ ┃░┃
┃░┃ │ │ ┃░┃
┃░┃ │ │ ┃░┃
┃░┃ │ │ ┃░┃
┃░┃ │ │ ┃░┃
┃░┃ └──────────────────────────────────┘ ┃░┃
┃░╰━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╯░┃
┃░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░┃
┃░░░░░░░░░░░░░░░░░░░▓▓▓▓░░░░░░░░░░░░░░░░░░░┃
┃░░░░░░░░░░░░░░░░░░▓▓▓▓▓▓░░░░░░░░░░░░░░░░░░┃
╲░░░░░░░░░░░░░░░░░░▓▓▓▓░░░░░░░░░░░░░░░░░░╱
╲░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░╱
╼━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╾
您可以把Store想象成一个非常沉重的“Model”层,完全独立于View和Controller,所有的业务逻辑都在这里。乍一看,这就像把“重大”问题从一个层转移到另一个层,这就是为什么Store只是一个具有非常明确角色和,最重要的是,限制的组合框集合。
╼━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╾
╱░░░░░░░░░░░░░░░░░◉░░░░░░░░░░░░░░░░░░╲
╱░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░╲
┃░░░░░░░░░░░░░◉░░◖■■■■■■■◗░░░░░░░░░░░░░░░░░┃
┃░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░┃
╭┃░╭━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╮░┃
│┃░┃ ┌────────┐ ┃░┃
╰┃░┃ │ Button │────────┐ ┃░┃
┃░┃ └────────┘ │ ┃░┃ ┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐ ┏━━━━━━━━━━━━━━━━━━━━━━━┓
╭┃░┃ ┌──────────────────┐ │ ┃░┃╮ dispatch ┃ ┃░
│┃░┃ │ Toggle │───┼────────────────────▶│ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─▶ │────────────▶┃ ┃░
│┃░┃ └──────────────────┘ │ ┃░┃│ view event f: (Event) → Action app action ┃ ┃░
╰┃░┃ ┌──────────┐ │ ┃░┃│ │ │ ┃ ┃░
╭┃░┃ │ onAppear │───────┘ ┃░┃╯ ┃ ┃░
│┃░┃ └──────────┘ ┃░┃ │ ObservableViewModel │ ┃ ┃░
│┃░┃ ┃░┃ ┃ ┃░
╰┃░┃ ┃░┃ │ a projection of │ projection ┃ Store ┃░
┃░┃ ┃░┃ the actual store ┃ ┃░
┃░┃ ┃░┃ │ │ ┃ ┃░
┃░┃ ┌────────────────────────┐ ┃░┃ ┃ ┃░
┃░┃ │ │ ┃░┃ │ │ ┌┃─ ─ ─ ─ ─ ┐ ┃░
┃░┃ │ @ObservedObject │◀ ─ ─ ╋░─ ─ ─ ─ ─ ─ ─ ─ ◀─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ◀─ ─ ─ ─ ─ ─ State ┃░
┃░┃ │ │ ┃░┃ view state │ f: (State) → View │ app state │ Publisher │ ┃░
┃░┃ └────────────────────────┘ ┃░┃ State ┳ ─ ─ ─ ─ ─ ┃░
┃░┃ │ │ │ ┃░┃ └ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘ ┗━━━━━━━━━━━━━━━━━━━━━━━┛░
┃░┃ ▼ ▼ ▼ ┃░┃ ░░░░░░░░░░░░░░░░░░░░░░░░░
┃░┃ ┌────────┐ ┌────────┐ ┌────────┐ ┃░┃
┃░┃ │ Text │ │ List │ │ForEach │ ┃░┃
┃░┃ └────────┘ └────────┘ └────────┘ ┃░┃
┃░┃ ┃░┃
┃░┃ ┃░┃
┃░┃ ┃░┃
┃░┃ ┃░┃
┃░┃ ┃░┃
┃░┃ ┃░┃
┃░┃ ┃░┃
┃░┃ ┃░┃
┃░┃ ┃░┃
┃░┃ ┃░┃
┃░╰━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╯░┃
┃░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░┃
┃░░░░░░░░░░░░░░░░░░░▓▓▓▓░░░░░░░░░░░░░░░░░░░┃
┃░░░░░░░░░░░░░░░░░░▓▓▓▓▓▓░░░░░░░░░░░░░░░░░░┃
╲░░░░░░░░░░░░░░░░░░▓▓▓▓░░░░░░░░░░░░░░░░░░╱
╲░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░╱
╼━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╾
那么SwiftUI呢?这种架构适合新的UI框架吗?实际上,这种架构在SwiftUI中工作得更好,因为SwiftUI受到了几个功能模式的启发,它是反应性和无状态的。在2019年WWDC上多次提到,在SwiftUI中,视图是状态的函数,我们应该始终追求单源真理,并且数据应该始终单向流动。
安装
CocoaPods
在您的项目根目录中创建或修改Podfile。您的设置将取决于您选择的ReactiveFramework。
对于Combine
# Podfile
source 'https://github.com/CocoaPods/Specs.git'
use_frameworks!
target 'MyAppTarget' do
pod 'CombineRex'
end
对于RxSwift
# Podfile
source 'https://github.com/CocoaPods/Specs.git'
use_frameworks!
target 'MyAppTarget' do
pod 'RxSwiftRex'
end
对于ReactiveSwift
# Podfile
source 'https://github.com/CocoaPods/Specs.git'
use_frameworks!
target 'MyAppTarget' do
pod 'ReactiveSwiftRex'
end
如上所示,一些行是可选的,因为最终的Podspecs已经包含了正确的依赖项。
然后,您只需安装您的pods并打开.xcworkspace
文件,而不是.xcodeproj
文件即可。
$ pod install
$ xed .
Swift Package Manager
在您的项目根目录中创建或修改Package.swift文件。您可以使用自动链接模式(静态/动态),或者使用后缀为Dynamic的项目来强制动态链接,并克服当前Xcode在解决菱形依赖问题上的限制。
如果您只从一个目标使用它,自动模式应该是没有问题的。
组合,自动链接模式
// swift-tools-version:5.5
import PackageDescription
let package = Package(
name: "MyApp",
platforms: [.macOS(.v10_15), .iOS(.v13), .tvOS(.v13), .watchOS(.v6)],
products: [
.executable(name: "MyApp", targets: ["MyApp"])
],
dependencies: [
.package(url: "https://github.com/SwiftRex/SwiftRex.git", from: "0.8.12")
],
targets: [
.target(name: "MyApp", dependencies: [.product(name: "CombineRex", package: "SwiftRex")])
]
)
RxSwift,自动链接模式
// swift-tools-version:5.5
import PackageDescription
let package = Package(
name: "MyApp",
platforms: [.macOS(.v10_10), .iOS(.v8), .tvOS(.v9), .watchOS(.v3)],
products: [
.executable(name: "MyApp", targets: ["MyApp"])
],
dependencies: [
.package(url: "https://github.com/SwiftRex/SwiftRex.git", from: "0.8.12")
],
targets: [
.target(name: "MyApp", dependencies: [.product(name: "RxSwiftRex", package: "SwiftRex")])
]
)
ReactiveSwift,自动链接模式
// swift-tools-version:5.5
import PackageDescription
let package = Package(
name: "MyApp",
platforms: [.macOS(.v10_10), .iOS(.v8), .tvOS(.v9), .watchOS(.v3)],
products: [
.executable(name: "MyApp", targets: ["MyApp"])
],
dependencies: [
.package(url: "https://github.com/SwiftRex/SwiftRex.git", from: "0.8.12")
],
targets: [
.target(name: "MyApp", dependencies: [.product(name: "ReactiveSwiftRex", package: "SwiftRex")])
]
)
Combine,动态链接模式(对RxSwift或ReactiveSwift产品也采用类似的方法添加“动态”后缀)
// swift-tools-version:5.5
import PackageDescription
let package = Package(
name: "MyApp",
platforms: [.macOS(.v10_15), .iOS(.v13), .tvOS(.v13), .watchOS(.v6)],
products: [
.executable(name: "MyApp", targets: ["MyApp"])
],
dependencies: [
.package(url: "https://github.com/SwiftRex/SwiftRex.git", from: "0.8.12")
],
targets: [
.target(name: "MyApp", dependencies: [.product(name: "CombineRexDynamic", package: "SwiftRex")])
]
)
然后您可以在终端中构建,或者使用支持原生SPM的Xcode 11或更高版本。
$ swift build
$ xed .
重要: 对于Xcode 12,请使用版本0.8.8。0.9.0及更高版本需要Xcode 13。
Carthage
由于兴趣不足和维护工作量高,Carthage不再受支持。
如果这确实对您非常重要,请创建一个GitHub问题,并告诉我们,我们将评估使其恢复的可能性。同时,您可以检查最后一个兼容Carthage的版本,该版本为0.7.1,然后针对该版本,直到我们找到更好的解决方案。