软件设计

Swift 代理模式(Proxy Pattern):隐藏在 lazy 背后的代理对象

进行 iOS 开发时,经常会使用 lazy var 这一行。

4 分钟阅读
Swift 代理模式(Proxy Pattern):隐藏在 lazy 背后的代理对象 封面图

进行 iOS 开发时,经常会使用 lazy var 这一行。

我也一样。需要延后初始化重量级对象时,我总是习惯性地加上它。

后来我突然好奇:lazy其实不就是设计模式教材中的 Proxy Pattern 吗?

先说结论。

lazy是语言内置版本的虚拟代理(Virtual Proxy),会代替对象创建,直到真正需要该对象时才完成创建。

今天我会按照亲自操作代码、逐步理解的顺序,讲解 Swift 的 Proxy Pattern,以及这个概念如何隐藏在lazy关键字之后。

先快速总结重点。

  1. 代理模式是一种结构型模式,通过在真实对象前放置代理来控制访问。
  2. lazy与延后创建对象的虚拟代理具有相同目的。
  3. 不过,lazy无法处理访问控制或日志记录等功能,只负责延迟创建。
  4. 如果需要日志记录或权限检查,就必须自己创建代理对象。

Swift 的代理模式到底是什么?

Proxy 就是代理人的意思。

在负责实际工作的对象前面,放置一个具有相同接口的代理人。

从外部看起来像是在调用真实对象,但实际上会先经过代理人。

代理人可以直接转发请求,也可以在中间做额外处理,例如检查权限、记录调用,或延后创建真实对象。

典型用途有三种。

  • 虚拟代理:将重量级对象的创建延后到实际使用时
  • 保护代理:阻止未经授权的访问
  • 日志代理:记录调用历史

今天的主角是第一种,也就是虚拟代理。它正好与lazy对应。


隐藏在 lazy 背后的代理对象本质

想象一下处理高分辨率图片的场景。

图片加载很耗费资源。还没显示在屏幕上就提前全部加载,会浪费内存。

于是就有了“真正需要时再加载”的想法。这就是虚拟代理的核心。

先用代理模式直接实现,代码如下。

protocol Image { func display() }

// 代理人:将真实对象的创建延后到需要时
final class ImageProxy: Image {
    private let filename: String
    private var real: RealImage?          // 尚未创建
    init(_ filename: String) { self.filename = filename }
    func display() {
        if real == nil { real = RealImage(filename) }  // 此时创建
        real?.display()
    }
}

只有第一次调用display()时,才会创建RealImage

在此之前,ImageProxy只带着文件名安静等待。这正是代理人的工作。

不过,Swift 已将这个模式融入语法中,就是lazy

代理人代替真实对象被持有的瞬间
代理人代替真实对象被持有的瞬间
final class Gallery {
    // 第一次访问此属性时只创建一次
    lazy var cover: RealImage = RealImage("cover.png")
}

let g = Gallery()   // 尚未 RealImage 创建
g.cover.display()   // 在这里才创建

创建Gallery()时,RealImage还不存在。

第一次访问g.cover时才会创建。也就是说,编译器替你生成了上面ImageProxy所执行的延迟创建。


那么,只要有 lazy,就不需要代理模式了吗?

我一开始的疑问也是这个。

答案是“不是”。

lazy只能代替你完成延迟创建。

它无法阻止访问、记录调用,也无法根据条件返回不同对象。

我把两者的区别整理成了表格。

项目 lazy 属性 手动创建的代理
延迟创建 可以 可以
访问权限检查 不可以 可以
调用日志与缓存 不可以 可以
代码量 一行 一个类
可复用性 仅限该属性 可在多处复用

所以判断标准很简单。

如果纯粹只是“延后创建”,lazy一行代码就是答案,没必要专门创建一个类。

但如果需要检查权限、记录日志,或封装远程调用等中间处理,就必须自己建立代理对象。

需要延后重量级初始化时,我总是先想到这一行
需要延后重量级初始化时,我总是先想到这一行

还有一点需要注意。

lazy不保证线程安全。

如果多个线程同时进行第一次访问,初始化可能会发生两次。在多线程环境中,最好用自定义代理封装这部分并自行处理同步。


常见问题总结

Q. lazy 和计算属性(computed property)有什么区别?

lazy只在第一次计算并保存值。计算属性则会在每次访问时重新计算。如果希望重量级初始化只执行一次,应使用lazy

Q. lazy 为什么只能声明为 var?

因为初始化发生在之后,所以在值确定前,实例会短暂存在。let不允许这种情况,因此lazy let在语法上不可能。

亲自建立代理类,就能立刻理解 lazy 替你完成了什么
亲自建立代理类,就能立刻理解 lazy 替你完成了什么

总的来说,lazy就像一份礼物:语言预先封装了代理模式中的虚拟代理。

一行代码就能完成的事情,不必特意创建代理类;但理解这一行背后的概念后,你看代码的方式会明显不同。

从今天开始,使用lazy var时,不妨想一想:“啊,我现在是在放置一个代理人。”这样会觉得这个模式亲切得多。

延伸阅读