主页 Swift 中使用 Option Pattern 改善可选项的 API 设计
Post
Cancel

Swift 中使用 Option Pattern 改善可选项的 API 设计

SwiftUI 中提供了很多“新颖”的 API 设计思路和 Swift 的使用方式,我们可以进行借鉴,并反过来使用到普通的 Swift 代码中。PreferenceKey 的处理方式就是其中之一:它通过 protocol 的方式,为子 view 们提供了一套模式,让它们能将自定义值以类型安全的方式,向上传到父 view 去。如果有机会,我会再专门介绍 PreferenceKey,但这种设计的模式其实和 UI 无关,在一般的 Swift 里,我们也能使用这种方法来改善 API 设计。

在这篇文章里,我们就来看看要如何做。文中相关的代码可以在这里找到。你可以将这些代码复制到 Playground 中执行并查看结果。

红绿灯

用一个交通信号灯作为例子。

作为 Model 类型的 TrafficLight 类型定义了 .stop.proceed.caution 三种 State,它们分别代表停止、通行和注意三种状态 (当然,通俗来说就是“红绿黄”,但是 Model 不应该和颜色,也就是 View 层级相关)。它还持有一个 state 来表示当前的状态,并在设置时将这个状态通过 onStateChanged 发送出去:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class TrafficLight {

    public enum State {
        case stop
        case proceed
        case caution
    }

    public private(set) var state: State = .stop {
        didSet { onStateChanged?(state) }
    }
    
    public var onStateChanged: ((State) -> Void)?
}

其余部分的逻辑和本次主题无关,不过它们也比较简单。如果你有兴趣的话,可以点开下面的详情查看。但这不影响本文的理解。

TrafficLight 的其他部分

为了能让信号灯进行状态转换,我们可以在 TrafficLight 里定义各个阶段的时间:

1
2
3
public var stopDuration = 4.0
public var proceedDuration = 6.0
public var cautionDuration = 1.5

然后用一个 Timer 计时,并进行控制状态的转换:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
private var timer: Timer?

private func turnState(_ state: State) {
    switch state {
    case .proceed:
        timer = Timer.scheduledTimer(withTimeInterval: proceedDuration, repeats: false) { _ in
            self.turnState(.caution)
        }
    case .caution:
        timer = Timer.scheduledTimer(withTimeInterval: cautionDuration, repeats: false) { _ in
            self.turnState(.stop)
        }
    case .stop:
        timer = Timer.scheduledTimer(withTimeInterval: stopDuration, repeats: false) { _ in
            self.turnState(.proceed)
        }
    }
    self.state = state
}

最后,向外提供开启和结束的方法就可以了:

1
2
3
4
5
6
7
8
9
public func start() {
    guard timer == nil else { return }
    turnState(.stop)
}

public func stop() {
    timer?.invalidate()
    timer = nil
}

在 (ViewController 中) 使用这个红绿灯也很简单。我们按照红绿黄的颜色,在 onStateChanged 中设定 view 的颜色:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
light = TrafficLight()
light.onStateChanged = { [weak self] state in
    guard let self = self else { return }
    let color: UIColor
    switch state {
    case .proceed: color = .green
    case .caution: color = .yellow
    case .stop: color = .red
    }
    UIView.animate(withDuration: 0.25) {
        self.view.backgroundColor = color
    }
}
light.start()

这样,View 的颜色就可以随着 TrafficLight 的变化而变更了:

青色信号

世界很大,有些地方 (比如日本) 会使用倾向于青色,或者实际上应该是绿松色 (turquoise),来表示“可以通行”。有时候这也是技术的限制或者进步所带来的结果。

The green light was traditionally green in colour (hence its name) though modern LED green lights are turquoise.

– Wikipedia 中关于 Traffic light 的记述

假设我们想要让 TrafficLight 支持青色的绿灯,一个能想到的最简单的方式,就是在 TrafficLight 里为“绿灯颜色”提供一个选项:

1
2
3
4
5
6
7
8
9
public class TrafficLight {
    public enum GreenLightColor {
        case green
        case turquoise
    }
    public var preferredGreenLightColor: GreenLightColor = .green
    
    //...
}

然后在 ViewController 中使用对应的颜色:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
extension TrafficLight.GreenLightColor {
    var color: UIColor {
        switch self {
        case .green: 
            return .green
        case .turquoise: 
            return UIColor(red: 0.25, green: 0.88, blue: 0.82, alpha: 1.00)
        }
    }
}

light.preferredGreenLightColor = .turquoise
light.onStateChanged = { [weak self, weak light] state in
    guard let self = self, let light = light else { return }
    // ...
    
    // case .proceed: color = .green
    case .proceed: color = light.preferredGreenLightColor.color
}

这样做当然能够解决问题,但是也会带来一些隐患。首先,需要在 TrafficLight 中添加一个额外的存储属性 preferredGreenLightColor,这使得 TrafficLight 示例所使用的内存开销增加了。在上例中,额外的 GreenLightColor 属性将会为每个实例带来 8 byte 的开销。 如果我们需要同时处理很多 TrafficLight 实例,而其中只有很少数需要 .turquoise 的话,这个开销就非常可惜了。

严格来说,上例的 TrafficLight.GreenLightColor 枚举其实只需要占用 1 byte。但是 64-bit 系统中在内存分配中的最小单位是 8 bytes。

如果想要添加的属性不是像例子中这样简单的 enum,而是更加复杂的带有多个属性的类型的话,这一开销会更大。

另外,如果我们还要添加其他属性,很容易想到的方法是继续在 TrafficLight 上加入更多的存储属性。这其实是很没有扩展性的方法,我们并不能在 extension 中添加存储属性:

1
2
3
4
5
6
7
// 无法编译
extension TrafficLight {
    enum A {
        case a
    }
    var myOption: A = .a // Extensions must not contain stored properties
}

需要修改 TrafficLight 的源码,才能添加这个选项,而且还需要为添加的属性设置合适的初始值,或者提供额外的 init 方法。如果我们不能直接修改 TrafficLight 的源码 (比如这个类型是别人的代码,或者是被封装到 framework 里的),那么像这样的添加选项的方式其实是无法实现的。

Option Pattern

可以用 Option Pattern 来解决这个问题。在 TrafficLight 中,我们不去提供专用的 preferredGreenLightColor,而是定义一个泛用的 options 字典,来将需要的选项值放到里面。为了限定能放进字典中的值,新建一个 TrafficLightOption 协议:

1
2
3
4
5
6
public protocol TrafficLightOption {
    associatedtype Value

    /// 默认的选项值
    static var defaultValue: Value { get }
}

TrafficLight 中,加入下面的 options 属性和下标方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class TrafficLight {

    // ...

    // 1
    private var options = [ObjectIdentifier: Any]()

    public subscript<T: TrafficLightOption>(option type: T.Type) -> T.Value {
        get {
            // 2
            options[ObjectIdentifier(type)] as? T.Value
                ?? type.defaultValue
        }
        set {
            options[ObjectIdentifier(type)] = newValue
        }
    }
    
    // ...    
}
  1. 只有满足 Hashable 的类型,才能作为 options 字典的 key。ObjectIdentifier 通过给定的类型或者是 class 实例,可以生成一个唯一代表该类型和实例的值。它非常适合用来当作 options 的 key。
  2. 通过 key 在 options 中寻找设置的值。如果没有找到的话,返回默认值 type.defaultValue

现在,对 TrafficLight.GreenLightColor 进行扩展,让它满足 TrafficLightOption。如果 TrafficLight 已经被打包成 framework,我们甚至可以把这部分代码从 TrafficLight 所在的 target 中拿出来:

1
2
3
4
5
6
7
8
extension TrafficLight {
    public enum GreenLightColor: TrafficLightOption {
        case green
        case turquoise

        public static let defaultValue: GreenLightColor = .green
    }
}

我们将 defaultValue 声明为了 GreenLightColor 类型,这样TrafficLightOption.Value 的类型也将被编译器推断为 GreenLightColor

最后,为这个选项提供 setter 和 getter:

1
2
3
4
5
6
extension TrafficLight {
    public var preferredGreenLightColor: TrafficLight.GreenLightColor {
        get { self[option: GreenLightColor.self] }
        set { self[option: GreenLightColor.self] = newValue }
    }
}

现在,你可以像之前那样,通过直接在 light 上设置 preferredGreenLightColor 来使用这个选项,而且它已经不是 TrafficLight 的存储属性了。只要不进行设置,它便不会带来额外的开销。

1
light.preferredGreenLightColor = .turquoise

有了 TrafficLightOption,现在想要为 TrafficLight 添加选项时,就不需要对类型本身的代码进行改动了,我们只需要声明一个满足 TrafficLightOption 的新类型,然后为它实现合适的计算属性就可以了。这大幅增加了原来类型的可扩展性。

总结

Option Pattern 是一种受到 SwiftUI 的启发的模式,它帮助我们在不添加存储属性的前提下,提供了一种向已有类型中以类型安全的方式添加“存储”的手段。

这种模式非常适合从外界对已有的类型进行功能上的添加,或者是自下而上地对类型的使用方式进行改造。这项技术可以对 Swift 开发和 API 设计的更新产生一定有益的影响。反过来,了解这种模式,相信对于理解 SwiftUI 中的很多概念,比如 PreferenceKeyalignmentGuide 等,也会有所助益。

该博客文章由作者通过 CC BY 4.0 进行授权。

Package.swift toolchain 版本的选择

使用 Property Wrapper 为 Codable 解码设定默认值