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 发送出去:

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 的其他部分
在 (ViewController 中) 使用这个红绿灯也很简单。我们按照红绿黄的颜色,在 onStateChanged 中设定 view 的颜色:

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 里为“绿灯颜色”提供一个选项:

public class TrafficLight {
    public enum GreenLightColor {
        case green
        case turquoise
    }
    public var preferredGreenLightColor: GreenLightColor = .green
    
    //...
}

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

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 中添加存储属性:

// 无法编译
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 协议:

public protocol TrafficLightOption {
    associatedtype Value

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

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

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 中拿出来:

extension TrafficLight {
    public enum GreenLightColor: TrafficLightOption {
        case green
        case turquoise

        public static let defaultValue: GreenLightColor = .green
    }
}

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

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

extension TrafficLight {
    public var preferredGreenLightColor: TrafficLight.GreenLightColor {
        get { self[option: GreenLightColor.self] }
        set { self[option: GreenLightColor.self] = newValue }
    }
}

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

light.preferredGreenLightColor = .turquoise

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

总结

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

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

原文作者:王巍 (onevcat) 链接:https://onevcat.com/2020/10/use-options-pattern/

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 222,252评论 6 516
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 94,886评论 3 399
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 168,814评论 0 361
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 59,869评论 1 299
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 68,888评论 6 398
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 52,475评论 1 312
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 41,010评论 3 422
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,924评论 0 277
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 46,469评论 1 319
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 38,552评论 3 342
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 40,680评论 1 353
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 36,362评论 5 351
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 42,037评论 3 335
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 32,519评论 0 25
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 33,621评论 1 274
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 49,099评论 3 378
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 45,691评论 2 361

推荐阅读更多精彩内容