SwiftUI 数据流之 State&Binding
在 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 值类型
对于 @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 关键字来修改结构方法的属性吗?
这是因为如果我们创建了作为变量的结构体属性,但结构体本身是常量,我们不能更改属性;当属性发生变化时,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 属性包装器
这个例子展示了一个有过滤开关的列表,为了简化内容说明核心问题,只有两行内容,父视图是 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),所以列表中展示的内容会不断根据条件进行过滤
可变和不可变
观察下面示例
flag 是标记为 State 的变量,anotherFlag 是没有使用属性包装器的普通变量,同时增加了一个 mutating 的方法changeAnotherFlag
被设计修改 anotherFlag;
在 body 中通过几种方式对两个变量进行修改,注释 1-3 处,分别标记了修改结果和提示错误,显然 flag 可以被修改,而 anotherFlag 不可以,这是为什么?
这里涉及两个问题:
为什么可以修改 flag?
为什么不可以修改 anotherFlag?
先来看第二个问题
为什么不可以修改 anotherFlag
计算属性 getter 方法
示例 5
注意对比两个属性:_anotherFlag 是存储属性,anotherFlag 是计算属性;
计算属性的 getter 方法,默认是 nonmutating,是不能被修改的,所以报错
但是,可以有例外,如果 getter 被特殊标记为 mutating,就可以被修改
修改如下:
并且还需要使用 SimpleStruct 时,声明实例为 var
既然可以通过添加 mutating,使得计算属性 get 中可以修改 self,那么 SwiftUI 中前面示例的 body 属性可否添加呢?
查看 View 协议的定义
body 修饰符是 get,不能被改为 mutating get,所以如果你改为这样下面
会报错,提示没有遵守 View 协议
小结:不可以修改 anotherFlag,即不可以修改 SwiftUI 中 Struct 内的普通变量,其内在的原因是:body 计算属性的 getter 方法不可以被修改为 mutating
为什么可以修改 flag
由于 SwiftUI 设计之初就是希望构建的 View 树保持不变,这样才能高效的渲染 UI,跟踪变化,当标记为 @State 的变量发生变化时,变量本身由于在 Struct 中不能发生变化,所以通过 State 为例的 property wrapper 本质是修改当前 struct 之外的变量
我们看一下 State 的定义
wrappedValue 就是被标记为 nonmutating set,直接使用 state 对象是用的 wrappedValue,$符号使用的 projectedValue
nonmutating 有什么含义?
计算属性 setter
在 setter 属性中,self 默认是 mutating,可以被修改;我们不能给一个不可变的量赋值,可以通过声明 setter nonmutating 使属性可赋值,这个 nonmutating 关键字向编译器表明,这个赋值过程不会修改这个 struct 本身,而是修改其他变量。
这个例子当中_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 结构体本身
继续执行到 ContentView 的初始化方法最后一行,此时 self 是 ContentView,打印一下
出现了一个新的_user
变量,类型是State<User>
,这个变量内部属性_value
类型是User
;这意味着,加了 @State 属性包装器的 user 实例变量,由本身的User
类型转变为一个新的State<User>
类型,这个转变完成的新类型实例_user
由 SwiftUI 负责生成和管理,它的内部包裹着真实的 User 实例,另外_location
也值得注意,它目前是 nil;
如果你注意到 35 行代码user = User(name: "TT", count: 100)
发现它并不会改变内部_user
;
如果想要修改,只能采用下面方式,通过 State 提供的第二个初始化方法
与此同时,检查当前 console 的 log 输出
按照预期的执行顺序,User init 执行,ContentView init 执行,然后打印出了当前结构体的地址和_user
内部结构;
下一步,由于 body 执行完毕,页面渲染完整,现在点击 Count+1 按钮,断点停在 47 行,再观察内部变量情况
_user 没有变化,但是_location
不再是 nil
继续执行,运行完成print(address(o: &user))
和 dump(_user)
两个函数,输出结果如下:
仔细对一下以下,user 的地址发生了变化,开始时创建的 user 被销毁又重新创建了,这是因为 @State 修饰的属性的它的所有相关操作和状态改变都应该是和当前视图生命周期保持一致,当视图没有被初始化完成时,无法完成状态属性和视图之间的绑定关系;当视图完成初始化和建立与 State 修饰状态的绑定关系后,_location
就不再是 nil,其中保存了众多标记视图关系和位置的信息,这里没有全部展示出来;
再点击一次 Count+1 按钮,count 值变为 2,user 的地址将持续保持不变,生命周期与视图保持一致。
通过前面的分析,已经明确内部_user
变量的存在,下面进一步分析 State 内部实现中 wrappedValue 和 projectedValue 的关系
SwiftUI 把@State var user = User()
转换成三个属性
为什么 $user 是只读的?测试一下会发现修改失败
说明 projectedValue 只读属性,这跟 State 的定义中 projectedValue 的定义public var projectedValue: Binding<Value> { get }
是一致的
通过上面分析可以画出一张 State 内部实现属性的关系
我们进一步可以大致写出 State 的部分可能实现逻辑
总结
@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
版权声明: 本文为 InfoQ 作者【kingnight_pig】的原创文章。
原文链接:【http://xie.infoq.cn/article/ccf75a7e3ef9a952959579873】。文章转载请联系作者。
评论