理解SwiftUI数据流(一)

4,556 阅读6分钟

核心设计思想

数据(状态)驱动

在 SwiftUI 中,视图是由数据(状态)驱动的,按照苹果的说法,视图是状态的函数View = func(State),每当视图在创建或解析时,都会为该视图和与该视图中使用的状态数据之间创建一个依赖关系,每当状态的信息发生变化是,有依赖关系的视图则会马上反应出这些变化并重绘。

SwiftUI中的视图是渲染界面的模型,而不是真正的界面:仅仅包含界面结构、元素和各种属性的描述,并不包含界面像素、绘图缓冲区、绘图上下文等和界面渲染相关的内容。

struct ContentView: View { 
    @State var content = "xiaoming" 
    var body:some View { 
        VStack{ 
            Text(name)
            Button("改内容"){ 
                self.content = "daming" 
            }
        } 
    } 
}

通过上面代码我们可以发现:

  1. View是值类型,但是我们可以在未使用 mutating 的情况下修改结构中的值 content
  2. 当状态值变化后,View会自动重绘以反映状态变化。

@State 如何工作的

在分析@State 如何工作之前,我们需要先了解几个知识点。

@propertyWrapper

属性包装器在管理属性如何存储和定义属性的代码之间添加了一个分隔层。举例来说,如果你的属性需要线程安全性检查或者需要在数据库中存储它们的基本数据,那么必须给每个属性添加同样的逻辑代码。当使用属性包装器时,你只需在定义属性包装器时编写一次管理代码,然后应用到多个属性上来进行复用。

定义一个属性包装器,你需要创建一个定义 wrappedValue 属性的结构体、枚举或者类。在下面的代码中,TwelveOrLess 结构体确保它包装的值始终是小于等于 12 的数字。如果要求它存储一个更大的数字,它则会存储 12 这个数字。

@propertyWrapper
struct TwelveOrLess {
    private var number = 0
    var wrappedValueInt {
        get { return number }
        set { number = min(newValue, 12) }
    }
}

struct SmallRectangle {
    @TwelveOrLess var height: Int
    @TwelveOrLess var width: Int
}

var rectangle = SmallRectangle()
print(rectangle.height)
// 打印 "0"
rectangle.height = 10
print(rectangle.height)
// 打印 "10"
rectangle.height = 24
print(rectangle.height)
// 打印 "12"

nonmutating

我们前面所说的一个问题:View是值类型,但是我们可以在未使用 mutating 的情况下修改结构中的值 content,这是为什么呢?

先看代码:

struct ContentView: View { 
    @State private var name: String = "xiaoming" // State的变量 
    private var tempName: String = "daming" // 普通变量 
    mutating func changeTempName(_ name: String) { 
        //mutating的方法修改tempName 
        self.tempName = name 
    } 
    var body: some View {
        Button(action: {
            //1 ok
            self.name = "xiao2ming" 
            //2 Cannot assign to property: 'self' is immutable
            self.tempName = "da2ming" 
            //3 Cannot use mutating member on immutable value: 'self' is immutable 
            changeTempName("da2ming") 
            }) { 
                Text("Test")
            } 
    } 
}

通过注释可以看出name可以修改, tempName 属性和调用 mutating 方法都不可以修改。

为什么tempName属性和调用mutating方法都不可以修改

struct ContactUs {
    var tempName: String {
        _tempName = "daming"
//      ^~~~~~~~~~~~
//      error: cannot assign to property: 'self' is immutable
        return _tempName
    }
    private var _tempName = "xiaoming"
}

_tempName 是存储属性, tempName 是计算属性,计算属性getter方法默认是nonmutating,不可修改的,我们可以将getter改为mutating,这样就可以被修改了。

struct ContactUs {
    var tempName: String {
    mutating get {
       _tempName = "daming"
       return _tempName
    }
    private var _tempName = "xiaoming"
}

既然可以通过添加mutating,使得计算属性get中可以修改self,那么SwiftUI中前面示例的body属性可否添加呢? 查看View协议的定义

public protocol View {
    associatedtype Body : View
    var body: Self.Body { get }
}

我们可以看到body是一个计算属性,并且getter方法没有标记mutating,所以tempName属性和调用mutating方法都不可以修改。注:只有mutating func 才能调用 mutating func。

为什么可以修改name

我们看一下@State的定义

@propertyWrapper public struct State<Value> {
   public init(wrappedValue value: Value)
   public init(initialValue value: Value)
   public var wrappedValue: Value { get nonmutating set }
   public var projectedValue: Binding<Value> { get }
}

wrappedValue是一个计算属性,getter方法默认是nonmutatingsetter方法默认是mutating,但这里被标记为nonmutating,为什么呢?

其实这个nonmutating是一个编译器声明,它像编译器表明这个赋值过程不会修改这个struct本身,而是修改其他变量。

struct ContactUs {
   private var name: String {
      get {
          return UserDefaults.standard.string(forKey: "name")
      }
      nonmutating set {
          UserDefaults.standard.setValue(newValue, forKey: "name")
      }
   }
}

可以修改name的原因: 添加了property wrapper的属性,变量本身并没有变化,而是修改了由 SwiftUI 维护的当前struct之外的变量,这也可以解释State中的wrappedValuesetter方法被标记nonmutating的原因。

  • 这就解释了我们为什么可以在body这个计算属性中修改View结构体中的@State的属性。
  • 同时也了解@State是如何让我们在视图中修改、绑定数据的。

@State是如何更新视图的

首先看一下DynamicProperty 的定义

public protocol DynamicProperty {
    /// Called immediately before the view's body() function is 
    /// executed, after updating the values of any dynamic properties 
    /// stored in `self`. 
    mutating func update()
}

遵循 DynamicProperty 协议,该协议完成了创建数据(状态)和视图的依赖操作所需接口。现在只暴露了很少的接口,我们暂时无法完全使用它。

update() 是在@State属性改变之后,body调用之前调用。

@State 和视图的依赖关系?

很遗憾的是前无法找到任何关于 SwiftUI 建立依赖的更具体的资料或实现线索。不过我们可以通过下面两段代码来猜测编译器是如何处理数据和视图之间的依赖关联时机的。

struct MainView: View { 
    @State var date: String = Date().description 
    var body: some View { 
        print("mainView") 
        return Form {
            SubView(date: $date) 
            Button("修改日期") { 
                self.date = Date().description 
            } 
        }
    }
}

struct SubView: View {
    @Binding var date: String 
    var body: some View {
        print("subView") 
        return Text(date) 
    } 
}


执行这段代码,我们点击修改日期 ,我们会得到如下输出

mainView 
subView 
...

虽然我们在 MainView 中使用@State 声明了 date,并且在 MainView 中修改了 date 的值,但由于我们并没有在 MainView 中使用 date 的值来进行显示或者判断,所以无论我们如何修改 date 值,MainView 都不会重绘。我推测@State 同视图的依赖是在 ViewBuilder 解析时进行的。编译器在解析我们的 body 时,会判断 date 的数据变化是否会对当前视图造成改变。如果没有则不建立依赖关联。

@Binding

通过@State的定义,我们还有一个 projectedValue 的属性没有讨论,他是一个 Binding 的可读类型,

在 Apple 的文档中,对 Binding 的描述是这样的:

Use a binding to create a two-way connection between a view and its underlying model.

先看一下例子

struct ContentView { 
    @State var content: String 
    var body: some View {
        TextField("Placeholder", $content)
    } 
}

$content 在 ContentView 代表的界面和 content 代表的数据之间,创建了双向绑定。

这是怎么实现的呢?

init(get: () -> Value, set: (Value) -> Void)

Binding 本质就是一个 getter 和 setter,那么getter 究竟是从哪读数据?setter 又设置了什么呢?我们结合下面这张图解释这个问题:

1653277355896.png

ContentView.content 作为一个 property wrapper,它的 projectValue 是一个 Binding<String> 对象,这个 Binding<String> 的 getter 和 setter 对应着 wrappedValue 的 get 和 set 方法。

当用户在 ContentView 内置的 TextField 中输入内容时候,TextField 就可以通过注入的 Binding 对象间接修改 content 属性的值了。

由于 content 又是一个 @State 对象,SwiftUI 运行时就会在它的渲染队列中加入所有依赖这个属性的界面的渲染任务。当屏幕刷新的时候,所有受影响的部分就更新过来了。言外之意,属性变化到界面变化的过程,是一个串行的过程,只不过这个过程发生的很快(大部分 iOS 设备上都是每秒 60 次,iPad pro 则可以达到每秒 120 次),它并不会让我们感觉到有任何延迟。

View 中的 @State

对于上述 View body 对 State的调用大家可能还有一些疑问,为什么我可以直接使用State进行复制操作呀,State和String类型不同呀?

实际上SwiftUI把@State var content: String = "" 转换成三个属性。

private var _content: State<String> = State(initialValue: "")
private var $content: Binding<String> { return _content.projectedValue }
private var content: String {
    get { return _content.wrappedValue }
    nonmutating set { _content.wrappedValue = newValue }
}

总结

  • @State 本身包含 @propertyWrapper, 意味着他是一个属性包装器。
  • public var wrappedValue: Value { get nonmutating set } 意味着他的包装值并没有保存在本地。
  • 它的呈现值(投射值)为 Binding 类型。也就是只是一个管道,对包装数据的引用
  • 遵循 DynamicProperty 协议,该协议完成了创建数据(状态)和视图的依赖操作所需接口。现在只暴露了很少的接口,我们暂时无法完全使用它。

@State 非常适合 struct 或者 enum 这样的值类型,它可以自动为我们完成从状态 到 UI 更新等一系列操作。但是它本身也有一些限制,我们在使用 @State 之前,对 于需要传递的状态,最好关心和审视下面这两个问题:

  1. 这个状态是属于单个 View 及其子层级,还是需要在平行的部件之间传递和使用?@State 可以依靠 SwiftUI 框架完成 View 的自动订阅和刷新,但这是有条件的:对于 @State 修饰的属性的访问,只能发生在 body 或者 body 所调 用的方法中。你不能在外部改变 @State 的值,它的所有相关操作和状态改变都应该是和当前 View 挂钩的。如果你需要在多个 View 中共享数据,@State 可能不是很好的选择;如果还需要在 View 外部操作数据,那么 @State 甚至就不是可选项了。

  2. 状态对应的数据结构是否足够简单?对于像是单个的 Bool 或者 String, @State 可以迅速对应。含有少数几个成员变量的值类型,也许使用 @State 也还不错。但是对于更复杂的情况,例如含有很多属性和方法的类型,可能其中只有很少几个属性需要触发 UI 更新,也可能各个属性之间彼此有关联,那么我们应该选择引用类型和更灵活的可自定义方式。

对于这样的不适合选择 @State 的情况 (往往这是实际数据传递中 更普遍的情况),ObservableObject@ObservedObject 是解决的方案。