Swift 一直以来有个非常方便的特性:属性观察者(Property Observer),即属性上的willSet
和 didSet
函数。在 Swift 5.3 中,对 didSet
有一处小的性能优化,在了解这个之前,我们来仔细复习一下 didSet
,有一些细节你不一定知道或者记得。
存储属性的观察者
最常见的使用场景是给正在定义的类型中的存储属性添加观察者,如下例所示,willSet
在属性值被更新之前被调用,didSet
在属性值更新后被调用。
class Container {
var items = [Int](repeating: 1, count: 100) {
willSet {
print("willSet is called")
print("current item size: \(items.count)")
print("new item size: \(newValue.count)")
}
didSet {
print("didSet is called")
print("current item size: \(items.count)")
print("old item size: \(oldValue.count)")
}
}
}
let container = Container()
container.items = [] // willSet 和 didSet 被调用
在willSet
和 didSet
中可以访问属性本身,并且如果不指定名称的话,willSet
可以通过 newValue
访问即将被设置的值,didSet
可以通过oldValue
访问此次设置之前的属性值。
这里需要注意的是,哪怕设置的值与原来的值相同,willSet
和 didSet
都是会被调用的。
有一种情况下属性观察者不会被调用:当前类型的init
函数中。假如有以下init
函数,在函数中对 items
进行赋值并不会触发willSet
和 didSet
。
class Container {
init() {
items = []
}
}
为什么要制定这一条规则呢?原因在于构造函数本来就是特殊的,假如在构造的时刻触发属性观察者,而属性观察者中又访问了还没被初始化的其它的属性的话,就导致了访问了未完全初始化的对象,Swift 主打安全的初始化就会被破坏。
继承下的属性观察者
在继承情况下的规则有一些不同,但是也好理解。
第一点:在构造函数中,对继承而来的属性设置值会触发父类中的属性观察者的调用。
class MyContainer: Container {
var tag: String
override init() {
tag = "Leon"
super.init()
items = [1,2,3] // 触发父类中的 willSet 和 didSet
}
}
这时候你可能会有个问号:这里的触发难道不会造成访问一个未完全初始化的对象吗?比如说父类的 didSet
中调用的一个方法被子类 override,而子类的这个方法访问了还没被初始化的子类中声明的属性?
访问未被初始化的子类属性的情况不存在,因为在调用 super.init()
前,子类必须完成自身属性的初始化。实际上,上述代码段中的三行初始化的语句是没有办法调换顺序的。Swift 通过强制初始化顺序来确保在复杂情况下构造函数还是安全的,避免了一些老牌的面向对象语言中存在的问题。
第二点:可以给继承的属性添加属性观察者,哪怕继承的是计算属性:
class MyContainer: Container {
override var items: [Int] {
didSet {
print("didSet is called in the subclass")
}
}
}
无论父类中的属性是否有属性观察者,作为子类我只添加不替换。属性观察者会按照先父类(若有)再子类的顺序来执行。另外,对于继承的计算属性我们也可以添加属性观察者,因为对于子类来说,给继承的属性添加属性观察者,无需区分它到底是存储属性还是计算属性,对子类来说它就是属性:一对 getter 和 setter 函数。
话说回来,为什么不能为自己声明的计算属性添加 willSet
和didSet
呢?那是因为你是个成熟的计算属性了,set
本来就是你自身定义的了,你在set
的函数开头写上 willSet
的逻辑、在结束写上 didSet
就起到属性观察者的同样作用了。
属性类型:值与引用
如果属性是一个值类型,调用它的 mutating
方法或者直接修改它的值的话会从内到外逐层调用属性观察者。
下面这个例子中,会先调用 items
的 didSet
,再调用 container
的didSet
,注意这个例子中的 Container
已经改成值类型了。
struct Container {
var items = [Int](repeating: 1, count: 100) {
didSet {
print("items didSet is called")
}
}
}
class ViewController: UIViewController {
var container = Container() {
didSet {
print("container didSet is called")
}
}
override func viewDidLoad() {
super.viewDidLoad()
container.items.append(1)
}
}
假如 Container
是个引用类型,那么只有 items
的 didSet
会被调用。引用类型作为属性,只有在该引用被替换的时候才会触发属性观察者。
inout 例行公事
当参数是由 inout 修饰的时候,我们需要知道在函数退出之前,无论有没有修改,属性都会被写回,属性观察者会被调用,这是由 Swift 内存模型所规定的。
struct Container {
var items = [Int](repeating: 1, count: 100) {
willSet {
print("item willSet is called")
}
didSet {
print("item didSet is called")
}
}
}
func modify(items: inout [Int]) {
print("actually do nothing")
}
var container = Container()
modify(items:&container.items)
这个例子中,首先会打印的是 actually do nothing
,然后是 willSet
和 didSet
。
Swift 5.3 didSet的性能优化
在 Swift 5.3 中,提供了一个简单版本的 didSet
:如果 oldValue
没有被用到(如上例),Swift 5.3 会直接跳过 oldValue
的创建,这意味着节省了 内存 和 CPU 的开销。
这个改动有很小的可能性会影响到代码兼容性,比如说代码的正确性依赖于属性 的 getter
被调用。要恢复这个行为可以显示地声明变量名字:
didSet(oldValue) {}
或者这样引用一下:
didSet { _ = oldValue }
我们可以使用计算属性来证明一下这件事情:
class Container {
var items :[Int] {
get {
print("getter is called")
return []
}
set {}
}
}
class MyContainer: Container {
override var items: [Int] {
didSet {
print("didSet is called")
}
}
}
let container = MyContainer()
container.items = []
在 Swift 5.3 中,getter
不会被调用,oldValue
不需要生成,性能得到了提升。
应用和提示
属性观察者是个非常实用的工具,除了可以日常进行 debug 或者打日志,还可以用来实现一些简单逻辑,比如说 ViewController
中有一个 person
属性,当它被更新的时候,我可以在 didSet
调用更新界面的逻辑。
另外,我们可以在自己声明的属性的didSet
中安全地重新给这个属性设置一个新的值,这不会触发didSet
的无限循环调用。
你一定觉得这很棒啊,有什么需要注意的吗?于是开始浪,在继承属性的didSet
中也对本属性赋值了新的值,那恭喜你无限循环崩溃了。
你想了下,这怎么可能是我,那还有一种情况可能适合你,在 A 属性的 didSet
中更新 B 属性的值,并在 B 属性的 didSet
中更新 A 属性的值。你是风儿我是沙,缠缠绵绵栈爆炸。
也许这种错误还不是你这种级别的高手会犯的,但一旦代码分支复杂了,联动效果多了,不是谁能一眼看出来了,说不定哪天就变成了蝴蝶效应。无节制的使用 didSet 的联动是明显的代码坏味道,尽管可以通过判等跳过来打破这个循环,临时解决这个问题,但是不让代码往这个方向腐化是每个代码维护者需要关心的事情。
扫码下方二维码关注“面试官小健”