在模块化架构中,共享可变状态(Shared Mutable State) 是导致“非确定性 Bug”和运行时崩溃的头号杀手。当多个模块(Target)同时持有一个类实例并尝试修改它时,追踪数据的流向会变得极其困难。
利用 不可变性(Immutability) 优化模块化架构,核心思路是将“修改”的行为从“原地更新”转变为“产生新版本”。
以下是具体的实践策略:
1. 跨模块通信:从“引用”转向“值”
在模块边界(API 接口)处,严禁传递 class 实例,优先传递由 let 定义的 struct。
-
为什么要这样做?
class是引用传递,模块 A 修改了对象,模块 B 会在毫无察觉的情况下看到变化。而struct是值传递,模块间传递的是独立副本。 -
防御效果:
由于
struct结合let保证了数据的完整性,模块 A 永远无法通过修改数据来破坏模块 B 的内部逻辑,从而消除了因跨模块状态污染导致的崩溃。
2. 状态管理:使用“快照(Snapshot)”模式
在复杂架构中,与其让多个模块共享一个大的 StateCenter,不如让 StateCenter 发布不可变的快照。
-
实践方案:
使用 Combine 或 Swift AsyncSequence 发布数据流。每次状态改变时,发布一个新的、完全不可变的
struct实例。 -
优势:
订阅模块拿到的是那一时刻状态的“切片”。即使主状态随后发生了变化,该模块手中的副本依然是稳定的,这在处理异步 UI 更新或后台计算时能有效避免“数组越界”或“野指针”问题。
3. 配置与依赖:强制不可变注入
在模块初始化时,通过 init 注入所需的配置(Configuration),并将其声明为 let。
Swift
public struct ModuleConfig {
public let apiBaseURL: URL
public let themeColor: UIColor
// 一旦初始化,该模块的生存周期内配置不可更改
}
public final class FeatureModule {
private let config: ModuleConfig
public init(config: ModuleConfig) {
self.config = config
}
}
- 防御点:这种方式避免了“全局配置变量”被某个子模块在运行时意外修改,导致其他所有模块行为异常的风险。
4. 消除并发风险:Sendable 协议与不可变性
在 Swift 并发模型(Swift Concurrency)中,不可变性是安全性的基石。
- 实践:将跨模块传输的模型标记为
Sendable。 - 原理:如果一个
struct所有的属性都是let且符合Sendable,那么编译器就会允许它在不同线程/Task 之间自由流动,而不需要加锁。 - 结果:从根本上消除了 Data Race(数据竞争) ,这是多线程环境下崩溃最常见的原因。
5. 架构层面的权衡:局部可变 vs 全局不可变
为了性能和开发的便利,我们不需要追求“绝对不可变”,而是遵循 “内部可变,边界不可变” :
| 维度 | 建议 | 目的 |
|---|---|---|
| 模块内部 | 可以使用 var 或 actor 处理局部逻辑 | 保证性能和开发效率 |
| 模块接口 (Public API) | 必须使用 let 和 struct | 保护契约,防止外部干扰 |
| 全局服务 | 使用 Actor 封装可变状态 | 通过“隔离”确保并发安全 |
总结
在模块化架构中,不可变性是一种“契约” 。通过大量使用 let 和 struct,你实际上是在模块之间建立了一道道防火墙。
“如果数据不能变,它就不可能变坏。” 这种设计能让你的架构从“试图通过复杂的锁和同步来修复 Bug”转变为“从结构上就不可能产生这种 Bug”。