关于 iOS 中状态管理的完整指南

软件开发存在许多挑战,但有一种野兽往往比其他野兽更频繁地把事情搞砸:应用程序的状态管理和数据传播问题。那么,状态会出什么问题,尽管它只是用于读写的数据?

原文 The Complete Guide to the State Management in iOS - Alexey Naumov (nalexn.github.io)

本指南深入探讨了基于 UIKit 的生产应用程序的状态管理设计。SwiftUI 在这方面改变了游戏规则;然而,即使对于新的苹果UI框架,本文中的大多数概念和陈述仍然适用。 这些精彩文章很好地涵盖了基于 SwiftUI 的应用程序的状态管理。


软件开发存在许多挑战,但有一种野兽往往比其他野兽更频繁地把事情搞砸:应用程序的状态管理和数据传播问题。

那么,状态会出什么问题,尽管它只是用于读写的数据?

使用状态时的常见问题

  1. 竞争:在并发环境中对数据的非同步访问。这会导致难以找到的错误,例如意外或不正确的计算结果、数据损坏甚至应用程序崩溃。
  2. 意想不到的副作用:当程序中的多个实体通过参考同一个状态值来共享状态时,其中一个实体发起的状态突变可能会出人意料。这通常是设计不佳和不受限制的数据可访问的结果。后果从用户界面的故障到死锁和崩溃不等。
  3. 值的一致性:当程序中的多个实体通过存储自己的状态副本来共享状态时,状态本地副本的突变不会自动影响其他副本。这需要编写额外的代码来在任一副本更新时续订值。如果不正确,会使状态副本不同步,这通常会导致向用户显示错误的数据,然后当用户或系统本身与过时的数据交互时,应用程序的状态会损坏。
  4. 类型的连贯性:在动态类型语言中,变量在其生命周期内不仅改变值,而且改变类型。尽管这种技术有实际应用,但总的来说,它被认为是一种糟糕的做法。它使算法更难遵循和理解,并增加了维护此类代码时出现人为错误的机会。即使是经验丰富的程序员也有可能通过错误分配错误的变量来无意中更改类型。这种错误的结果取决于语言,但可以看出不会发生任何好事。
  5. 错误的值:对错误地取代了具有相同原始类型的另一个参数的值。例如,如果我们将UserID和BlogID都表示为String类型,则可能会意外地将UserID传递给期望BlogID的函数。无论哪种方式,服务器调用都可能使用错误的值,或存储在本地应用程序状态中-这是一个错误的情况。解决这个问题的解决方案可能是对原始值使用结构包装器,这允许编译器区分类型并警告类型不匹配。
  6. 内存泄漏:就像程序中的任何其他资源一样,如果处理不当,该状态即使在不再打算使用后也可以保留在内存中。泄漏大块内存(例如,分配给图像的数百兆字节)最终可能导致严重的自由内存赤字,随后崩溃。当状态泄漏时,我们可能最多会损失几KB内存,但谁知道我们的程序会泄漏多少次?后果是性能较慢和崩溃。
  7. 可测试性有限:国家在单元测试中发挥着至关重要的作用。按值或参考耦合编程实体共享状态,使其算法相互依赖。程序中状态管理的不恰当设计可能会降低测试的有效性,甚至无法为设计不当的模块编写。

当引入新的状态时,开发人员总是会遇到两个问题:“在哪里存储状态数据?”和“如何将状态更新通知应用程序中的其他实体?”让我们详细介绍每一个,看看这个问题是否有灵丹妙药。

1. 在哪里存储状态数据?

我们可以在方法中有一个局部变量,或者在类中有一个实例变量,或者一个从任何地方都可以访问的全局变量——它们之间的主要区别在于可以读取或写入它的程序的范围。

在决定在哪里存储新变量时,我们需要考虑该状态的主要特征——其位置或可访问性范围。

一般经验法则是始终使用尽可能小的作用域。方法中定义的局部变量优于全局变量,这不仅是为了避免我们乍没有注意到的其他范围的意外数据突变,也是为了提高使用该数据的模块的可测试性。

本地屏幕的状态不与其他屏幕共享,可以安全地存储在屏幕模块本身中。这仅取决于您用于屏幕模块的架构。例如,在Model-View-Controller的情况下,这是ViewController,对于Model-View-ViewModel来说,它将是Model。

当我们有要由多个模块传输或共享的数据时,事情会变得更加复杂。主要有两种情况:

  • 我们需要将某个状态传递到后续屏幕,并在它执行时收到回复,并可能获得一些数据。

  • 我们有一个由整个应用程序共享的状态。这可能是任何可以由多个屏幕显示的数据,这些屏幕不以嵌套形式出现,就像上一点中所提及的。数据可以读取或更新,各方都应该优雅地处理更改。

在本文中,我涵盖了这两种情况,从第一种情况开始是合乎逻辑的。为了不让您困惑,第二种情况在下面的共享状态管理部分中被讨论,只需继续阅读即可。

好的,我们从后续两个屏幕(父子)之间的交互开始。为了实现模块之间的松散耦合,我们需要通过披露传递或接收数据的各方的不必要的细节,确保我们设计的数据传输不会引入不必要的依赖性。他们对彼此了解得越少越好。

对于传递数据,我们有一个几乎标准的技术——对值本身的依赖注入持有拥有该值的实体的引用

将数据反向传递给调用方有点棘手,我们可以自然过渡到回答有关状态的第二个问题:

2. 如何将状态更新通知应用程序中的其他实体?

在我之前的文章中,我已经涵盖了这个问题的可能答案,仅在我们拥有的标准工具中:

  • delegate
  • NotificationCenter
  • KVO
  • Closure
  • Target-Action
  • Responder Chain

开发人员可以通过无穷无尽的方式单独或相互使用这些技术,更不用说在选择函数和参数命名时的完全自由了。

如果我们也在项目中引入Promise、Event或Stream of values,这种选择可能会让人大吃一惊(以及他们的程序)。

那么,使用哪种方法来传播状态变化?

在过去几年里,我在选择数据传播方法时形成了以下规则(您可能有不同的偏好):

  • delegate。尽管这项技术在 iOS 社区中保持着流行,但我支持这样的观点,即闭包通常更灵活、更方便地取代delegate。它们具有相同的目的,但使用闭包会导致编写更少的样板代码,同时具有更高的内聚力。

  • Target-Action。与 delegate 的评论大致相同。我仍然使用它的唯一原因是当我子类化 UIControl 或 UIGestureRecognizer 时,因为 Target-Action 自然受它们的支持。

  • Closure,即闭包。这是我对两个模块之间互动的最简单情况的选择。只要有任何复杂功能,例如后续的异步任务并带有回调函数,或者当我需要通知多个模块时,我就会开始查看Promise、Event或Stream。

  • Promise 是我最喜欢的处理一系列异步任务的工具,例如后续的后端API调用。Stream of values 也可以处理这个问题,但 Promise 为工程团队提供了不那么复杂的API,适合出于任何原因避免 Rx 和其他反应式工具。

  • Event 是观察者模式的轻量级而强大的实现,是 NotificationCenter 和 KVO 的绝佳替代品。每当您需要预测有或没有数据的通知时,此工具都会提供方便的订阅点,该订阅点可以安全使用且易于测试。可以用作可观察属性——一个总是带有值的变量,该值还为任何数量的订阅者提供“KVO”风格的更改观察。

  • Stream of values。这是一个通用工具,可以取代 Promise 和 Event ,同时提供UI绑定和其他特权。不过要小心!我自己也是函数反应式编程的忠实粉丝,但仍然没有太多人完全理解它的概念,并在实践中正确使用这个工具。在接受一家大型零售商商店采访时,他们的团队经理秘密地与我分享了他的担忧,即他们不得不雇用专门的高级工程师,只是因为他们的应用程序完全是用 RxSwift 编写的,并且无法得到技能水平较低的工程师的支持(他们试图雇用他们)。这令人震惊的是,一个旨在在实践中简化开发的工具是如何将事情变成相反的!在另一次采访中,这次是在俄罗斯一家排名前三的银行,他们表示,在所有10多个产品团队中,出于同样的原因,他们被严格禁止使用反应式编程。

  • NotificationCenter。在我的项目中,我正在使用自定义 SwiftLint 规则来防止将 NotificationCenter 用于自定义需求。我相信这是您可以在应用程序中用于数据分发的最有害的工具之一。它不仅依赖于使用单例(这使得单元测试更难),而且还引入了虫洞,这使数据以不受控制的方式流动。这是编码地狱的先兆头!我唯一仍在使用它们的情况是观察来自苹果框架的通知,其中它们没有提供替代API。如果您需要观察者模式的不间断的实现,请考虑使用 Event 或 Stream of values。

  • KVO。当我必须监听一个封闭的类,并且没有其他方法来观察其状态的变化时,我使用一种非常强大的技术作为最后手段。我们都应该感谢KVO——没有),我们就不会有反应式的 UIKit 扩展(Stream of values)。当您需要可观察的属性时,Event 是一个更实用的替代方案。

  • Responder Chain。由于它不能在通知中携带数据,因此对于我们的目的,这种技术几乎毫无用处。然而,假设我们持有对状态的引用,只需要一个UI刷新的触发器,这种技术仍然是一个糟糕的选择。它构建了一个隐式且非常脆弱的通知通道,很难测试和维护。

共享状态管理

好的,我们有一些数据需要通过应用程序中的多个模块访问。我们可以把它做成全局变量还是单例,并整天调用它吗?是的,如果我们参加几个小时的黑客马拉松,并计划在项目结束后扔掉……

今天的移动应用程序必须处理日益复杂的业务问题,这些问题不仅涉及与底层数据处理密切相关的功能丰富和响应迅速的用户界面,还涉及从服务器接收或本地创建、缓存在 RAM 或数据库中的数据的复杂状态管理。

每个项目都必须确定存储数据及其在系统中传播的统一策略;如果不及早这样做,不可避免地会导致对数据流失去控制、数据一致性以及对应用程序如何运作的基本了解。

考虑到我上面列出的状态的常见问题,让我就如何在应用程序中组织共享状态提出一些建议。

真相的单一来源

您必须选择是将状态缓存在多个地方还是存储在一个地方。在我看来,遵守单一真相来源原则的状态更实用,因为一旦您更改状态,您就无需担心过时的数据,旧值就消失了,不能从其他地方弹出并破坏用户的体验。

想象一下,我们正在开发一个简单的TODO列表应用程序。我们将列表存储在本地数据库中,也存储在后端。当应用程序启动时,我们会显示本地副本,同时向后端运行请求,以获取可能从其他设备更新的列表。

如果我们不太关心用户体验,我们可以冻结用户界面,直到请求完成,但我们继续允许用户检查列表中的任务,并以其他方式对其进行修改。

用户编辑本地副本和延迟后完成的网络请求之间的竞争条件将迫使我们在发生冲突时实现合并文档编辑的逻辑。

为此,我们可以引入本地数据库包装器,并在提供最新任务列表时将其作为单一的真相来源。通过这种方式,我们可以避免程序不必要的复杂性。

一旦您统一了存储状态的地方,随着项目的发展,扩展或重构会变得容易得多:如果稍后我们决定需要为任务添加一个单独的编辑屏幕,那么在该屏幕上,我们可以安全地从该包装中读取,并确保它始终提供最新数据,无论何时更新谁。

限制性状态突变

您需要为外部代码设置一个屏障,以便无法更改值并逃跑。如果还有其他方需要知道价值发生了变化怎么办?通知其他人的责任不应在于调用者一方,因为很容易忘记在数据突变的每个地方添加所需的代码。

另一方面,如果我们为突变操作引入包装器,那么我们可以从该包装器发送通知,并根据需要对数据执行其他操作。

在我们的 TODO 列表示例中,该包装器可以是一个外墙,既隐藏对本地数据库的访问,也隐藏后端调用,给客户端代码留下一个整洁的API,使得从这些 API 中获取不到数据的来源,并为数据突变提供一个简单的关口。

单向数据流

这是您可以在应用程序中实施的另一个限制,它将大大提高整个系统的清晰度和稳定性。

假设我们没有将后端API调用放在外墙后面,而是直接从主 ViewController 发起网络调用。

在这种情况下,我们将有两个数据来源——第一个仍然正常运行,另一个是请求完成回调,我们必须独立更新这两种情况的UI。

过去,我一直在开发应用程序,通常的做法是通过 NotificationCenter 为每个数据突变情况发送通知。有一个关于单个记录更新的通知和另一个关于整个列表更新的通知。当然,网络回调还提供了单个记录的列表——用户界面必须处理来自4个来源的数据!你能想象为这种结构编写测试吗?

在实际应用程序中,数据流可以快速成倍增加,这需要开发人员持续努力更新现有功能,并且随着应用程序的演变,此类场所的数量可能会呈指数增长。

实现单向数据流的方法允许我们构建一个统一的通道来处理一次数据更新,并在我们继续开发时几乎忘记了它的存在。

所有这些都极大地促进了 principle of least astonishment,使项目中的每个人都更容易快速定位状态、突变时的所有可能条件以及数据分发的通道。

有很多方法可以同时遵守所有三种模式。一个例子是 Redux 库,最初是为 JavaScript 世界创建的,后来启发了iOS社区构建自己的:ReSwift。

当您使用这三个概念设计共享状态管理并决定在项目中使用 Stream of Values 时,您可以轻松利用将 UI 与状态绑定,使整个应用程序对任何状态更改都超级响应,并具有明确的UI更新声明代码。

获取共享状态的引用

我不会建议为该状态创建全局接入点(单例对象或全局变量),无论是 ReSwift 的状态还是其他任何内容。上面所提及的依赖注入是确保使用共享状态的模块的最佳解耦和隔离的更实用的方法。为此,您可以使用 typhoon 等 DI 库,或通过直接从创建新模块的实体指派引用来注入实例。对于后者,这可以是 AppDelegate 为 RootViewController 分配依赖项,也可以是某种 Builder 或 Router,可以创建新的 ViewController 并立即注入依赖项。


“Everything should be made as simple as possible, but no simpler.” - Albert Einstein

一方面,我们都需要以我们编写的代码的简单性为目标,但有一些工程师不应该采取的快捷方式,其中之一是忽视了他们应用程序中牢固的状态管理的设计。

Licensed under CC BY-NC-SA 4.0
comments powered by Disqus
Built with Hugo
主题 StackJimmy 设计