Swift 5.3 新特性精讲(3):属性观察者以及didSet的性能优化

6,939 阅读6分钟

Swift 一直以来有个非常方便的特性:属性观察者(Property Observer),即属性上的willSetdidSet 函数。在 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 被调用

willSetdidSet 中可以访问属性本身,并且如果不指定名称的话,willSet 可以通过 newValue访问即将被设置的值,didSet可以通过oldValue访问此次设置之前的属性值。

这里需要注意的是,哪怕设置的值与原来的值相同,willSetdidSet都是会被调用的。

有一种情况下属性观察者不会被调用:当前类型的init函数中。假如有以下init函数,在函数中对 items 进行赋值并不会触发willSetdidSet

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 函数。

话说回来,为什么不能为自己声明的计算属性添加 willSetdidSet 呢?那是因为你是个成熟的计算属性了,set本来就是你自身定义的了,你在set 的函数开头写上 willSet 的逻辑、在结束写上 didSet 就起到属性观察者的同样作用了。

属性类型:值与引用

如果属性是一个值类型,调用它的 mutating方法或者直接修改它的值的话会从内到外逐层调用属性观察者。

下面这个例子中,会先调用 itemsdidSet,再调用 containerdidSet,注意这个例子中的 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 是个引用类型,那么只有 itemsdidSet会被调用。引用类型作为属性,只有在该引用被替换的时候才会触发属性观察者。

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,然后是 willSetdidSet

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 的联动是明显的代码坏味道,尽管可以通过判等跳过来打破这个循环,临时解决这个问题,但是不让代码往这个方向腐化是每个代码维护者需要关心的事情。

扫码下方二维码关注“面试官小健”