软件设计

Swift 装饰器模式:无需继承也能层层叠加功能

进行 iOS 开发时,你可能想保留基础功能,只额外加入日志或缓存。但如果为此开始继承类,没过多久继承树就会变得一团糟。

4 分钟阅读
Swift 装饰器模式:无需继承也能层层叠加功能 封面图

进行 iOS 开发时,你可能想保留基础功能,只额外加入日志或缓存。但如果为此开始继承类,没过多久继承树就会变得一团糟。

给网络客户端逐个添加功能时,很容易最终创建出类似 CachingLoggingRetryingClient 这样的类名。

今天就来梳理解决这个问题的 Swift 装饰器模式:无需继承,像洋葱一样将功能层层包裹起来。

装饰器模式使用遵循同一协议的对象包裹原对象,在不改动原始代码的情况下逐层添加功能。

先说结论。

  1. 先通过 协议定义公共行为
  2. 装饰器遵循该协议,并在内部 持有另一个相同协议类型的值
  3. 完成自己的工作,例如日志或缓存,然后将其余工作 委托给内部对象
  4. 按需继续包裹,功能就会不断叠加

什么是装饰器模式?它和继承有什么不同?

继承表示“是一种”关系。子类会继承父类的全部内容。

问题在于组合。如果通过继承创建带日志、带缓存以及两者兼具的客户端,每种组合都会产生一个类。

装饰器表示“被包裹的对象”关系。

由于包裹对象和被包裹对象共享 同一个接口,从外部看起来无法区分。因此无论包裹多少层,调用方代码都无需改变。

简而言之,继承在编译时固定关系,而装饰器可以在运行时自由组合。


用代码实现 Swift 装饰器模式

我们来创建一个用于加载数据的 DataLoader。首先是所有实现都要遵循的协议。

protocol DataLoader {
    func load(id: String) -> Data?
}

// 执行实际工作的基础实现
struct NetworkLoader: DataLoader {
    func load(id: String) -> Data? {
        print("向网络发起 \(id) 请求")
        return Data("payload-\(id)".utf8)
    }
}

到这里为止,只是普通的协议和实现。

现在进入核心部分。装饰器遵循 DataLoader,并在内部持有另一个 DataLoader

// 记录日志的装饰器
struct LoggingLoader: DataLoader {
    let wrapped: DataLoader   // 被包裹的对象

    func load(id: String) -> Data? {
        print("[日志] load 开始: \(id)")
        let result = wrapped.load(id: id)  // 委托实际工作
        print("[日志] load 完成: \(id)")
        return result
    }
}

LoggingLoader只负责自己的工作(记录日志),真正的数据加载则交给 wrapped

借助这种结构,无需关心包裹的具体对象是什么,只要它是 DataLoader 即可。

只处理自身工作、其余工作委托给内部对象的结构
只处理自身工作、其余工作委托给内部对象的结构

逐层叠加功能

这次再创建一个缓存装饰器,实际体验一下多层包裹。

final class CachingLoader: DataLoader {
    let wrapped: DataLoader
    private var cache: [String: Data] = [:]

    init(wrapping loader: DataLoader) { self.wrapped = loader }

    func load(id: String) -> Data? {
        if let hit = cache[id] { return hit }        // 命中缓存时立即返回
        let data = wrapped.load(id: id)              // 未命中时委托处理
        cache[id] = data
        return data
    }
}

组装部分正是装饰器模式的精髓。

let loader = LoggingLoader(
    wrapped: CachingLoader(wrapping: NetworkLoader())
)

从内向外阅读即可。我们先用缓存包裹网络加载器,再用日志包裹它。

调用进入后,流程依次是:日志 → 检查缓存 →(未命中时)访问网络。

包裹多少层,调用就会逐层向内传递
包裹多少层,调用就会逐层向内传递
从内到外依次为网络、缓存、日志的嵌套结构
从内到外依次为网络、缓存、日志的嵌套结构

如果想调整顺序,只需改变包裹顺序。NetworkLoaderCachingLoader 都无需修改一行代码。

不需要的功能,直接移除对应层即可。


继承和装饰器,应该在什么时候使用?

没有哪一个永远正确,下面按场景区分。

场景 推荐方案
明确的“是一种”关系 继承
自由组合、移除功能 装饰器
在运行时启用或停用功能 装饰器
无法修改原始代码时 装饰器
组合情况很多时 装饰器

对于日志、缓存、重试、添加认证请求头等 非核心的附加功能,装饰器尤其合适。

反过来,如果强行使用装饰器,包裹层数会增加,调试时调用栈也会变深。使用时应考虑这一点。


两个常见问题

问:应该使用 struct 还是 class?

如果装饰器需要直接持有状态,例如缓存,使用 class 更方便。单纯进行委托时,struct 就足够了。这就是上例只将缓存设为 class 的原因。

问:包裹层数变多会有性能问题吗?

由于调用会逐层向内传递,确实存在极其微小的开销。但与网络或磁盘 I/O 相比,这在大多数应用中都可以忽略。


装饰器模式归根结底就是定义一个协议,创建一个持有相同协议的对象,再通过它进行委托。

下次为了添加功能而翻找继承树时,不妨想想再包裹一层。代码会轻量得多。建议把今天的示例原样移到 playground 中试试看 🙂

延伸阅读