1. 程式人生 > 其它 >Swift 中使用 Option Pattern 改善可選項的 API 設計

Swift 中使用 Option Pattern 改善可選項的 API 設計

技術標籤:swiftios

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/