SwiftUI Environment:理念与实践

发表于

SwiftUI 的 Environment 是一个优雅且功能强大的依赖注入机制,几乎每个 SwiftUI 开发者都会在日常开发中接触和应用。这一机制不仅简化了视图间的数据传递,也为应用架构设计提供了更多的可能性。本文将暂且搁置具体的实现细节,转而聚焦于 Environment 在架构中的角色与边界,探讨那些常被忽视却至关重要的设计理念与实践经验。

专属于视图的依赖注入机制

依赖注入(Dependency Injection,简称 DI)是现代软件工程中实现组件与其依赖解耦的重要技术。其核心思想包括:

  • 控制反转:组件不再主动创建依赖,而是被动地从外部接收。
  • 关注点分离:对象的创建逻辑与使用逻辑清晰分离。

SwiftUI 的 EnvironmentValue(以及 EnvironmentObject)作为原生的 DI 机制,有一个明显特征:Environment 只能在视图加载后获取。这一特性既体现了 SwiftUI 作为声明式 UI 框架的基本理念,也引起了部分开发者的顾虑——即无法直接用于视图层次结构之外。

这种设计并非偶然。SwiftUI 通过限制依赖注入的时机,确保视图构建和更新与其依赖的数据和环境保持一致性与可预测性。

不仅用于值类型

EnvironmentValue 的应用范围远超出值类型的边界。与其他成熟的依赖注入方案一样,它能够灵活地持有并注入引用类型、函数、工厂方法、协议约束等多种形式的依赖。

尽管 SwiftUI 从诞生之初就为 ObservableObject 提供了专门的 EnvironmentObject 机制,但这实际上是出于实现细节的考量。在 Observation 框架出现之前,SwiftUI 视图只能通过订阅 ObservableObject 实例的 Publisher 来响应状态变化,因此需要一个专门的 API 来处理这种特殊情况。随着 Observation 框架的推出,Observable 实例现在已经能够与 EnvironmentValue 无缝协作,并提供了更精细的、基于属性级别的响应机制。

事实上,SwiftUI 的官方 EnvironmentValue 默认实现中,始终包含了多种引用类型和函数类型的案例:

  • managedObjectContext:提供 NSManagedObjectContext 实例,用于 Core Data 的数据访问
  • dismiss:对应 DismissActioncallAsFunction 方法,用于以编程方式关闭视图
  • editMode:提供读写 EditMode 的 Binding,用于控制列表的编辑状态

因此,开发者不应将 Environment 的应用局限于值类型。SwiftUI Environment 是一个通用的依赖容器,能够承载任何符合依赖注入核心理念的外部信息,无论其类型如何。充分利用这一特性,可以构建更加灵活和模块化的 SwiftUI 应用。

Observation 与默认值

开发者对 EnvironmentObject 常有顾虑,其中一个关键原因是:一旦忘记注入依赖,应用就会立即崩溃。相比之下,EnvironmentValue 的设计更为安全可靠,因为它要求开发者必须为每个环境值提供默认值。这一特性在结合 Observation 框架使用时尤为重要。

随着 Observation 框架的出现,我们有了更优雅的方式来注入可观察对象:

Swift
extension EnvironmentValues {
  @Entry var store: Store = .init()
}

@Observable
class Store {
  ...
}

struct ContentView: View {
    @Environment(\.store) var store // 在视图中通过环境注入
    var body: some View {
       ...
    }
}

这种基于 EnvironmentValue 的注入方式不仅避免了潜在的崩溃风险,还提供了额外的灵活性。比如,你可以轻松地注入多个相同类型的可观察实例,这在 EnvironmentObject 中是难以实现的:

Swift
extension EnvironmentValues {
  @Entry var store: Store = .init()
  @Entry var store1: Store = .init()
  @Entry var store2: Store = .init()
}

在 DynamicProperty 中使用

@Environment 的应用范围不仅限于视图本身,它同样可以在遵循 DynamicProperty 协议的自定义属性包装器中使用。这为创建复杂、可重用的 UI 组件提供了强大的支持。

一个很好的实例是在我们之前讨论的《SwiftUI 与 Core Data —— 数据获取》一文中,我们创建了 @FetchRequest 的替代实现 —— MockableFetchRequest

Swift
@propertyWrapper
public struct MockableFetchRequest<Root, Value>: DynamicProperty where Value: BaseValueProtocol, Root: ObjectsDataSourceProtocol {
    @Environment(\.managedObjectContext) var viewContext
    @Environment(\.dataSource) var dataSource
    // 其他实现...
}

这种实现方式使我们能够从环境中获取 Core Data 的上下文和数据源,同时将数据获取逻辑封装在一个可测试、可复用的属性包装器中。

需要特别注意的是,与视图中使用 @Environment 类似,DynamicProperty 的实现也遵循同样的生命周期规则:环境值只能在包含该属性的视图被加载后才能获取。这意味着我们不能在自定义属性包装器的构造方法中访问环境值,而应该在适当的生命周期方法(如 update() 或视图的 body 计算属性被访问时)中使用这些值。

Environment 的优化

当使用 EnvironmentValue 管理应用状态时,视图的更新效率直接影响用户体验。以下是两种有效的优化策略,可以显著减少不必要的视图重绘:

精准引入

对于包含多个子状态的复合值类型,精准引入特定属性是避免级联更新的关键。通过只订阅视图实际需要的部分状态,我们可以构建更高效的响应式界面。

看这个示例:

Swift
struct MyState {
  var name = "fat"
  var age = 100
}

extension EnvironmentValues {
  @Entry var myState = MyState()
}

struct NameView: View {
  @Environment(\.myState.name) var name // 只引入 name 属性
  var body: some View {
    let _ = print("name view update")
    Text("name: \(name)")
  }
}

struct AgeView: View {
  @Environment(\.myState.age) var age // 只引入 age 属性
  var body: some View {
    let _ = print("age view update")
    Text("age: \(age)")
  }
}

struct RootView: View {
  @State var myState = MyState()
  var body: some View {
    List {
      Button("Change Name") {
        myState.name = "\(Int.random(in: 200 ... 400))"
      }
      Button("Change Age") {
        myState.age = Int.random(in: 100 ... 199)
      }
      NameView()
      AgeView()
    }
    .environment(\.myState, myState)
  }
}

在这个示例中,修改 name 属性只会触发 NameView 更新,而修改 age 属性只会触发 AgeView 更新。这种精细的依赖跟踪机制避免了整个视图树的不必要重建,特别是在处理大型复杂状态时尤为重要。

选择性修改

另一个强大的优化手段是使用 transformEnvironment 替代常规的 environment 修饰符。这允许我们添加条件逻辑,只在满足特定条件时才更新环境值:

Swift
struct RootView: View {
  @State var myState = MyState()
  @State var age = 100
  var body: some View {
    List {
      Button("Change Age") {
        age = Int.random(in: 100 ... 199)
      }
      AgeView()
    }
    .transformEnvironment(\.myState){ state in
      guard age > 150 else {
        print("Ignore \(age)")
        return
      }
      state.age = age // 只有当 age > 150 时才会更新
    }
  }
}

这个技巧特别适用于那些只有在达到特定阈值或条件时才需要触发视图更新的场景。通过减少更新频率,我们可以显著提升应用的响应速度和流畅度,尤其是在处理频繁变化的数据时。

环境值的作用域与传递机制

SwiftUI 中的环境值修改遵循严格的向下传递原则 - 任何对环境值的修改仅对当前视图的子层级及其后代生效,而无法影响同级或上层视图。这一特性在处理像列表编辑模式这样的常见场景时尤为重要:

Swift
struct EnvironmentBinding:View {
  @State private var editMode = EditMode.active
  @State var items = (0..<10).map{Item(id: $0)}
  @State var item:Item?
  var body: some View {
      List(selection:$item) {
        ForEach(items){ item in
          Text("\(item.id)")
            .tag(item)
        }
        .onDelete { _ in }
      }
      // 关键点:必须显式注入编辑模式到环境中
      .environment(\.editMode, $editMode)
  }
}

image-20250321155937423

这种设计确保了视图之间的隔离性,但也容易导致开发者忘记在必要位置注入环境值的常见错误。未能提供正确的环境值注入会直接影响功能的正常运行,如上例中若缺少编辑模式的注入,列表将无法进入编辑状态。

值得注意的是,这一传递规则同样适用于可观察对象实例。虽然视图能够自动响应可观察对象内部属性的变化,但若需要替换整个实例,必须在目标视图的上层视图中进行环境值的注入。这种层级化的依赖注入机制既保证了数据流的可预测性,也为组件之间建立了清晰的边界。

Environment 与并发安全

随着 Swift 6 对并发模型的强化和开发者越来越多地通过 Environment 注入非值类型,确保并发安全已成为 SwiftUI 应用开发中的关键考量。在设计环境值时,我们应优先考虑其在多线程环境下的行为特性,特别是当它们涉及异步操作时。

函数类型的并发标记

对于通过环境传递的函数类型,明确指定并发安全属性是一项良好实践:

Swift
struct CreateNewGroupKey: EnvironmentKey {
    static let defaultValue: @Sendable (TodoGroup) async -> Void = { _ in }
}

extension EnvironmentValues {
  var createNewGroup: @Sendable (TodoGroup) async -> Void {
    get { self[CreateNewGroupKey.self] }
    set { self[CreateNewGroupKey.self] = newValue }
  }
}

struct TodoGroup:Sendable {}

在这个例子中,@Sendable 标记确保了函数可以安全地跨线程边界传递,而不会引发数据竞争或其他并发问题。同时,参数类型 TodoGroup 也被标记为 Sendable,表明它的值可以安全地在不同的任务间传递。

@Entry 宏的并发简化优势

@Entry 宏不仅简化了环境值的定义,还能缓解并发约束的压力。这一点在处理引用类型时尤为明显。考虑以下在 Swift 6 中无法编译通过的代码:

Swift
@Observable
class Store {}

struct StoreKey:EnvironmentKey {
  static let defaultValue = Store()
}

extension EnvironmentValues {
  var store: Store {
    get {self[StoreKey.self]}
  }
}

image-20250321162044791

Swift 6 编译器会明确要求 Store 类遵循 Sendable 协议,以确保其实例可以安全地在并发环境中使用。然而,使用 @Entry 宏后,这一限制被自动处理:

Swift
@Observable
class Store {}

extension EnvironmentValues {
  @Entry var store = Store()
}

这种方式让开发者无需显式处理 Sendable 一致性,避免了以下繁琐的声明:

Swift
// 选项 1: 使用 @unchecked 手动确保并发安全
@Observable
class Store:@unchecked Sendable {}

// 选项 2: 将整个类限制在主线程上
@MainActor
@Observable
class Store {}

@Entry 宏的实例重复创建问题

在 Xcode 16.2 之前,@Entry 通过将 defaultValue 声明为计算属性来满足编译要求。换句话说,上文的 @Entry 代码在底层实际会展开为以下逻辑(功能效果相同):

Swift
@Observable
class Store {}

struct __StoreKey: EnvironmentKey {
    static var defaultValue: Store { Store() }
}

extension EnvironmentValues {
    var store: Store {
        get { self[__StoreKey.self] }
        set { self[__StoreKey.self] = newValue }
    }
}

因此,如果只依赖 @Entry 提供的引用类型默认值,SwiftUI 会在每次为视图准备上下文时不断创建新实例。尽管在上层视图中注入一个实例可以解决一致性问题,但需要注意的是,即使在 Xcode 16.2+ 中 @Entry 的内部实现已调整为使用存储类型,SwiftUI 在实际准备视图环境时依然会多次创建新实例(而视图最终使用的则是上层注入的那个实例)。如果对性能有顾虑,建议开发者依旧采用传统方式在 EnvironmentValues 中手动声明引用类型数据。

感谢 Rick van Voorden 对上述行为的反馈。

Environment 与第三方依赖注入框架

SwiftUI Environment 的设计虽然优雅,但其严格绑定视图生命周期的特性在某些场景下可能成为限制 — 特别是当开发者需要将业务逻辑分离到 ViewModel 层或进行单元测试时。为了突破这一限制,许多开发者转向第三方依赖注入框架,其中 Point-Free 的 Swift-Dependencies 库是一个值得关注的选择。

Swift
private enum MyValueKey: DependencyKey {
  static let liveValue = 42
}

extension DependencyValues {
  var myValue: Int {
    get { self[MyValueKey.self] }
    set { self[MyValueKey.self] = newValue }
  }
}

Dependencies 的设计与 SwiftUI 的 EnvironmentValue 如出一辙,使熟悉 SwiftUI 的开发者能够快速上手,同时获得更大的灵活性。

双系统共存的优势

这种结构相似性创造了一个强大的可能性:在同一项目中并行维护两套依赖注入系统,甚至为相同功能提供双重实现。在实际项目中,这不仅是可行的,更是一种策略性选择:

Swift
// SwiftUI Environment 实现
struct UpdateMemoKey: EnvironmentKey {
    static let defaultValue: @Sendable (TodoTask, TaskMemo?) async -> Void = { _, _ in }
}

extension EnvironmentValues {
   var updateMemo: @Sendable (TodoTask, TaskMemo?) async -> Void {
        get { self[UpdateMemoKey.self] }
        set { self[UpdateMemoKey.self] = newValue }
    }
}

// Swift-Dependencies 平行实现
struct UpdateMemoKey: DependencyKey {
    static let liveValue: @Sendable (TodoTask, TaskMemo?) async -> Void = { _, _ in }
}

public extension DependencyValues {
      var updateMemo: @Sendable (TodoTask, TaskMemo?) async -> Void {
        get { self[UpdateMemoKey.self] }
        set { self[UpdateMemoKey.self] = newValue }
    }
}

这种双轨制实现为开发者提供了多重优势:

  1. 架构灵活性:可在视图层使用 SwiftUI Environment,同时在业务逻辑层采用 Swift-Dependencies
  2. 测试友好:业务逻辑可以完全脱离视图进行单元测试
  3. 渐进式迁移:项目可以逐步从一种依赖注入模式过渡到另一种,而不必全面重构
  4. 技术锁定避免:减少对特定框架的依赖,增强代码的长期可维护性

以下示例展示了一个实际应用程序中如何同时应用这两种依赖注入机制:

Swift
@main
struct Todo_TCAApp: App {
    let stack = CoreDataStack.shared
    var body: some Scene {
        WindowGroup {
            NavigationStack {
                GroupListContainerView(
                    store: .init(
                        initialState: .init(),
                        reducer: GroupListReducer()
                            // Swift-Dependencies 注入
                            .dependency(\.createNewGroup, stack.createNewGroup)
                            .dependency(\.updateGroup, stack.updateGroup)
                            // 更多依赖项...
                    )
                )
            }
            // SwiftUI Environment 注入
            .environment(\.managedObjectContext, stack.viewContext)
            .environment(\.getTodoListRequest, stack.getTodoListRequest)
            // 更多环境值...
        }
    }
}

这种混合策略让开发者能够权衡利用 SwiftUI 原生机制的便捷性与第三方解决方案的额外灵活性,在不同层级选择最适合的工具,从而构建更加模块化、可测试且易于维护的应用程序架构。

总结

SwiftUI 的 Environment 巧妙地将依赖注入与视图生命周期融为一体,这既是它的力量源泉,也是它的边界所在。这种设计哲学保证了数据流在视图树中的高效与可控,同时也促使开发者构建出界限分明的 UI 组件。

通过精准引入和选择性修改等优化手段,我们可以最大限度地发挥 Environment 的性能优势,避免不必要的视图更新。同时,对环境值作用域的清晰认知,以及对潜在注入问题的警惕,是保证应用稳定运行的关键。

在 SwiftUI 生态中,Environment 并非孤立存在。我们可以灵活地将其与第三方依赖注入方案结合,以应对更复杂的应用场景。选择何种方案,取决于具体的项目需求和团队偏好。但无论如何,对 SwiftUI Environment 设计理念的深入理解,将为开发者构建高质量、可扩展的 SwiftUI 应用奠定坚实基础。

"加入我们的 Discord 社区,与超过 2000 名苹果生态的中文开发者一起交流!"

每周精选 Swift 与 SwiftUI 精华!