写点什么

SwiftUI 数据流之 State&Binding

用户头像
kingnight_pig
关注
发布于: 2021 年 02 月 28 日

在 SwiftUI 中,以单一数据源(single source of truth)为核心,构建了数据驱动状态更新的机制。其中引入了多种新的属性包装器(property wrapper),用来进行状态管理。本篇主要介绍 @State 和 @Binding,将从简单的使用入手,通过一系列具体的代码实例展示它们的使用场景,并进步一探索 State 的内部实现

环境

  • MacOS 10.15.5

  • Xcode 12.0 beta

State

A property wrapper type that can read and write a value managed by SwiftUI.

@State 是一个属性包装器(property wrapper),被设计用来针对值类型进行状态管理;用于在 Struct 中 mutable 值类型

struct User {struct User {    var firstName = "Bilbo"    var lastName = "Baggins"}
struct ContentView: View { @State private var user = User() //1
var body: some View { VStack { Text("Your name is \(user.firstName) \(user.lastName).") //2 TextField("First name", text: $user.firstName) //3 TextField("Last name", text: $user.lastName) } }}
复制代码
  • 对于 @State 修饰的属性的访问,只能发生在 body 或者 body 所调用的方法中。你不能在外部改变 @State 的值,只能 @State 初始化时,设置初始化值,如注释 1 处所示,它的所有相关操作和状态改变都应该是和当前 View 生命周期保持一致。

  • 在引用包装为 @State 的属性是,如果是读写都有,引用属性需要 $开头(注释 3 处),如果只读直接使用变量名即可(注释 2 处)

  • State 针对具体 View 的内部变量进行管理,不应该从外部被允许访问,所以应该标记为 private(注释 1 处)

但是,如果把struct User替换为class User将会无效,为什么呢?

State 检测的是值类型

  • 值类型仅有独立的拥有者,而 class 类型可以多个指向一个;对于两个 SwiftUI View 而言,即使发送给他们两个相同的 struct 对象,事实上他们每个 View 都得到了一份独立的 struct 的拷贝,所以其中一个 View 的 struct 值发生变化,对另一个没有影响;反之,如果是 class 则会互相影响;

  • 当 User 是一个结构体时,每次我们修改这个结构体的属性时,Swift 实际上是在创建一个新的结构体实例。@State 能够发现这个变化,并自动重新加载我们的视图。现在如果改为 class,我们有了一个类,这种行为就不再发生,Swift 可以直接修改值。

还记得我们如何使用 mutating 关键字来修改结构方法的属性吗?

struct User {struct User {    var name:String    mutating func changeName(name:String) {        self.name = name    }}
复制代码

这是因为如果我们创建了作为变量的结构体属性,但结构体本身是常量,我们不能更改属性;当属性发生变化时,Swift 需要能够销毁并重新创建整个结构体,而这对于常量结构体是不可能的。类不需要 mutating 关键字,因为即使类实例被标记为常量,Swift 仍然可以修改变量属性。

如果 User 是一个类,属性本身就不会改变,所以 @State 不会注意到任何东西,也无法重新加载视图。即使类内的某个属性值发生变化,但 @State 不监听这些,所以视图不会被重新加载。

如果想要改变这种情况,使得 class 类被监听到变化,就不能使用 State,需要使用 @ObservedObject 或 @StateObject

Binding

A property wrapper type that can read and write a value owned by a source of truth.

Binding 的作用是在保存状态的属性和更改数据的视图之间创建双向连接,将当前属性连接到存储在别处的单一数据源(single source of truth),而不是直接存储数据。将存储在别处的值语意的属性转换为引用语义,在使用时需要在变量名加 $符号。

通常使用场景是把当前 View 中的 @State 值类型传递给其子 View,如果直接传递 State 值类型,将会把值类型复制一份 copy,那么如果子 View 中对值类型的某个属性进行修改,父 View 不会得到变化,所以需要把 State 转成 Binding 传递。

@Binding 修饰属性无需有初始化值,Binding 可以配合 @State 或 ObservableObject 对象中的值属性一起使用,注意不是 @ObservedObject 属性包装器

//Modelstruct Product:Identifiable {    var isFavorited:Bool    var title:String    var id: String}//SubViewstruct FilterView: View {    @Binding var showFavorited: Bool  //3
var body: some View { Toggle(isOn: $showFavorited) { //4 Text("Change filter") } }}//ParentViewstruct ProductsView: View { let products: [Product] = [ Product(isFavorited: true, title: "ggggg",id: "1"), Product(isFavorited: false, title: "3333",id: "2")]
@State private var showFavorited: Bool = false //1
var body: some View { List { FilterView(showFavorited: $showFavorited) //2
ForEach(products) { product in if !self.showFavorited || product.isFavorited { Text(product.title) } } } }}
复制代码

这个例子展示了一个有过滤开关的列表,为了简化内容说明核心问题,只有两行内容,父视图是 ProductsView,其中嵌套着子视图 FilterView 和列表元素,为了能够使得 FilterView 中对 showFavorited 的修改能够传递回父视图:

  • 注释 1,showFavorited 使用 @State 修饰

  • 注释 2,在 body 中通过 $showFavorited 获得 showFavorited 对应的 Binding 传递给子视图 FilterView

  • 注释 3,子视图 FilterView 中定义了@Binding var showFavorited: Bool引用传入参数

  • 注释 4,当切换开关后,由于 @Binding 机制的作用,会修改外层的单一数据源(single source of truth),所以列表中展示的内容会不断根据条件进行过滤

可变和不可变

观察下面示例

struct StateMutableView: View {struct StateMutableView: View {    @State private var flag = false  //flag是标记为State的变量    private var anotherFlag = false  //anotherFlag是没有使用属性包装器的普通变量
mutating func changeAnotherFlag(_ value: Bool) { //mutating的方法修改anotherFlag self.anotherFlag = value } var body: some View { Button(action: { //1 ok self.flag = true //2 Cannot assign to property: 'self' is immutable self.anotherFlag = true //3 Cannot use mutating member on immutable value: 'self' is immutable changeAnotherFlag(true) }) { Text("Test") } }}
复制代码

flag 是标记为 State 的变量,anotherFlag 是没有使用属性包装器的普通变量,同时增加了一个 mutating 的方法changeAnotherFlag被设计修改 anotherFlag;

在 body 中通过几种方式对两个变量进行修改,注释 1-3 处,分别标记了修改结果和提示错误,显然 flag 可以被修改,而 anotherFlag 不可以,这是为什么?

这里涉及两个问题:

  1. 为什么可以修改 flag?

  2. 为什么不可以修改 anotherFlag?

先来看第二个问题

为什么不可以修改 anotherFlag

计算属性 getter 方法

示例 5

struct SimpleStruct {struct SimpleStruct {    //计算属性anotherFlag    var anotherFlag: Bool {        _anotherFlag = true//      ^~~~~~~~~~~~//      error: cannot assign to property: 'self' is immutable        return _anotherFlag    }    //存储属性_anotherFlag    private var _anotherFlag = false}
复制代码

注意对比两个属性:_anotherFlag 是存储属性,anotherFlag 是计算属性;

计算属性的 getter 方法,默认是 nonmutating,是不能被修改的,所以报错

但是,可以有例外,如果 getter 被特殊标记为 mutating,就可以被修改

修改如下:

struct SimpleStruct {struct SimpleStruct {    var anotherFlag: Bool {        mutating get {            _anotherFlag = true            return _anotherFlag        }    }
private var _anotherFlag = false}
复制代码

并且还需要使用 SimpleStruct 时,声明实例为 var

var s0 = SimpleStruct()_ = s0.anotherFlag // ok, and modifies s0let s1 = SimpleStruct()_ = s1.anotherFlag// ^~ error: cannot use mutating getter on immutable value: 's1' is a 'let' constant
复制代码

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

查看 View 协议的定义

public protocol View {
/// The type of view representing the body of this view. /// /// When you create a custom view, Swift infers this type from your /// implementation of the required `body` property. associatedtype Body : View
/// Declares the content and behavior of this view. var body: Self.Body { get }}
复制代码

body 修饰符是 get,不能被改为 mutating get,所以如果你改为这样下面

struct SimpleView: View {//     ^ error: type 'SimpleView' does not conform to protocol 'View'     var body: some View {        mutating get { Text("Hello") }    }}
复制代码

会报错,提示没有遵守 View 协议

小结:不可以修改 anotherFlag,即不可以修改 SwiftUI 中 Struct 内的普通变量,其内在的原因是:body 计算属性的 getter 方法不可以被修改为 mutating

为什么可以修改 flag

由于 SwiftUI 设计之初就是希望构建的 View 树保持不变,这样才能高效的渲染 UI,跟踪变化,当标记为 @State 的变量发生变化时,变量本身由于在 Struct 中不能发生变化,所以通过 State 为例的 property wrapper 本质是修改当前 struct 之外的变量

我们看一下 State 的定义

@frozen @propertyWrapper public struct State<Value> : DynamicProperty {
/// Initialize with the provided initial value. public init(wrappedValue value: Value)
/// Initialize with the provided initial value. public init(initialValue value: Value)
/// The current state value. public var wrappedValue: Value { get nonmutating set }
/// Produces the binding referencing this state value public var projectedValue: Binding<Value> { get }}
复制代码

wrappedValue 就是被标记为 nonmutating set,直接使用 state 对象是用的 wrappedValue,$符号使用的 projectedValue

nonmutating 有什么含义?

计算属性 setter

在 setter 属性中,self 默认是 mutating,可以被修改;我们不能给一个不可变的量赋值,可以通过声明 setter nonmutating 使属性可赋值,这个 nonmutating 关键字向编译器表明,这个赋值过程不会修改这个 struct 本身,而是修改其他变量。

struct SimpleStruct {    var anotherFlag: Bool {        mutating get {            _anotherFlag = true            return _anotherFlag        }    }
private var _anotherFlag: Bool { get { return UserDefaults.standard.bool(forKey: "storage") } nonmutating set { UserDefaults.standard.setValue(newValue, forKey: "storage") } }}
let s0 = SimpleStruct()var s1 = s0_ = s1.anotherFlag // 同时影响s0和s1,他们内部的_anotherFlag都发生了变化
复制代码

这个例子当中_anotherFlag 修改了 UserDefaults 的值,会同时对 s0 和 s1 都产生影响,相当于起到了引用类型的作用,在实际编程中这当然是一个不好的范例,容易产生问题

小结:可以修改 flag 的原因,添加了 property wrapper 的属性,变量本身并没有变化,而是修改了由 SwiftUI 维护的当前 struct 之外的变量

State 内部实现

为了进一步深入分析,我们继续展示一个相对完整的例子


  • 为了分析变量状态,在 16 行,User 结构体 init 方法;39 行,ContentView 的 init 方法结束;47 行,按钮点击执行函数部分,都加入了断点

  • 由于 @State 针对值类型,为了打印出 struct 的地址,增加了 address 函数

  • dump 系统函数,能够打印出变量内部结构


运行界面如上图所示,本文输入框可以修改 name,Count+1 按钮使得 count 计数加 1

打开断点,从头开始执行代码,首先执行到 16 行断点处,User 初始化,此时 self 是 User 结构体本身

po self▿ User \- name : "" \- count : 0
复制代码

继续执行到 ContentView 的初始化方法最后一行,此时 self 是 ContentView,打印一下

po self▿ ContentView ▿ _user : State<User>  ▿ _value : User   \- name : ""   \- count : 0  \- _location : nil
复制代码

出现了一个新的_user变量,类型是State<User>,这个变量内部属性_value类型是User;这意味着,加了 @State 属性包装器的 user 实例变量,由本身的User类型转变为一个新的State<User>类型,这个转变完成的新类型实例_user由 SwiftUI 负责生成和管理,它的内部包裹着真实的 User 实例,另外_location也值得注意,它目前是 nil;

如果你注意到 35 行代码user = User(name: "TT", count: 100)发现它并不会改变内部_user;

如果想要修改,只能采用下面方式,通过 State 提供的第二个初始化方法

_user = State(wrappedValue: User(name: "TT", count: 100))
复制代码

与此同时,检查当前 console 的 log 输出

User initContentView init140732783334216▿ SwiftUI.State<DemoState.User>  ▿ _value: DemoState.User    - name: ""    - count: 0  - _location: nil
复制代码

按照预期的执行顺序,User init 执行,ContentView init 执行,然后打印出了当前结构体的地址和_user内部结构;

下一步,由于 body 执行完毕,页面渲染完整,现在点击 Count+1 按钮,断点停在 47 行,再观察内部变量情况

po self▿ ContentView  ▿ _user : State<User>    ▿ _value : User      - name : ""      - count : 0    ▿ _location : Optional<AnyLocation<User>>      ▿ some : <StoredLocation<User>: 0x600003c26a80>
复制代码

_user 没有变化,但是_location不再是 nil

继续执行,运行完成print(address(o: &user))dump(_user)两个函数,输出结果如下:

140732783330824▿ SwiftUI.State<DemoState.User>  ▿ _value: DemoState.User    - name: ""    - count: 0  ▿ _location: Optional(SwiftUI.StoredLocation<DemoState.User>)    ▿ some: SwiftUI.StoredLocation<DemoState.User> #0
复制代码

仔细对一下以下,user 的地址发生了变化,开始时创建的 user 被销毁又重新创建了,这是因为 @State 修饰的属性的它的所有相关操作和状态改变都应该是和当前视图生命周期保持一致,当视图没有被初始化完成时,无法完成状态属性和视图之间的绑定关系;当视图完成初始化和建立与 State 修饰状态的绑定关系后,_location就不再是 nil,其中保存了众多标记视图关系和位置的信息,这里没有全部展示出来;

再点击一次 Count+1 按钮,count 值变为 2,user 的地址将持续保持不变,生命周期与视图保持一致。

通过前面的分析,已经明确内部_user变量的存在,下面进一步分析 State 内部实现中 wrappedValue 和 projectedValue 的关系

(lldb) p _user(State<DemoState.User>) $R6 = {  _value = (name = "", count = 2)  _location = 0x0000600003c26a80 {    SwiftUI.AnyLocationBase = {}  }}
(lldb) p _user.wrappedValue(DemoState.User) $R8 = (name = "", count = 2)
(lldb) p _user.projectedValue(Binding<DemoState.User>) $R10 = { transaction = { plist = { elements = nil } } location = 0x0000600003c26a80 { SwiftUI.AnyLocationBase = {} } _value = (name = "", count = 2)}
复制代码

SwiftUI 把@State var user = User()转换成三个属性

private var _user: State<User> = State(initialValue: User())private var $user: Binding<User> { return _user.projectedValue }private var user: User {    get { return _user.wrappedValue }    nonmutating set { _user.wrappedValue = newValue }}
复制代码

为什么 $user 是只读的?测试一下会发现修改失败

(lldb) expr $user = User(name:"",count:100)error: <EXPR>:3:1: error: cannot assign to property: '$user' is immutable$user = User(name:"",count:100)^~~~~error: <EXPR>:3:9: error: cannot assign value of type 'User' to type 'Binding<User>'$user = User(name:"",count:100)^~~~~~~~~~~~~~~~~~~~~~~(lldb) expr $user.name = "Tim"error: <EXPR>:3:7: error: cannot assign to property: '$user' is immutable$user.name = "Tim"~~~~~ ^error: <EXPR>:3:14: error: cannot assign value of type 'String' to type 'Binding<String>'$user.name = "Tim"^~~~~
复制代码

说明 projectedValue 只读属性,这跟 State 的定义中 projectedValue 的定义public var projectedValue: Binding<Value> { get }是一致的

通过上面分析可以画出一张 State 内部实现属性的关系

_user:State<User>	_value:User		_name:String		_count:Int	_wrappedValue:User 		get { _value }		set { _value = newValue }	_projectedValue:User 		get { _value }
复制代码

我们进一步可以大致写出 State 的部分可能实现逻辑

@propertyWrapper struct State<T> {    var _value:T        init(wrappedValue: T) {        _value = wrappedValue    }
var wrappedValue: T { nonmutating set { _value = newValue } get { _value.value } }
var projectedValue: T { _value }}
复制代码

总结

  • @State 属性包装器针对值类型进行状态管理,用于在 Struct 中 mutable 值类型,它的所有相关操作和状态改变和当前 View 生命周期保持一致

  • Binding 将存储在别处的值语意的属性转换为引用语义,在使用时需要在变量名加 $符号

  • 添加了 property wrapper 的属性,变量本身并没有变化,而是修改了由 SwiftUI 维护的当前 struct 之外的变量

参考

  • https://forums.swift.org/t/why-i-can-mutate-state-var-how-does-state-property-wrapper-work-inside/27209

  • https://medium.com/@kateinoigakukun/inside-swiftui-how-state-implemented-92a51c0cb5f6

  • https://kateinoigakukun.hatenablog.com/entry/2019/03/22/184356


发布于: 2021 年 02 月 28 日阅读数: 9
用户头像

kingnight_pig

关注

mobile developer focus in Swift,SwiftUI 2017.10.23 加入

iOS 开发者,技术团队管理者

评论

发布
暂无评论
SwiftUI数据流之State&Binding