如果您有任何关于SwiftRex或Redux以及泛型编程(Functional Programming)的一般问题,请加入我们的Slack频道。
简介
SwiftRex是一个框架,它结合了单向数据流架构和响应式编程(《Combine》、《RxSwift》或《ReactiveSwift》),为您的整个应用状态提供了一个中央状态Store,您的SwiftUI视图或UIViewControllers可以观察并对此做出反应,并提供处理来自用户交互的事件。
这种模式也被称为"Redux",允许我们将应用视为一个单一生成函数(纯函数),它接收用户事件作为输入,并返回UI更改作为响应。这种工作流程的好处将会很快变得清晰。
快速指南
在赶时间?是否已经熟悉了其他Redux实现?
没问题,我们有一个TL;DR快速指南,以非常实用的方式展示了您需要了解的SwiftRex的最基本信息。
我们仍然建议您阅读完整的README,以便更深入地了解SwiftRex的概念。
目标
如今,许多用于移动开发的架构和设计模式提出解决与< spyOn https://www.youtube.com/watch?v=Gt0M_OHKhQE" rel="nofollow">单一职责原则相关的特定问题(如巨型ViewController),或提高可测试性和依赖管理。对于移动开发者来说,其他一些常见的挑战,如状态处理、竞态条件、模块化/组件化、线程安全性,或适当地处理UI生命周期和所有权,虽然探索较少,但同样可能对应用程序造成危害。
管理所有这些问题可能听起来像是一项不可能的任务,需要大量模式和复杂的教学场景。毕竟,如何重现只有一些用户才能发现的罕见但关键的错误,而开发者设备上从未出现?这可能会令人沮丧,我们大多数人可能都曾不时地遇到过这样的问题。
这就是SwiftRex闪亮登场的地方,因为它
强制执行单一职责原则[展开]
一些架构非常灵活,允许我们在任何地方添加任何代码。这对于只有一个人开发的大部分小型应用程序来说应该没问题,但一旦项目和团队开始增长,一些层会变得非常庞大,承担过多的责任,隐式副作用,竞态条件和其他错误。在这种情况下,可测试性也会受损,应用程序各部分之间的连贯性也会受损,因此查找和修复错误变得非常困难。
SwiftRex通过制定一个非常严格的策略来防止这种情况,即代码应该在哪里以及这个层有多大的限制,通常由编译器强制执行。好吧,这听起来很难而且很复杂,但实际上它比传统模式更容易,因为一旦你了解这个架构,你就知道应该做什么,你知道根据其职责在哪里查找某些代码行,你知道如何测试每个组件,而且你非常清楚地了解每个层的边界。
为每个层提供清晰的测试策略(查看测试扩展)[展开]
我们认为,架构不仅要非常易于测试,而且要为如何测试其每个层提供一个清晰的指南。如果一个层只有一个任务,而这个任务可以根据给定的输入始终通过期望的输出断言进行验证,那么测试就可以更有意义和更广泛,因此不会在创建新功能时引入回归。
SwiftRex架构中的大多数层将是纯函数,这意味着所有计算都是基于输入参数完成的,所有结果都将暴露在输出上,没有隐式效果或对全局作用域的访问。这种测试无需mock、stubs、依赖注入或任何类型的准备,你只需用一个值调用一个函数,检查结果即可。
这同样适用于UI层、演示层、reducers和状态发布者,因为整个链是一个纯函数的组合。唯一需要依赖注入、因此需要mock的层是中间件,因为它取决于服务并对外部世界触发副作用。幸运的是,由于中间件是可组合的,我们可以将它们分解成非常小的部分,每部分只做一项工作,因此测试变得更加愉快和容易,因为你只需注入一个即可,而无需mock数百个组件。
我们还提供了测试扩展,允许我们使用一种DSL语法(领域特定语言)测试整个用例,这将验证所有的SwiftRex层,确保没有出现意外的副作用或动作,状态按预期的逐步更改。这是一种强大而有趣的方式,只需几行简单易于撰写的代码即可测试整个应用程序。
在不可变中间件盒子中隔离所有副作用[展开]
如果一个层需要同时处理多个服务并异步响应用户操作而改变状态,那么保持这个状态的统一性和防止竞态条件是非常困难的。同时,测试也更加困难,因为一个效果可能会干扰另一个。
多年来,苹果和社区创建了令人惊叹的框架来访问网页或网络中的服务以及设备中的传感器。不幸的是,其中一些框架依赖于委托模式,一些使用闭包/回调,一些使用通知中心、KVO或响应式流。组合这种通知形式将需要布尔标志、计数器以及其他最终因竞态条件而破坏的隐式状态。
响应式框架有助于使这一过程更加统一和可组合,尤其是在与它们的Cocoa扩展一起使用时。事实上,苹果也意识到了这一点,2019年WWDC([点击查看视频](https://developer.apple.com/videos/play/wwdc2019/226/))的很大一部分就是关于演示和解决这个问题,借助新引入的框架Combine和SwiftUI。
但是,在响应式管道中组合许多服务并不是总是一件简单的事情,它有其自身的陷阱,如因一个流发出错误而导致整个管道取消,事件递归性,以及最后但同样重要的是,深入学习几个操作符的陡峭曲线。
SwiftRex大量使用响应式编程,并允许你尽可能多地使用它。然而,我们也提供了一种更统一的组合不同服务的方式,只需使用1种数据类型和2个操作符:中间件,`< >` 操作符和 `lift` 操作符,其他所有操作都可以通过触发对自身、其他中间件或状态减少器的操作来简化。你仍然可以选择创建一个更大的中间件,并以传统响应式流的方式处理多个来源,如果你喜欢的话,但这可能对不熟悉开发的新手来说可能会让人不知所措,也更加难以测试和在不同的应用程序中重复使用。
由于这个主题非常广泛,它将在中间件文档中更好地解释。
最小化了依赖于ViewControllers/Presnters/Interactors/SwiftUI Views [点击展开]
在浏览你的应用程序的同时传递依赖关系从来不是一件容易的事情:ViewControllers初始化器非常棘手,你必须始终考虑类是创建自NIB/XIB、程序化还是Storyboards,然后编写正确的init方法,不仅要传递这个类所需要的所有依赖,还要传递其子视图控制器和按下按钮时将被推入的下一个视图控制器的依赖。因此,在通过路由时,你必须在视图之间发送数十个依赖。如果你不使用初始化器而喜欢属性分配,这些属性必须使用隐式解包,这并不是很好。
当然,协调器/路由模式对此有所帮助,但你 somehow 将问题转移到了路由器那里,它们也需要保留更多实际上使用的依赖,因为下一个路由器将会使用。你可以使用服务定位器模式,如流行的环境方法,这确实是一个处理问题的简单方法。但是,测试这个单例可能会有点复杂,因为,嗯,它是一个单例。有些人不喜欢这种隐式注入,更愿意添加一个层需要的显式依赖。
所以,不可能解决这个问题让每个人都满意,对吗?实际上并非如此。如果你的视图控制器只需要单个名为“Store”的依赖项,从中获取它需要的状态并将所有用户事件分派到那里,而不实际执行任何工作,会发生什么?在这种情况下,注入store要容易得多,无论你使用显式注入还是服务定位器。
好的,但仍然需要有人去做这项工作,这正是中间件的任务。在SwiftRex中,应该在中件在应用的入口点创建,紧接在配置好依赖并准备就绪之后。然后创建所有中间件,注入它们执行工作所需的一切(希望每个中间件不要超过2个依赖,所以你知道它们没有承担太多责任)。最后,将它们组合起来并开始使用商店。中间件可以拥有计时器,也可以纯粹响应用户界面的动作,但它们是唯一具有副作用的一层,因此是唯一需要服务依赖的一层。
最后,你可以在全局状态中添加地区、语言和界面特性,即使需要在你的状态中创建数字和日期格式器,你也可以不使用依赖注入来完成它,而且更好的是,当用户决定更改iOS设置时可以正确响应。
将状态、服务和突变以及其他副作用完全从UI生命周期及其所有者树中分离出来[点击展开]
UIViewControllers具有一个非常独特的所有权模型:你不控制它。当视图控制器位于导航堆栈中、正在显示标签页或模态视图时,它们会保持存在,但可以在任何时间点进行释放,以及它下面任何你将其置于视图控制器之下的事物。我们一直在使用和喜爱的所有那些[weak self]有时也实际上是弱引用,在我们说“guard that else return”时很容易不对此进行思考。任何重要任务,无论你的视图是否显示,都应不在视图控制器生命周期内,因为用户可以轻易地关闭你的模态或弹出你的视图。SwiftUI改善了这一点,但仍然有可能从视图的闭包中启动异步任务,尽管现在这个视图是一个值类型,这稍微难做,但仍有可能出错。
通过强制要求所有和每一项副作用或异步任务都由中间件,而不是视图来执行,SwiftRex解决了这个问题。并且中间件的生命周期属于商店,所以只要应用在运行,我们就不会期望出现任何不幸的惊喜。
你仍然可以从你的视图中分发“viewDidLoad”,“onAppear”,“onDisappear”事件,以便执行任务取消,这样你就可以获得更多的控制,而不是更少。
有关更多信息,请点击此链接
消除竞争条件[点击展开]
当应用程序必须处理来自不同服务和来源的信息时,通常需要在这里或那里添加小的布尔标志,以检查某些操作是否已完成或失败。通常这是由于某些服务通过委托,一些通过闭包等报告回,还有其他几种创新方式。使用标志同步这些多个来源,或从并发任务中突变相同的变量或数组,可能导致真正奇怪的错误和崩溃,这些通常是捕捉、理解和修复最困难的错误。
处理锁和dispatch队列可能会有所帮助,但以这种方式反复和计划外进行会既乏味又危险,必须编写测试来考虑所有可能的路径和时间,而其中一些测试最终可能会变得不稳定,如果仍然存在竞争条件。
通过让应用的所有事件都通过同一个队列,并且在最后以一致的方式均匀地突变全局状态,SwiftRex将防止竞态条件。首先,因为中间件是副作用和异步任务的唯一来源,这将使竞态条件的测试变得简单,特别是如果你使它们保持小型化和专注于单一任务。在这种情况下,你的响应将按照FIFO顺序通过队列到来,并由所有缩减器同时处理。其次,因为缩减器是状态突变的守门人,使它们没有副作用对于成功和一致地突变至关重要。最后但同样重要的是,所有这些都是在响应于动作的情况下发生的,动作可以被轻松地记录在你的崩溃报告中,包括调用了哪个动作,所以如果你仍然发现竞态条件发生,你可以轻松地了解哪些动作正在突变状态以及这些动作来自何处。
允许更类型安全的编码风格 [点击展开]
Swift泛型有点难以学习,而且还有与这些协议关联的类型。SwiftRex不需要你掌握泛型,理解协变或类型擦除,但当你更深地进入这个领域时,你肯定会写出编译器验证而不是单元测试验证的应用程序。将运行时错误从编译时转移到是一种非常重要的目标,我们作为优秀的开发者都应该接受。与在应用发布到野外的崩溃报告后检查相比,挣扎于Swift类型系统可能更好。这正是Swift带来的思维模式,作为静态类型语言,甚至nullability也是类型安全的,多亏了Optional,我们现在可以安心地知道,除非我们不安全地并且明确地选择这样做, otherwise we won't access null pointers.
SwiftRex强制在各个地方使用强类型的事件/动作和状态:存储动作分发器、中间件动作处理程序、中间件动作输出、缩减器动作和状态输入输出以及存储状态观察,整个流程都是强类型的,因此编译器可以防止错误或运行时错误。
此外,中间件、缩减器和存储都可以从部分状态和动作“提升”到全局状态和动作。这意味着你可以编写一个强类型模块来在特定领域操作,例如网络可达性。你的中间件和缩减器将“说”网络领域状态和动作,比如是否连接,是Wi-Fi还是LTE,是否发生了连接变化动作等。然后,你可以通过提供两个映射函数将这两个组件-中间件和缩减器-“提升”到你的应用的全球状态,一个用于提升状态,另一个用于提升动作。多亏了泛型,整个操作都是完全类型安全的。同样,也可以从主存储“推导”出存储投影。存储投影实现了存储必须有的两个方法(输入动作和输出状态),但它不是真正的存储,它只将全局状态和动作投影到更局部化的领域,这意味着,视图事件被转换成动作,视图状态被转换成领域状态。
有了这些工具,我们相信,如果你愿意,你可以编写一个从边缘到边缘类型安全的应用程序。
有助于在项目之间实现模块化、组件化和代码重用 [点击展开]
中间件应专注于一个非常小的领域,执行仅一种类型的工作,并以动作的形式返回。缩减器应专注于一种非常小的动作和状态的组合。视图应能够访问非常小的状态部分,或理想情况下,是一个视图状态,它是全局应用状态(使用直接映射到文本框字符串、开关的布尔值、进度条的从0.0到1.0的双值等原始数据)的平面表示。
然后,你可以将这三个部分-中间件、缩减器、存储投影-提升到你的应用所需的全球状态和动作中。
SwiftRex 允许我们创建小的单元工作,只有在需要时才能将其提升到全局域,这样我们就可以在特定域中运行 Swift 框架,并使用测试和 Playgrounds/SwiftUI 预览来使用,而无需启动整个应用程序。一旦这个框架准备好,我们只需要插入我们的应用程序,或者更好的是应用程序。专注于小域将解锁更优的抽象,当它从中间件(副作用)变到视图时,你将拥有一个强大的工具来定义你的构建块。
强制使用单一事实来源和适当的状态管理 [点击展开]
SwiftRex 可以实现一个可信赖的单一事实来源,该来源将在屏幕间永远保持一致且同步。将所有状态放在一个单一的位置,一个单一的树中包含一切可能会让人感到害怕。看到你需要多少状态也可能会令人害怕。但不用担心,这并不是你没有的,它已经存在了,在 ViewController、Presenter 中,在用于控制服务结果的标志中,但由于它分布得太广,你没有看到它有多大。更糟糕的是,这会导致重复,因为当你需要从两个不同的地方获取相同的信息时,复制并希望正确同步它们会更容易。
实际上,当你将整个应用程序状态收集到一个统一的树中时,你开始去除你不再需要的大量东西,最终的状态将比混乱的状态更小。
正确书写全局状态和全局动作树可能具有挑战性,但这确实是应用程序域,并且对此进行推理可能是工程师必须做的最重要的任务。
有关更多信息,请点击此链接
提供开发、测试和调试工具 [点击展开]
有几个项目提供了 SwiftRex 工具,以帮助开发者编写应用程序、测试、调试以及在评估崩溃报告时。
CombineRextensions 提供了与 CombineRex 一起工作的 SwiftUI 扩展,TestingExtensions 有“测试断言”,以有趣且简单的方式解锁用例的可测试性,InstrumentationMiddleware 允许您使用 Instruments 查看 SwiftRex 应用程序中的情况,SwiftRexMonitor 将是知名 Redux DevTools 的 Swift 版本,您可以在外部 Mac 或 iOS 拥有远程监控应用程序的状态和操作,并甚至注入动作来模拟副作用(例如,适用于 UITests),GatedMiddleware 是一个中间件包装器,可以在运行时启用或禁用其他中间件,LoggerMiddleware 是一个超级强大的日志记录器,允许开发者轻松理解运行时的情况。不久将要开源更多中间件,例如,允许创建良好的 Crashlytics 报告,该报告讲述了一个崩溃的故事,这是你以往从未真正接触过的,并且以这种方式,重新创建崩溃或用户报告。此外,还有用于生成代码的工具(Sourcery 模板、Xcode 片段和模板、控制台工具),以及更高级别的 API,例如 EffectMiddlewares,允许我们通过与 Reducers 一样简单易用的方式创建具有单个功能的 Middlewares,或者 Handler 将允许在相同的结构下对 Middlewares 和 Reducers 进行分组,以便能够同时提升两者。新依赖注入策略也即将发布。
所有这些工具都已准备就绪,并将立即发布,未来还有更多。
说实话,这是一种完全不同的编写应用程序的方式,就像大多数反应式方法一样;但一旦你习惯了,它更有意义,并使你能够在项目之间重用更多代码,为你编写软件、测试、调试、日志记录以及最终以你从未有过的角度思考事件、状态和变化提供更好的工具。我向你们保证,这将是一条没有回头路的单向旅程。
反应式框架库
SwiftRex目前支持3个主要反应式框架
稍后可以很容易地添加更多框架,只需实现ReactiveWrappers.swift
文件中的一些抽象桥接就可以。为了避免将不必要的文件添加到您的应用程序中,SwiftRex被拆分为4个包
- SwiftRex: 核心库
- CombineRex: 为Combine框架的实现
- RxSwiftRex: 为RxSwift框架的实现
- ReactiveSwiftRex: 为ReactiveSwift框架的实现
SwiftRex本身不足以使用,因此您必须选择其中一个实现。
部分
让我们通过将它们分为3个部分来理解SwiftRex的组件
概念部分
操作
操作表示由应用的外部(有时是内部)参与者通知的事件。它涉及到相关的输入事件。
在SwiftRex中没有名为“操作”的协议或类型。但是,操作将作为一个泛型参数存在于大多数核心数据结构中,这意味着定义根操作类型将由你决定。
从概念上讲,操作代表来自应用外部参与者的某种事件,也就是说用户交互、计时器回调、Web服务响应、CoreLocation和其他框架的回调。虽然一些内部参与者也可以启动操作,例如,当UIKit完成加载视图时,我们可以将viewDidLoad
视为一个操作,如果我们对此事件感兴趣。对于SwiftUI View(.onAppear
、.onDisappear
、.onTap
)或手势(.onEnded
、.onChanged
、.updating
)修饰符,它们也可以被视为操作。当URLSession返回我们可以将其解析为结构体的数据时,这可以是一个成功的操作,但如果是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中的枚举没有与结构体相同的关键路径,我们强烈建议您阅读《操作枚举属性》文档,并为每个情况手动或使用代码生成器实现属性,这样您就可以避免编写大量易出错的switch/case。我们还提供了一些模板来帮助您。
状态
状态表示应用程序在开启时持有的全部知识,通常在内存中且可变。这关乎相关输出属性。
在SwiftRex中,没有“状态”协议或类型。然而,状态通常作为大多数核心数据结构的泛型参数被发现,意味着定义根状态类型的事由你自行决定。
从概念上讲,我们可以说状态表示应用程序在开启时持有的全部知识,通常在内存中且可变;它就像一张纸,你在上面写下一些值,对于你收到的每个动作,你擦去一个值,用另一个值替换它。另一种思考状态的方式是函数式编程方法:状态不持久化,但它是从一个函数的结果得到的,该函数将应用程序的初始条件和它自启动以来收到的所有动作结合起来,通过对初始状态应用所有按时间顺序的更改来计算当前值。这被称为事件存储设计模式,它最近在一些网络后端服务中变得越来越流行,例如Kafka事件存储。
在电池和内存有限的设备上,我们不能忍受真正的事件存储模式,因为它太昂贵了,每次有人请求一个简单的布尔值时,都需重新创建应用的全部历史。因此,当我们收到一个动作时,我们“缓存”新的状态,而这个内存中的缓存恰好是我们称其为SwiftRex中“状态”的东西。所以,也许我们可以混合两种思考状态的方式,来提出一个关于状态是什么的更好的一般化。
状态是函数的结果,该函数接收两个参数:前一个(或初始)状态和某个发生过的动作,以确定新状态。这随着越来越多动作的到来而增量地发生。状态对于向用户提供输出数据很有用。
然而,请注意,有些东西可能看起来像状态,但并不是。让我们假设你有一个向用户显示商品价格的程序。这个价格在美国将显示为“$3.00”,在德国将显示为“$3,00”,或者这个商品可能在英镑中列出,因此在美国我们应该显示“£3.00”,而在德国它会显示为“£3,00”。在这个例子中,我们有:
- 货币类型(
£
或$
) - 数值(
3
) - 地区(
en_US
或de_DE
) - 格式化字符串(
"$3.00"
,"$3,00"
,"£3.00"
或"£3,00"
)
格式化字符串本身不是状态,因为它可以从其他属性计算得出。这可以称为“导出状态”并且保留它是请求不一致性的。我们必须记住每次其他任一属性改变时都更新此值。所以最好将以计算属性或函数的形式来表示这个字符串,这其他三个值。这种类型的导出状态的最好地方是在展示者或控制器中,除非你有一个高成本来重新计算它,在这种情况下你可以将其存储在状态中,并且对此非常小心。幸运的是,SwiftRex如我们在Reducer部分关于如何保持状态一致性即将看到的,仍然更有利于不让易于和成本低廉计算的信息重复。
为了表示应用程序的状态,我们建议使用值类型:结构体或枚举。元组也可以接受,但不幸的是,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 │
│ │
│ │
│ │
│ │
└──────────────────────────────────┘
存在实际存储的实现,它是唯一的一个实例,将成为整个Redux架构的核心中心。其他实现只能是投影或主存储,因此它们通过实现相同的角色来充当存储,但它们不对全局状态进行所有者操作或直接处理动作,这些投影只需在链中应用一些小的(且纯的)转换并向真实存储委托。这在您想在视图中拥有本地“存储”时非常有用,但您不想它们重复数据或拥有任何类型的状态,而只想充当存储而非在幕后使用中央存储。
有关真实存储的更多信息,请参阅ReduxStoreBase
和ReduxStoreProtocol
,有关投影的更多信息,请参阅StoreProjection
和StoreType/projection(action:state:)
。
实际存储是什么?
真实存储是一个你想要在整个应用程序执行期间创建和保持生命周期的类,因为它的唯一责任是 като координатор за животновъдния цикъл на Един方向的资料流。这也正是我们想要一个唯一的存储实例的原因,因此你要么创建一个静态实例单例,要么将其保存在AppDelegate中。如果你的应用程序支持多个窗口并且你想要在这些多个应用程序实例之间共享状态,请小心处理SceneDelegate。这通常是你想要的。这就是为什么通常建议用AppDelegate,单例或全局变量作为存储,而不是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
}
}
存储投影是什么?
通常,你不想让你的视图能够访问整个应用状态或分发任何可能的全球应用操作。这不仅可能会比需要时更快地刷新你的UI,而且还增加了错误的可能性,在视图层增加了更复杂的代码,最终减少了模块化,使得视图与全局模型耦合。
然而,你不想将你的状态分割成多个部分,因为将它集中于一处可以确保一致性。此外,你也不想多个不同的地方负责动作,因为这可能会导致竞争条件。真实的存储确实是唯一拥有全局状态并实际处理动作的地方,这就是它应该的方式。
为了解决这两个问题,我们提供了一种StoreProjection
,它遵循StoreType
协议,所以从所有目的来看,它就像一个真实的存储一样行为,但实际上它只使用自定义类型(状态和动作)投影实际的存储,即要么是你的模型的一个子集(例如状态树中的一个分支),要么是一个完全不同的实体,如视图状态。一个StoreProjection
有2个闭包,允许它在全局动作和视图使用的动作之间转换动作和状态。这样,视图就不会与全局模型耦合,而只是与其中很小的一部分耦合,而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交互的名称。如何在应用程序动作中映射这部分的责任由其他部分承担,在我们的例子中,由ContentViewAction
本身,但也可以是展示层、视图模型层,或您决定创建的组织代码的任何结构。
使用这种方法,测试也变得简单,因为视图不持有任何逻辑,投影转换是纯函数。
中间件
MiddlewareProtocol
是一个插件,或多个插件的组合,它被分配到应用程序全局StoreType
管道中,以处理接收到的每个动作(InputActionType
),执行对应的副作用,并在过程中最终分派更多动作(OutputActionType
)。它还可以在处理传入动作时访问最新的StateType
。
我们可以将中间件视为一个对象,它将操作转换为同步或异步任务,并在这些副作用完成后创建更多的操作,同时还能够在处理操作时检查当前状态。
动作(Action)是一种轻量级结构,通常是一个枚举,会被分发给处理器(通常是一个StoreType
)。
例如,像ReduxStoreProtocol
这样的存储将新到达的操作入队,并将其提交到中间件管道。换句话说,一个MiddlewareProtocol
是一个处理操作并且可以立即或异步任务回调后分派更多操作的类的名称。中间件还可以简单地忽略动作,或者执行相应的副作用,例如记录到文件或通过网络,或者执行http请求等。对于这些异步任务,当它们完成时,中间件可以分派包含响应有效负载的新操作(如JSON文件、电影数组、凭证等)。其他中间件将处理这些操作,或者可能甚至相同的中间件在将来处理,或者也许是某个Reducer
使用这些操作来改变状态,因为MiddlewareProtocol
自己永远不能改变状态,只能读取它。
MiddlewareProtocol/handle(action:from:state:)
将在Reducer之前被调用,因此如果你在那个时刻读取状态,它仍然是未更改的版本。在实现这个函数时,你期望返回一个IO
对象,它基本上是一个闭包,在它里面你可以执行副作用并分派新动作。在这个闭包内部,状态将在Reducer处理当前动作后的新值,所以如果你复制了旧的状态,你可以进行比较、记录、审计、执行分析跟踪、遥测或与外部设备(如Apple Watch)进行状态同步。通过网络进行远程调试也是一个很好的中间件用途。
每个分派的动作都附带其动作来源,即该动作的主要分派者。中间件可以访问文件名、行代码、函数名以及有关创建和分派该动作的责任实体的其他信息,这是一份非常有用的调试信息,可以帮助开发者追踪信息在应用中的流动。
理想情况下,一个MiddlewareProtocol
应该是一个小巧并且可重用的组件,它只处理非常有限的一组动作,并结合其他小的中间件来创建更复杂的应用程序。例如,相同的CoreLocation
中间件可以从iOS应用、它的扩展、Apple Watch扩展或不同的应用中使用,只要它们共享一些子动作树和子状态结构。
一些中间件的建议
- 定时器运行,定期从外部资源中抽取一些资源或更新一些本地状态
- 订阅
CoreData
、Realm
、Firebase Realtime Database
或类似的数据库变更 - 作为
CoreLocation
代理检查显著的位置更改或信标范围,并触发操作以更新状态 - 作为
HealthKit
代理跟踪活动,或者甚至将之与CoreLocation
观察结合起来以跟踪活动路线 - 记录器、遥测、监控、分析跟踪器、崩溃报告追踪
- 监控或调试工具,如用于从另一台设备远程监控状态和动作的外部应用程序
WatchConnectivity
同步,保持iOS和watchOS状态同步- API调用和其他“冷观察器”
- 网络可达性
- 应用内导航(Redux Coordinator模式)
CoreBluetooth
中心或外围设备管理器CoreNFC
管理器和代理NotificationCenter
和其他代理- WebSocket、TCP套接字、多播和其他连接协议
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
对象,这是一个闭包,在其中执行副作用并可以分派新动作。
在某些情况下,我们可能想在reducer之后不执行任何副作用或运行任何代码,在这种情况下,该函数可以返回简单的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
这是一个致力于简洁同时具有强大功能的中间件实现。对于每个传入的操作,您必须返回一个Effect,它只是对反应性Observable、Publisher或SignalProducer的包装,具体取决于您喜欢的反应性库。唯一条件是您的反应流的输出(元素)必须是DispatchedAction,错误必须是Never。DispatchedAction是一个具有操作本身和派发器(操作源)的结构体,所以它对操作是泛型的,匹配EffectMiddleware的OutputAction。错误必须是Never,因为中间件预计要解决所有副作用,包括错误。因此,如果您想处理错误,您可以在中间件中处理它,如果您想在用户那里警告错误,则可以在您的反应流中捕获错误并将其转换为如.somethingWentWrong(messageToTheUser: String)
的操作,然后将其派发并将其后来还原到AppState。
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
是由单例容器包装的纯函数,它接受一个动作和当前状态来计算新状态。
MiddlewareProtocol管线可以做的事情有两件:分发外出动作和处理传入动作。但是,它们不能做的事情是更改应用状态。中间件只有只读访问权限,访问我们的应用最新状态,但在需要修改时,我们使用MutableReduceFunction
函数。
(ActionType, inout StateType) -> Void
它与旧的ReduceFunction
具有相同的语义(但性能更好)。
(ActionType, StateType) -> StateType
给定一个动作和当前状态(作为可变的inout),它计算新状态并更改它。
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结构体添加属性或调用任何外部函数。如果您这样做了,请创建一个中间件,并通过它分发带有日期或 locales的动作。
Reducer还负责维护状态的连贯性,因此在进行状态更改之前总是进行最终健全性检查是个好主意,例如检查必须一起更改的依赖属性。
reducer函数执行后,存储将更新其单个真相来源,并将新计算的状态传播到所有订阅者,他们将对新状态做出反应并更新视图,例如。
该函数封装在结构体中以克服Swift的一些限制,例如,允许我们将多个reducer组合成一个(单义操作,其中两个或多个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
提供,不要使用来自全局作用域的其他任何变量,甚至用于读取目的。如果您需要其他内容,它们应该位于状态中或包含在操作负载中。 - 不要开始副作用、请求、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
协议,所以从所有目的来看,它就像一个真实的存储一样行为,但实际上它只使用自定义类型(状态和动作)投影实际的存储,即要么是你的模型的一个子集(例如状态树中的一个分支),要么是一个完全不同的实体,如视图状态。一个StoreProjection
有2个闭包,允许它在全局动作和视图使用的动作之间转换动作和状态。这样,视图就不会与全局模型耦合,而只是与其中很小的一部分耦合,而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 读取偏好设置。
尽管这些活动组合在一起以创建完整的体验,但它们可以彼此隔离,以避免在相同的位置使用 URLSession 逻辑和 CLLocation 逻辑,争夺相同的资源,并可能导致竞态条件。还,隔离测试这些部分通常更容易,并导致更具意义的测试。
理想情况下,我们应该组织我们的AppState
和AppAction
,将这些部分视为独立的树。在上面的示例中,我们可以在AppState中拥有3个不同的属性,并在AppAction中拥有3个不同的枚举情况来对与天气API相关的状态和动作、用户位置以及UserDefaults访问相关的状态和动作进行分组。
在将我们的应用程序拆分为3种类型的Reducer
和3种类型的MiddlewareProtocol
的情况下,这更加有用,而这3种类型的工作范围是针对我们在模型中分组的3个路径,而不是针对完整的AppState
和AppAction
。第一对Reducer
和MiddlewareProtocol
将用于WeatherState
和WeatherAction
,第二对将对LocationState
和LocationAction
,第三对将对RepositoryState
和RepositoryAction
。它们甚至可以存在于不同的框架中,编译器将禁止我们将天气API代码与CLLocation代码链接起来,这是一个很好的做法,因为它强制执行更好的实践并解锁代码重用性。也许我们的CLLocation中间件/Reducer可以用在检查公共交通线路的完全不同的应用程序中。
但是,在某个时候,我们希望将这些3种不同的实体放在一起,而我们的应用程序的StoreType
“说”的是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中的枚举没有与结构体相同的关键路径,我们强烈建议您阅读《操作枚举属性》文档,并为每个情况手动或使用代码生成器实现属性,这样您就可以避免编写大量易出错的switch/case。我们还提供了一些模板来帮助您。
让我们探讨如何提升Reducer和Middleware。
提升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中的两种类型插入到3个闭包标题中。
- 对于第1类型,找到一个将AppAction解析为匹配类型的棱镜。务必运行Sourcery并确保所有枚举情况都被棱镜覆盖
- 对于第2类型在stateGetter闭包中,找到可以将AppState解析为匹配类型的透镜(属性获取器)。
- 对于第2类型在stateSetter闭包中,找到可以将全局状态接收到的值转换为newValue的透镜(属性设置器)。确保一切都是可写的。
使用键路径提升reducer
.lift(
action: \AppAction.prism1?.prism2?.prism3,
state: \AppState.property1.property2
)
步骤
- 从上面的闭包示例开始
- 对于动作,我们可以使用来自
\AppAction
的键路径遍历棱镜树 - 对于状态,我们可以使用来自
\AppState
的可写键路径遍历属性,只要所有属性都声明为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,在这个例子中,我们将“it”包装在 .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)
步骤
- 从上面的闭包示例开始
- 对于 inputAction,我们可以使用来自
\AppAction
的键路径遍历棱镜树 - 对于 outputAction,它不是键路径,而是一种包装。因为我们不能一次包装超过一层,所以我们
- 为此使用闭包版本
- 逐级提升,从内到外,在这种情况下,遵循将本地包装到 Prism2(case .prism3)的步骤,然后将结果包装到 Prism1(case .prism2),最后将结果包装到 AppAction(case .prism1)。
- 当它只有一层时,无需担心
- 对于状态,我们可以使用来自
\AppState
的键路径遍历属性。
可选转换
如果某个操作正在通过商店运行,一些reducer和中间件可能会选择忽略它。例如,如果动作树与该中间件或reducer无关。这就是为什么,每个进入的动作(中间件的动作输入和简单的Reducer动作)都是从AppAction → Optional<子集>
的转换。返回nil表示将忽略该动作。
对于动作由Middlewares分发的反向情况,动作必须成为AppAction,我们不能忽略中间件的意见。
箭头方向
Reducer 接收动作(输入动作)并能够读取和写入状态。
Middleware 接收动作(输入动作),分派动作(输出动作)并且只读取状态(输入状态)。
响应当中,我们必须考虑到这一点,因为它定义了转换的可变性(协变/逆变),即 map 或 contramap。
一个特殊情况是reducer的状态,因为这需要一个读取和写入权限,换句话说,你被给予一个 inout Whole
和一个 Part
的新值,你使用这个新值来设置inout Whole中的正确路径。这正是WritableKeyPaths的意义,我们将在下面详细介绍。
KeyPaths的使用
KeyPath与Global -> Part
转换相同,其中您按照以下方式描述树:\Global.parent.part
。
WritableKeyPath具有类似的用法语法,但功能更强大,允许我们进行(Global, Part) -> Global
或(inout Global, Part) -> Void
的转换,意义相同。
也就是说,我们需要了解,只有在箭头方向来自AppElement -> ReducerOrMiddlewareElement
时,KeyPaths才是可能的,也就是说
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<ReducerOrMiddlewareAction>
的KeyPaths。
{ (globalAction: AppAction) -> ReducerOrMiddlewareAction? in
globalAction.parent?.reducerOrMiddlewareAction
}
// or
// KeyPath<AppAction, ReducerOrMiddlewareAction?>
\AppAction.parent?.reducerOrMiddlewareAction
对于ReducerState ←→ AppState
和MiddlewareState ← AppState
转换,我们可以使用类似的语法,尽管Reducer是inout(WritableKeyPath)。这意味着我们的整个树必须由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 \
然而,简短的形式不能一次性遍历两级
{ (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.
身份,忽略和荒谬
空
- 当中间件不需要状态时,它可以变成空
- 使用
ignore
提升空,它是一个{ (_: 任何) -> Void in }
永不
- 当中间件不需要分发操作时,它可以变为永不
- 使用
absurd
提升永不,它是一个{ (never: Never) -> 任何 in }
身份
- 当某些部分在你的提升中保持不变,因为它们已经处于期望的类型中
- 使用
identity
提升它,它是一个{ $0 }
背后的理论:空的和永不会互为对偶
- 任何可以变成空(终端对象)
- 永不(初始对象)可以变成任何
- 空有 1 个实例可能(它是单例)
- 永不没有实例可能
- 因为你不能得到永不,所以你可以将任何作为挑战来承诺。这就是为什么函数被称为荒谬的,它是无法调用的。
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 的实现,它与 Apple 的 MVC 有很大不同,因为它提供了对层责任的严格和严格描述,并通过更好地定义其实现方式来强制模型层增长:在这种情况下,模型是 Store。你的控制器所必需做的就是将视图操作转发到 Store 并订阅状态变化,在需要时更新视图。如果这个流程听起来不像 MVC,让我们看看苹果网站上的图片
一个重要的区别是关于用户操作:在 SwiftRex 中,它由控制器转发并达到 Store,因此更新状态的责任现在成为 Store 的责任。其余部分基本相同,但有一个更好的模型操作定义。
╼━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╾
╱░░░░░░░░░░░░░░░░░◉░░░░░░░░░░░░░░░░░░╲
╱░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░╲
┃░░░░░░░░░░░░░◉░░◖■■■■■■■◗░░░░░░░░░░░░░░░░░┃
┃░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░┃
╭┃░╭━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╮░┃
│┃░┃ ┌──────────┐ ┃░┃
╰┃░┃ │ 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 视为一个非常重的“模型”层,它与视图(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 在解决菱形依赖问题上的限制。
如果您只是在单个目标中使用它,自动模式应该没问题。
Combine,自动连接模式
// 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 产品添加“Dynamic”)
// 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")])
]
)
然后您可以在终端上构建,或使用 11 或更高版本的 Xcode,它现在原生支持 SPM。
$ swift build
$ xed .
重要:对于 Xcode 12,请使用版本 0.8.8。0.9.0 和更高版本需要 Xcode 13。
Carthage
由于缺乏兴趣和维护成本高,Carthage 已不再受到支持。
如果您认为这非常重要,请通过 Github 问题单告知我们,我们会评估是否有可能将其恢复。在此之前,您可以检查最后一个与 Carthage 兼容的版本,该版本为 0.7.1,并最终将该版本作为目标,直到我们找到更好的解决方案。