这个 ComposableEffectIdentifier
是一个小型辅助库,用于 Composable Architecture (TCA)。它允许在定义 Effect
标识符时改善用户体验。
它为此目的提供了两个工具:一个 @EffectID
属性包装器,以及一个 namespace()
高阶 reducer,它允许多个类似的 store 实例在同一进程中运行,而无需微观管理正在进行的 Effect
标识符。
当在 TCA 中使用长期存在的 effect 时,我们需要提供一些可哈希的值来跨同一 reducer 的多次运行来标识它们。如果我们启动一个 timer
effect,我们需要为该 effect 提供一个标识符,以便在不再需要它时检索并取消该 effect。
任何 Hashable
值都可以用作 effect 标识符。TCA 的作者建议利用 Swift 类型系统,通过定义临时的、无属性的 Hashable
结构体。这种结构体的任何值都等于自身,并且碰撞风险有限,因为这些类型是局部定义的。
例如,在某个 Reducer
的代码块内,可以定义
struct TimerID: Hashable {}
然后我们可以使用此类型的任何值作为 effect 标识符
switch action {
case .start:
return Effect.timer(id: TimerId(), every: 1, on: environment.mainQueue)
.map { _ in .tick }
case .stop:
return .cancel(id: TimerID())
case .tick:
state.count += 1
return .none
}
这工作得很好。但是,当我们仅仅需要一个 Hashable
值时,调用 TimerID()
并创建一个完整的类型感觉有点笨拙。
@EffectID
属性包装器允许以绝对清晰的意图定义标识符
@EffectID var timerID
访问此值会返回一个唯一且稳定的 Hashable
值,可用于标识 effect
switch action {
case .start:
return Effect.timer(id: timerID, every: 1, on: environment.mainQueue)
.map { _ in .tick }
case .stop:
return .cancel(id: timerID)
case .tick:
state.count += 1
return .none
}
为了在某些 reducer 中局部定义,需要 Swift >=5.4(更准确地说,需要 Swift >=5.5,因为 Swift 5.4 中存在无值局部属性包装器的错误)。
通过为属性分配一些 Hashable
值,您可以利用附加数据来增强生成的标识符
@EffectID var timerID = state.identifier
请注意,如果 @EffectID
在不同的位置定义,即使共享相同的用户定义值,它们也不会相等
@EffectID var id1 = "A"
@EffectID var id2 = "A"
// => id1 != id2
当使用命名空间时,甚至可以避免大多数时候使用用户定义的值。
在其当前的实现中,核心 TCA 库在某些配置中使用起来可能不太方便,尤其是在开发基于文档的应用程序时。在这种应用程序中,每个文档都由一个根 Store
表示。每个文档应该不知道在同一时间打开的其他文档的存在。在这种应用程序中,同一类型的根 Store
的多个实例可能在同一进程中同时运行。当在 reducers 中使用像 Hashable
结构体这样的局部标识符来标识 effect 时,可能会创建冲突,其中一个 store 实例可能会取消来自另一个 store 的 effect(因为正在进行的 effect 在内部存储在一个通用的、顶级的字典中)。
一种解决方案是在 State
或 Environment
中传播一些文档特定的标识符,但这将需要将此标识符附加到每个 effect 标识符,以便正常工作。此外,在每个不相关的功能中“泄漏”这样的标识符会阻碍功能隔离和可重用性(TCA 的核心原则)。
同样的精神,组合具有正在进行的 effect 的功能集合也很麻烦,因为我们需要注入一些元素特定的标识符来辨别来自不同行的相似 effect。通常,行功能相对简单,因此行标识符的渗透不太明显,但它仍然存在,而行功能最终应该在某些与列表无关的上下文中工作。
幸运的是,ComposableEffectIdentifier
附带一个功能,可以极大地帮助解决此类问题。它与 @EffectID
属性包装器结合使用,并需要使用它来声明 effect 标识符。任何 Reducer
都可以用某个 Hashable
值进行命名空间化。此值用于使用上下文数据来增强 @EffectID
标识符(您可以将其视为用户提供的值,但来自“顶部”)。命名空间沿着 Reducer
的树向下传播,并与更深层的命名空间组合。
您可以使用 .namespace<ID>(_ id: ID)
高阶 reducer 来命名空间化 reducer,这不会更改源 Reducer
的泛型签名。id
可以直接提供,也可以作为来自 State
或 Environment
的函数或 KeyPath
提供。在程序的所有执行过程中,id
值对于分支应该是恒定的。对于基于文档的应用程序,您很可能使用一些稳定值来命名空间化根 reducer,该值唯一标识文档。
提供了 forEach
pullback 的两个半重载。它们都命名为 forEachNamespace
,但否则它们与它们的 forEach
对应项共享相同的参数。这些 reducer 的工作方式类似于 forEach
,但它们也使用元素的标识符(或字典键)来命名空间化它们的局部 reducer,从而隔离每个 pullback reducer 的 effect。因此,这些局部 reducer 可以使用 @EffectID
属性包装器独立地定义它们的 effect 标识符,而无需携带外部标识符。
TCA 已经附带了一个 Identified
包装器,可以将任何值包装成一个 Identifiable
值。@EffectID
的使用导致功能变得越来越独立于外部标识符。因此,将无标识符功能的 State
与 Identified
包装器包装起来可能很方便,例如将其包含到 IdentifiedArrayOf<Identified<State, ID>>
中。由于使功能可标识化可能是该过程的唯一结果,因此还提供了 forEachNamespace
的重载,以在一个调用中直接 pullback、命名空间化和标识无标识符的 reducer。当 GlobalState
是 IdentifiedArrayOf<Identified<LocalState, ID>>
,并且无标识符的 reducer 在 LocalState
上工作时,此重载可用。
为了演示命名空间化的 reducers 和 @EffectID
属性包装器的强大功能,该库附带了一个示例应用程序,该应用程序展示了一个巧妙的技巧:实现了一个 LonelyTimer
TCA 功能。此功能处理一个向下计数到零的计时器,具有一些启动/停止功能。LonelyTimer
功能不知道外部世界。它处理它的计数,仅此而已。它本身没有标识符。它有一个名称,但只是出于礼貌。
围绕这个 LonelyTimer
功能,构建了一个名为 ManyTimers
的应用程序。此应用程序处理任意数量的计时器,但无需触及 LonelyTimer
的代码。ManyTimers
应用程序还提供 4 种风格:一个 shoebox 应用程序,其中十几个计时器同时托管在一个列表中,适用于 iOS 和 macOS,以及一个基于文档的应用程序,其中每个文件处理一个计时器,再次适用于 iOS 和 macOS。基于文档的应用程序可以同时打开多个文件,这在某种程度上类似于将它们并排托管在列表中。
使用这两个应用程序,可以同时运行和交互多个计时器,每个计时器都是隔离的。只定义了一个 effect 标识符,在 LonelyTimer
级别。
ComposableEffectIdentifier
的 API 的最新文档可在此处 获取。
添加
.package(url: "https://github.com/tgrapperon/composable-effect-identifier", from: "0.0.1")
到您的 Package.swift
中的 Package 依赖项,然后
.product(name: "ComposableEffectIdentifier", package: "composable-effect-identifier")
到您的目标的依赖项。
作者 (@tgrapperon) 特别感谢 @iampatbrown,他提供了最初的反馈,从而塑造了这个库,当然还要感谢 @mbrandonw 和 @stephencelis,感谢他们为 TCA 和他们其他令人惊叹的开源项目所做的出色工作。
此库在 MIT 许可证下发布。有关详细信息,请参阅 LICENSE。