SwiftUI中的Data Flow(一)
SwiftUI提供了一个声明式的框架让我们来构建界面,其核心的View只是状态的一个计算属性,而不是UIKit中所真实表示的界面元素。在我们使用View构建界面的时候,还需要指明视图之间的数据依赖关系。这在往常会使用属性来设置依赖,进一步使用响应式框架(RxSwift等)的时候会使用特殊的操作来完成依赖、绑定。在swiftUI中,由于View只是状态的计算属性,而状态是以数据的形式进行存储,所以就需要有个唯一的source of truth
来保证各个View与数据的状态保持同步。
Views are a function of state
在不同的使用场景下,source of truth可以是@State
、@Binding
,也可以是@ObservedObject
、@EnvironmentObject
,甚至是@StateObject
。他们功能各不相同,但是他们所运用的核心技术是一样的:Property Wrappers
、Dynamic Member Lookup
等等。本文在此基础上,先来探讨一些非swiftUI框架下开发遇到的问题,以及使用这些核心技术来解决他们,然后逐步引出swiftUI中要如何设计数据流,为后续章节做铺垫。
Property Wrappers
在开发中,不论是调试还是做业务,都会遇到需要观察某一个类(Person)中属性(name)改变的需求,一般的做法是重写set方法,当然也有重写get方法的。比如下面通过一个私有属性_name
以及重写set方法
来输出对应的log:
1 | struct Person { |
这样在外部使用的时候,只要name有所变动就会有对应的log输出:
1 | var person = Person() |
这样可以很方便的实现我们的需求:打印、发通知、调用方法等等。但是这样会有一个弊端:一个属性还好,如果有很多属性都需要被观察呢?软件编程中的一个观点是:不要重复的做一件事两次。
这个时候就要对一操作进行抽象封装。
思考一下,_name这个属性本身的作用是对name的wrap
,对Person设置name属性的时候其实是设置了_name,因此可以将这个_name进行拓展一下。比如抽象成一个ConsoleLogged
的类型,它接受任意要wrapped的值:
1 | struct ConsoleLogged<Value> { |
接下来,修改Person,使用这个ConsoleLogged类型来wrap我们的_name属性:
1 | struct Person { |
这时候再测试一下最新的Person类型:
1 | var person = Person() |
目前看来,还不错。
不过,虽然我们封装出了一个具有wrapper功能的ConsoleLogged,但如果属性过多,还是需要编写很多的模板代码。
从iOS13开始,swift为我们提供了一个新特性:Property Wrappers。这里修改一下我们的ConsoleLogged,使用@propertyWrapper
来标记一下,这样它就具备了Property Wrappers
所提供的功能了。接下来修改我们的Person,删除内部所有代码,只保留一个属性:
1 | @propertyWrapper |
这样,在Person中就没有了样板代码,并且测试也是可以正常打印log:
1 | var rocky = Person() |
其实@ConsoleLogged
就是一个语法糖,它等价于我们之前在Person中编写的对name的set、get方法的监听,以及一个私有的_name属性这些代码:
1 | @ConsoleLogged var name = "default" |
在这里,我们使用的是一个struct来作为Property Wrappers
,如果现在我们为这个结构体添加一个方法,我们应该如何访问它呢?
1 | @propertyWrapper |
我们试着编写如下代码会发现编译报错,这是因为我们目前为止,只是通过ConsoleLogged提供了一个被wrapped的数据,并没有提供ConsoleLogged类型本身
的数据:
1 | var person = Person() |
我们需要为ConsoleLogged提供一个projectedValue
属性,通过这个属性我们可以获取到ConsoleLogged实例(当然,并不是规定一定要返回当前类型的实例,也可以是其他类型的实例),从而执行它相关的方法:
1 | @propertyWrapper |
这个时候我们就可以调用oop方法了,只不过需要使用$name
:
1 | var person = Person() |
$name
其实也是个语法糖,编译器会自动我们提供一个$name
的属性:
1 | @ConsoleLogged var name = "default" |
通过以上我们会发现,使用@propertyWrapper标记的类型(struct、class、enum)提供备了很强的钩子、甚至转化特性,可以让我们在这些地方做一些特殊的逻辑,比如这里的@ConsoleLogged的log输出,之后的@State、@Binding对数据和View之间的双向绑定等等。
以上只是我们在接下来会用到的关于
Property Wrappers
的一些知识点,它还有很多其他的特性,感兴趣的可以查看SE:0258。
Publisher
在使用UIKit编写app的时候,假如我们有一个数据类型(ModelY),并且有一个视图(ModelYView)会根据它来展示具体的内容,我们希望在这个模型有变动的时候(不论任何属性的变动),ModelYView都会同步的渲染出来。
1 | class ModelY { |
通常如上的处理并不会生效,结果只是modelY属性有所改动,但是ModelYView并没有将更新体现出来。如果在modelY更新的时候,也同步的更新依赖modelY的控件,比如:
1 | func modifColor() { |
这样虽然可以达到目的,但其实并没有达到一种绑定的效果
。另外,如果modelY中有更多的属性变动会分别引起不同的控件的展示,就会将逻辑散落在各处。比如:
1 | override init(frame: CGRect) { |
我们可以提供一个updateUI函数统一处理model与view之间的数据依赖。
1 | func updateUI() { |
我们还可以在ModelY中统一处理,使用willSet
方法监听所有属性的变动,然后将更新以代理、通知或者block的方式通知ModelYView。这些方法本质上和我们上面的方案是一样的,只不过一个是在分发侧(ModelY)统一处理,一个是在接收侧(ModelYView)统一处理,两者并没有形成一个绑定的依赖关系。
模型中的数据会被拷贝一份到视图上,这样在模型的数据有改动的时候,还需要
手动的
再次对新数据进行拷贝以使用,同样一份数据,却会存在多处拷贝。
如果要完成model与view之间的响应式依赖,我们需要一个响应式框架,使用这个框架来完成两者之间的绑定。从iOS13开始,苹果为我们提供了一个官方的响应式框架:Combine
。这里我们使用其中的ObservableObject
协议,使得我们的分发侧拥有一个Publisher,从而可以在属性有所变动的时候将最新的状态分发出去,这样接收侧就可以根据变动进行对应的界面更新。
1 | public protocol ObservableObject : AnyObject { |
首先,让我们的ModelY遵守ObservableObject
协议,这样它就会拥有一个ObjectWillChangePublisher
类型的objectWillChange
,使用这个Publisher,我们可以在属性有变动的时候调用它的send
进行分发:
1 | class ModelY: ObservableObject { |
那么到现在我们已经有了一个分发侧的Publisher
,接下来就需要在接收侧进行订阅。在这里,我们使用sink
(也可以使用assign直接作用在modelYDesLabel上)来进行订阅,并且在收到消息之后与之前一样,调用updateUI
使用最新的modelY更新界面。同时需要去掉在两个target-action方法中对updateUI的调用:
1 | class ModelYView: UIView { |
到目前为止,我们已经完成了ModelY发生更新,ModelYView根据最新内容展示界面的任务。不过,使用下来会发现有以下一些问题:
- 对属性的willSet方法监听太过于模板化
- 接收侧对于事件的监听太不响应式
- model与view之间并没有形成双向绑定
针对第一个问题,结合上面的Property Wrappers
,我们可以使用@Published
来优化:使用@Published来标记属性,就省去了过于模板化的分发操作。
Published这个Property Wrappers
提供的projectedValue是一个Publisher,也就是说通过Published标记的属性也可以分发。
1 | @propertyWrapper |
我们为ModelY添加一个使用Published标记的host属性:
1 | class ModelY: ObservableObject { |
到目前为止,ModelY有两个Publisher,一个是自身具备的objectWillChange
,一个是@Published修饰的$host
。在ModelYView中为这个$host
添加一个订阅者,以及新增一个修改host属性的按钮。通过测试会发现,两个Publisher都能如期分发数据,前者会在任意属性变动的时候分发,后者只会在host属性发生变动的时候分发,这符合我们的预期。如果我们只是关心model发生变动,不关心具体变动的属性,那么这里的$host多少显得有些多余和重复,但如果我们要特殊的对该属性做处理,它就显得很合适了。
1 | class ModelYView: UIView { |
另一方面,我们也不用过多的担心,一个模型中有n多个@Published修饰的属性会创建巨多的Publisher从而导致内存暴涨,这样的情况。在Combine中,如果一个Publisher不被订阅,那么它就不会被创建,从下面的内存分布中也可以看出来这个结论,所以无需担心模型中n多个的@Published。
使用了@Published标记了属性省去了很多分发侧的样板代码,但是还不够优雅,我们还需要在接收侧中去主动监听Publisher。回想一下前面提到的Property Wrappers
,通过它可以统一的处理一些样板逻辑,而这里的为Publisher添加订阅-更新就是一个样板逻辑。
比如提供一个叫做ObservedObject
的Property Wrappers
来标记我们的modelY,从而就可以减少对Publisher的添加监听、更新等逻辑,这也是系统在swiftUI中提供的ObservedObject
。
但是目前还遗留一个问题:实现model与view之间的双向绑定,这个就要涉及到swiftUI中的Binding、State等Property Wrappers
。通过这些特性,SwiftUI消除了为了在屏幕上显示数据而需要复制数据的需求,能够将数据存储从UI中剥离出来,可以在模型的单个位置有效地管理数据,而且不会让应用的用户在屏幕上看到任何旧状态。
DynamicMemberLookup
在OC中我们借助于runtime访问一个对象不存在的属性,经过一系列的查找、转发之后,系统会给我们一个崩溃。而在swift中作为一个安全的类型语言,我们编译都会失败,根本运行不起来。如果真的有需求要动态的访问对象的某些不存在的属性,我们需要使用swift4.2为我们提供的@dynamicMemberLookup
,使用它标记的类型将拥有动态调用的特性,只需要实现一个方法:
1 | subscript(dynamicMember member: Any) -> Any |
比如有一个UserData,使用@dynamicMemberLookup来标记它,并且实现subscript(dynamicMember)
方法,根据动态的属性在json中获取对应的数据:
1 | @dynamicMemberLookup |
因此我们可以编写如下的代码,并且也不会编译报错,这样就为UserData这个类型添加了动态访问的特性。当然了,我们还可以重载subscript(dynamicMember)
方法,设置不同类型的返回值,这在编译器来看是允许的。
1 | let userData = UserData(json: [ |
通过@dynamicMemberLookup,我们可以为自定义的模型增加动态性,可以根据内置数据进行动态映射,还可以使用另一个类型来映射。在SE:0252这个提案中,增加了以KeyPath为参数的动态特性,突破了以String为访问对象的限制,这在以KVC的基础上实现动态性更加便捷,这个特性与PropertyWrapper结合可以发挥出很大的作用,将在下面示例中进行体现。
下面我们尝试根据已有的特性,实现swiftUI中的@State、@Binding。
首先,我们提供一个动态类(MyBinding),使得它可以处理以KeyPath为参数的动态属性,这里为了拓展我们使用setter
、getter
两个闭包来将设置属性以及获取属性与dynamicMemberLookup结合,这样我们就可以根据KeyPath进行链式的获取数据。
1 | @propertyWrapper |
同时还使用@propertyWrapper来标记了MyBinding,通过上一小节我们知道,使用@MyBinding
标记的属性在发生变化的时候,会调用MyBinding的wrappedValue
这个计算属性,而这个计算属性是根据getter、setter来实现读写的,这两个block的实现是在初始化的时候确定的。
我们简单的测试一下它,分别使用Int以及具有数据层级的struct来。可以发现,使用@MyBinding标记的属性能够与外部的数据进行绑定,达到共用同一份数据的目的,也就是我们说的双向绑定
。
1 | private var counter: Int = 10 |
但是,我们创建MyBinding的时候太过于麻烦,上面测试中绑定的本质是被标记的数据依赖于外部的数据变化,所以很容易想到前面的Property Wrapper。接着再创建一个MyState,并且将它的projectedValue返回为MyBinding,这样被MyState标记的数据在发生变化的时候就会和MyBinding标记的数据产生双向绑定。
1 | @propertyWrapper |
现在我们有了两个工具,接着将他们放入一个应用场景中:一个视图控制器(MyDetailViewController)中使用@MyState标记的Rectangle数据会在子视图(MyTopView)被修改,并且在视图控制器中也会修改它的值。
1 | struct Point { var x, y: Double } |
不论是在视图控制器中修改数据,还是在子视图中修改数据,根据我们设计的MyBinding和MyState来看,最终会进入到初始化MyBinding的setter回调
中,以及MyState对wrappedValue的set方法
中。到这里,我们已经无法进行下去了,由于MyState是一个不可变类型的,我们会得到一个'self' is immutable
的编译错误。
我们知道
struct是值类型的
,这就意味着,在多个地方传递的时候数据会被复制,如果我们改变了其中一个值,也只是改变了这个副本,并不会改变原始值,甚至其他副本。虽然可以使用mutating
、var
将struct由不可变的(immutable)变为可变的,但是值类型还是建议保持不可变
,这点在swiftUI中很重要。
如果我们将MyState由Struct替换为Class,那么问题就可以解决了;或者在MyState内部使用一个Class的私有类(比如叫做Storage)来存储被wrap的数据,我们只在有疑问的两个地方修改这个Storage的value也可以解决编译错误。不过很显然,系统并不是按照第一种来做的,那么就有可能是根据第二种方案来做的,具体原理官方也没有文档说明,目前这些都只是猜测。
1 | // 1️⃣ |
但是在前面一小节我们还遗留了一个问题,那就是我们需要显示的去订阅Publisher,并且让依赖数据的控件在订阅回调中更新数据。由于这两个方法的实现都是在MyState中,所以我们可以在这里将数据的变动通知给外部,比如使用前面提到的ObservableObject。奇幻的地方就在这里,系统在这一步做了一些我们不知道的处理,然后将这种处理转交给SwiftUI进行更新界面,这也是func update()
一直未提及的原因。在UIKit的示例中,本质上还是由于我们的界面并不是以数据驱动的,界面和数据之间只是依赖的关系,因此数据变动之后需要手动的更新界面。
What is the next
以上作为引子讨论了如何使用Property Wrappers
来统一的完成更新下发,使用@Published
、ObservableObject
来完成model与view之间的绑定,以及尝试重新实现Binding
、State
来探究双向绑定
。并且在一步步的探索过程中发现,绑定效果并不是很理想,想要达到Vuex那样的双向绑定还有很多事情要做。
接下来我们会系统的学习swiftUI中的@State
、@Binding
、@ObservedObject
、@StateObject
、@EnvironmentObject
等这些Property Wrappers,从他们的使用场景以及官方建议来探讨在如何项目中合理的使用这些技术。