深入了解 SwiftUI 和 Swift 属性包装器的工作原理
深入了解 SwiftUI 和 Swift 属性包装器的工作原理

深入了解 SwiftUI 和 Swift 属性包装器的工作原理

代码示例

首先分享一段代码示例,下面的代码内容为通过Slider滑动或者点击Button按钮,改变Text文本内容的模糊程度。

struct ContentView: View {
    @State private var blurAmount = 0.0
    
    var body: some View {
        VStack {
            Text("Hello, World!")
                .blur(radius: blurAmount)
            
            Slider(value: $blurAmount, in: 0...20)
                .onChange(of: blurAmount) { newValue in
                    print("New value is \(newValue)")
                }
            
            Button("Random Blur") {
                blurAmount = Double.random(in: 0...20)
            }
        }
    }
}

在blurAmount中添加didSet重新观察输出内容:

@State private var blurAmount = 0.0 {
    didSet {
        print("New value is \(blurAmount)")
    }
}

会发现只有在视图点击Button的时候,才会调用didSet闭包。拖动Slider并不会调用didSet

Slider没有触发didSet的原因

在 SwiftUI 中,使用 @State 声明的属性是专门为视图的本地状态设计的。这些属性是由 SwiftUI 管理的,并且它们的值更改不会触发 didSet 属性观察器。原因是 @State 的实际存储由 SwiftUI 框架控制,而不是自己声明的属性存储。

具体来说,didSet 不会被调用的原因是 SwiftUI 对 @State 的更改进行了特殊的处理,它直接将新的值注入到 State 的存储中,而不是通过传统的 Swift 属性赋值逻辑,这绕过了 didSet。

什么是@State

@State 是 SwiftUI 提供的属性包装器,用于管理视图的本地状态。其作用是:

存储视图状态值:状态值被存储在 SwiftUI 管理的内存中,而不是对象的普通属性。

驱动视图重绘:当 @State 的值发生更改时,SwiftUI 会自动重新计算依赖该值的视图,并更新 UI。

例如:

@State private var count: Int = 0

在编译后,@State 的行为类似于一个包装器,它在视图结构中维护状态值。这种特殊机制让 @State 的存储位置和生命周期不同于普通的类或结构体属性。

@State 如何管理状态?

@State 的实际值存储在 SwiftUI 的内部环境中,而不是视图结构本身。这种存储方式有以下特点:

SwiftUI 会为每个 @State 创建一个专用的存储容器。

当视图的 body 被重新计算时,@State 的值会自动绑定到正确的存储容器中。

通过 $ 获取 Binding,让 UI 控件能够直接读写状态值。

因此,当 @State 的值改变时,SwiftUI 是通过底层容器更新值,而不是直接修改视图的属性。

为什么 didSet 不会触发?

didSet 是 Swift 属性的一部分,用于监听普通属性的值变化。它的触发条件是通过属性赋值语法 (self.property = newValue) 修改值。然而:

@State 的值并不是直接存储在视图的属性中,而是存储在 SwiftUI 的专用容器中。

当 @State 的值更新时,SwiftUI 会直接操作容器内部的值,而不是通过属性赋值。这种操作绕过了 Swift 属性观察器的调用。

换句话说,@State 的更改是 由 SwiftUI 管理的内部机制触发,而不是通过标准的 Swift 属性赋值流程。

didSet触发条件

属性观察器(didSet 和 willSet)是在属性的值被修改时触发的。这种修改必须通过显式的赋值语法完成,例如:

self.property = newValue

对属性进行赋值操作时,Swift 会自动调用 didSet。这是因为属性赋值是 Swift 编译器内置的一个明确的触发点。

因为:

1、Swift 属性观察器是与属性赋值逻辑绑定的。

2、只有赋值操作才能触发属性存储的变更逻辑,继而调用 didSet。

3、其他非赋值操作绕过了编译器对属性存储的管理,因此不会触发 didSet。

SwiftUI容器

@State 的存储容器是 SwiftUI 内部管理的一个机制,旨在为声明式 UI 编程模型提供状态管理支持。

@State 容器存在在哪里?

@State 的容器并不直接存在于视图的结构中,而是由 SwiftUI 的运行时环境动态管理。它的存储位置和作用范围主要包括以下几个方面:

1、绑定到视图的生命周期

当一个 SwiftUI 视图被初始化时,SwiftUI 会为其 @State 属性创建一个专用的存储容器。

这个存储容器与视图的生命周期绑定,但独立于视图的具体实现。

2、在 SwiftUI 内部的状态缓存中

SwiftUI 维护了一个内部状态缓存系统,用于存储和管理 @State 值。

这些值与视图树关联,并通过键值或其他标识符在视图树中查找和更新。

3、独立于视图实例的重建

当 SwiftUI 重新计算视图的 body(例如,视图被重新渲染时),原来的 @State 容器依然保留,确保状态在视图重建过程中不会丢失。

容器中存储的内容是什么?

@State 容器的核心是一个简单的值存储。具体来说:

1、实际存储的状态值

容器存储的是 @State 属性所管理的值,例如 Int、String、Double 或自定义类型。

值是直接存储的,SwiftUI 在需要时访问和更新这些值。

2、绑定机制

容器还存储了与绑定 (Binding) 相关的信息。通过 $ 访问的绑定实际上是对容器的引用,允许 UI 控件双向绑定到状态值。

3、变更通知机制

容器还会跟踪值的变更。当值发生变化时,SwiftUI 会标记相关视图为“需要更新”,从而触发 UI 重绘。

容器的作用是什么?

@State 容器的作用可以总结为以下几点:

1、状态管理

容器确保视图的状态值在视图生命周期内是持久的,即使视图本身被重新计算。

2、UI 重绘驱动

当 @State 的值发生变化时,SwiftUI 会监听到这个变化,并根据声明式 UI 的依赖关系自动触发视图的更新。

3、绑定支持

容器通过 Binding 提供了双向数据流支持,使得 UI 控件和状态值之间可以进行动态交互。

4、简化开发者的工作

开发者不需要手动管理状态值的存储或更新,也不需要触发视图的重绘。@State 的容器自动完成这些工作。

容器的内部实现(推测)

SwiftUI 是一个封闭的框架,其内部实现未公开,但从其行为和设计可以推测:

1、哈希或标识符管理

每个 @State 属性通过某种哈希值或标识符与视图树关联。这确保即使视图被重建,状态也能被正确恢复。

2、值存储和观察

SwiftUI 使用值类型(如 Double、Int)进行轻量存储。

值变化会触发 SwiftUI 内部的观察机制。

3、增量更新

SwiftUI 会根据状态的变化确定哪些视图需要重新计算,从而优化性能。

解决方案

为了解决前面的代码示例,可以考虑以下两种方式:

使用 onChange 修饰符

SwiftUI 提供了一个 onChange(of:perform:) 修饰符,可以用于监听状态值的更改:

struct ContentView: View {
    @State private var blurAmount = 0.0
    
    var body: some View {
        VStack {
            Text("Hello, World!")
                .blur(radius: blurAmount)
            
            Slider(value: $blurAmount, in: 0...20)
                .onChange(of: blurAmount) { _, newValue in
                    print("New value is \(newValue)")
                }
            
            Button("Random Blur") {
                blurAmount = Double.random(in: 0...20)
            }
        }
    }
}

需要注意的是onChange在iOS14后,推荐使用接受旧值和新值的完整形式:

.onChange(of: someValue) { oldValue, newValue in
    // 响应变化,并可使用旧值和新值
}

之前的只接受新值的简化形式被标记为废弃,从iOS17开始不建议使用。

使用 Binding 自定义行为

通过包装一个 Binding,可以在更改值时插入自定义逻辑:

struct ContentView: View {
    @State private var blurAmount = 0.0
    
    var body: some View {
        VStack {
            Text("Hello, World!")
                .blur(radius: blurAmount)
            
            Slider(value: Binding(
                get: { blurAmount },
                set: { newValue in
                    blurAmount = newValue
                    print("New value is \(newValue)")
                }
            ), in: 0...20)
            
            Button("Random Blur") {
                blurAmount = Double.random(in: 0...20)
            }
        }
    }
}

由于 @State 的特殊实现,didSet 不会在其值变化时触发。如果需要响应状态值变化,可以使用 onChange 修饰符或者自定义 Binding,这两种方式都适合 SwiftUI 的声明式编程模式。

解决方案背后的机制

如果想在 @State 值更改时执行逻辑操作,SwiftUI 提供了更符合声明式编程模型的方式,比如 onChange 和自定义 Binding。它们通过在值更新时显式插入逻辑来替代传统的 didSet。

onChange 的实现机制

onChange 是一个 SwiftUI 修饰符,它监听指定值的变化,并在值更新后触发回调:

.onChange(of: value) { newValue in
    // 执行逻辑
}

内部原理是 SwiftUI 监视 value 的变化并触发回调。这是一种响应式的、与 @State 机制契合的设计。

自定义 Binding 的实现机制

通过 Binding 包装,可以将值的读取和写入操作显式控制:

Binding(
    get: { self.value },
    set: { newValue in
        self.value = newValue
        // 插入自定义逻辑
    }
)

在这里,get 和 set 是明确的闭包,它们允许拦截值的变化,而不是依赖 didSet。

总结

@State 的值变化不会触发 didSet 的核心原因在于:

1、@State 的值存储在 SwiftUI 的专用容器中,而不是视图的属性中。

2、SwiftUI 更改 @State 的值时,绕过了普通的 Swift 属性赋值逻辑,因此不会触发 didSet。

正确响应 @State 的变化需要使用 SwiftUI 的响应式工具(如 onChange 和 Binding),这符合 SwiftUI 的声明式编程范式。

相关文章

How property wrappers become structs:https://www.hackingwithswift.com/books/ios-swiftui/how-property-wrappers-become-structs

如果您认为这篇文章给您带来了帮助,您可以在此通过支付宝或者微信打赏网站开放者。

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注