常规的在扩展中定义存储属性的方法是借助 Objective-C 的 runtime 进行属性关联。但是这个方法仅限于 ObjC 类,那么纯 Swift 类,比如结构体该怎么办呢?

epoxy 这个库中发现了解决办法。

Objective-C runtime

先说一下用 Objective-C runtime 的实现方式,比如下面的代码:

private var loadingViewKey: Void?

extension HUDProtocol {
    public var loadingView: LoadingView {
        get {
            if let view = objc_getAssociatedObject(self, &loadingViewKey) as? LoadingView {
                return view
            }
            
            loadingView = LoadingView()
            return loadingView
        }
        set {
            objc_setAssociatedObject(self, &loadingViewKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
        }
    }
}

使用 loadingViewKey 关联 loadingView 属性,同时 get 方法内还做了一次懒加载。对于 Key 的定义形式多种多样,这里就不做过度赘述。

值得一提的是,翻看 epoxy 代码中发现,在使用关联属性给 ObjC 类增加存储属性时,还是用到了 @nonobjc。因为它是一个纯 Swift 库,所以使用 @nonobjc 来避免生成 objc 接口也是很合理的优化。

Epoxy 中的做法

让我们先看一下源码,比如 DidChangeStateProviding.swift 这个文件,其中内容如下:

我会省略掉一些和本文无关的注释和内容,完整的内容请点击上方链接查看原始代码。

extension CallbackContextEpoxyModeled {
    public typealias DidChangeState = (CallbackContext) -> Void

    public var didChangeState: DidChangeState? {
        get { self[didChangeStateProperty] }
        set { self[didChangeStateProperty] = newValue }
    }

    private var didChangeStateProperty: EpoxyModelProperty<DidChangeState?> {
        .init(keyPath: \Self.didChangeState, defaultValue: nil, updateStrategy: .chain())
    }
}

可以看到上述代码扩展了 CallbackContextEpoxyModeled,并在其中定义了存储属性 didChangeState

细看 didChangeState,发现用到了 subscript 语法。接着查看 CallbackContextEpoxyModeled 的声明:

public protocol CallbackContextEpoxyModeled: EpoxyModeled {
    associatedtype CallbackContext
}

发现仅仅是一个协议。接着往下看,它所遵循的 EpoxyModeled这里进行定义。

关键代码如下:

public protocol EpoxyModeled {
    /// The underlying storage of this model that stores the current property values.
    var storage: EpoxyModelStorage { get set }
}

extension EpoxyModeled {
    /// Stores or retrieves a value of the specified property in `storage`.
    public subscript<Property>(property: EpoxyModelProperty<Property>) -> Property {
        get { storage[property] }
        set { storage[property] = newValue }
    }
}

从这里感觉EpoxyModeled就是一个壳,用来封装 storage 这个属性。上面提到的 subscript 语法其实也是对 storagesubscript 的调用。

但是这里就复杂了一些,同时出现了 EpoxyModelStorageEpoxyModelProperty 两个类型。让我们先看一下 EpoxyModelStorage

EpoxyModelStorage

该类型在这里进行定义。

依然贴出来一些关键代码:

public struct EpoxyModelStorage {
  public init() { }

  /// Stores or retrieves the value of the specified property.
  public subscript<Property>(property: EpoxyModelProperty<Property>) -> Property {
    get {
      guard let propertyStorage = storage[property.keyPath] else {
        return property.defaultValue()
      }

      // This cast will never fail as the storage is only settable via this subscript and the `KeyPath` key is unique for any provider and value type pair.
      // swiftlint:disable:next force_cast
      return propertyStorage.value as! Property
    }
    set {
      // We first update the value without using the `updateStrategy` since the likely scenario
      // is that there won't be a collision that requires the `updateStrategy`, and we'll be able to
      // return without incurring the cost of another write.
      let propertyStorage = PropertyStorage(value: newValue, property: property)

      guard var replaced = storage.updateValue(propertyStorage, forKey: property.keyPath) else {
        return
      }

      // This cast will never fail as the storage is only settable via this subscript and the
      // `KeyPath` key is unique for any provider and value type pair.
      // swiftlint:disable:next force_cast
      replaced.value = property.updateStrategy.update(replaced.value as! Property, newValue)

      storage[property.keyPath] = replaced
    }
  }

  // MARK: Private

  /// The underlying storage for the properties, with a key of the `EpoxyModelProperty.keyPath` and
  /// a value of the property's `PropertyStorage`.
  ///
  /// Does not include default values.
  private var storage = [AnyKeyPath: PropertyStorage]()
}

// MARK: - PropertyStorage

/// A value stored within an `EpoxyModelStorage`.
private struct PropertyStorage {
  /// The type-erased value of the `EpoxyModelProperty`.
  var value: Any

  /// The property's corresponding `EpoxyModelProperty`, erased to an `AnyEpoxyModelProperty`.
  var property: AnyEpoxyModelProperty
}

可以看到在 EpoxyModelStorage 中定义了一个关键的存储属性 storage,该属性是字典类型,使用 KeyPath 做 Key,Value 是一个自定义属性。但是这里其实 PropertyStorage 不是重点,因为用到 PropertyStorage.property 的代码并不在本文的范围之内,所以我们可以将 storage 进行简化,就当作 [AnyKeyPath: Any] 类型来对待。

再回看 EpoxyModelStoragesubscript 实现,至此其实我们已经能明白 Epoxy 在 extension 中定义存储属性的原理:其实就是用 KeyPath 做 key,将存储属性对应的值存储到内部的一个字典属性中

看到这里会不会有点失望?有的人还会说这是脱裤子放屁,理由如下:

  1. 在需要 extension 定义存储属性的类型里手动定义一个 [String: Any],然后在 extension 里操作这个字典不也能达到一样的效果吗?还至于费这么大劲写这么多封装?
  2. 而且一点也不灵活,不像是ObjC一样随写随用,无需更改原类型的定义。
  3. 还有,因为 protocol 是 public 的,那么 storage 必然会被暴露给外界,造成一定程度的隐患。

疑问暂且按下不表,再来看看 EpoxyModelProperty 这个类型。

EpoxyModelProperty

先看定义

public struct EpoxyModelProperty<Value> {
  /// Creates a property identified by a `KeyPath` to its provided `value` and with its default value if not customized in content by consumers.
  ///
  /// The `updateStrategy` is used to update the value when updating from an old value to a new value.
  public init<Model>(
    keyPath: KeyPath<Model, Value>,
    defaultValue: @escaping @autoclosure () -> Value,
    updateStrategy: UpdateStrategy)
  {
    self.keyPath = keyPath
    self.defaultValue = defaultValue
    self.updateStrategy = updateStrategy
  }

  /// The `KeyPath` that uniquely identifies this property.
  public let keyPath: AnyKeyPath

  /// A closure that produces the default property value when called.
  public let defaultValue: () -> Value

  /// A closure used to update an `EpoxyModelProperty` from an old value to a new value.
  public let updateStrategy: UpdateStrategy
}

// MARK: EpoxyModelProperty.UpdateStrategy

extension EpoxyModelProperty {
  /// A closure used to update an `EpoxyModelProperty` from an old value to a new value.
  public struct UpdateStrategy {
    public init(update: @escaping (Value, Value) -> Value) {
      self.update = update
    }

    /// A closure used to update an `EpoxyModelProperty` from an old value to a new value.
    public var update: (_ old: Value, _ new: Value) -> Value
  }
}

// MARK: Defaults

extension EpoxyModelProperty.UpdateStrategy {
  /// Replaces the old value with the new value when an update occurs.
  public static var replace: Self {
    .init { _, new in new }
  }

  /// Chains the new closure value onto the old closure value, returning a new closure that first calls the old closure and then subsequently calls the new closure.
  public static func chain() -> EpoxyModelProperty<(() -> Void)?>.UpdateStrategy {
    .init { old, new in
      guard let new = new else { return old }
      guard let old = old else { return new }
      return {
        old()
        new()
      }
    }
  }

  /// Chains the new closure value onto the old closure value, returning a new closure that first calls the old closure and then subsequently calls the new closure.
  public static func chain<A>() -> EpoxyModelProperty<((A) -> Void)?>.UpdateStrategy {
    .init { old, new in
      guard let new = new else { return old }
      guard let old = old else { return new }
      return { a in
        old(a)
        new(a)
      }
    }
  }

  // Add more arities as needed
}

EpoxyModelProperty 有三个属性:keyPathdefaultValueupdateStrategy

keyPathdefaultValue 配合范型可以很好的解决 objc_getAssociatedObject 中类型转换的问题,我们可以回看上面提到过的这个代码片段:

// This cast will never fail as the storage is only settable via this subscript and the `KeyPath` key is unique for any provider and value type pair.
// swiftlint:disable:next force_cast
return propertyStorage.value as! Property

这也就解答了上面提到的第一个问题:如果仅仅定义一个 [String: Any],那么 String 类型的 Key 和 Any 类型的 Value 明显无法保证对齐。

那么你可能想说改成 [KeyPath: Any] 呢?答案也是不行的。因为在调用 subscript 时,如果直接使用 KeyPath,则会触发系统默认的通过 KeyPath 取值的方法,不会走自定义的 subscript,所以 Map 的 Key 无论如何都需要自定义类型来包一层,既然要包一层,何不直接连 defaultValue 也封装一下呢?

至于 updateStrategy 定义了属性赋值时的方法,是替换原值?还是两个值都执行?虽然它和本文内容无关,但是这个实用的属性确实也是 EpoxyModelProperty 的必要性之一。

解决疑问

针对上述三个问题:

  1. 为何不手动定义 [String: Any]?epoxy 属于过度封装
  2. 缺乏灵活性,而且需要更改原类型的定义。对于无法更改定义的类型,则无法使用这个方法。
  3. 因为 protocol 是 public 的,导致 storage 会被暴露给外界,造成一定程度的隐患。

针对第一个问题,用 protocol 规范属性定义在 Swift 中绝对不算是过度封装,而是很常见的方法;因为无法直接使用 KeyPath 类型作为 Map 的Key,为了保持类型安全,在上层调用中也无法直接使用 AnyKeyPath,所以必然需要自定义一个类型来做调用链中的 “索引”。

而且其他两个问题确实是实际存在的。特别是第二个问题,除非使用 objc_getAssociatedObject 来实现相关协议,否则则无法使用这套方法。

至于第三个问题,考虑到外部对该类型的扩展性,暴露 storage 属性给外界是必然的,否则外部如何给这个类型增加新的存储属性呢?除非你明确不允许外部给这个类型增加存储属性,那么你可以使用一个 internal type 包一层 Map。

简化代码

现在让我们学以致用,考虑一下如何简化代码。

关键类型是三个协议:EpoxyModeledEpoxyModelStorageEpoxyModelProperty,简化肯定也是针对这三个类型进行简化。

但是其实可以删减的空间并不多,仅仅在你不需要 updateStrategy 时,可以将实现简化为下面的这样:

public struct ModelProperty<Value> {
    public init<Model>(
        keyPath: KeyPath<Model, Value>,
        defaultValue: @escaping @autoclosure () -> Value
    ) {
        self.keyPath = keyPath
        self.defaultValue = defaultValue
    }

    public let keyPath: AnyKeyPath

    public let defaultValue: () -> Value
}

public struct ModelStorage {
    private var storage = [AnyKeyPath: Any]()

    public init() { }

    public subscript<Property>(property: ModelProperty<Property>) -> Property {
        get {
            guard let propertyStorage = storage[property.keyPath] else {
                return property.defaultValue()
            }
            return propertyStorage as! Property
        }
        set {
            storage[property.keyPath] = newValue
        }
    }
}

public protocol Modeled {
    var storage: ModelStorage { get set }
}

extension Modeled {
    public subscript<Property>(property: ModelProperty<Property>) -> Property {
        get { storage[property] }
        set { storage[property] = newValue }
    }
}

其实上述代码也就相当于 updateStrategy = .replace 的情况。不过,查看这份简化的代码是不是感觉整个逻辑都更清晰了呢😏

在调用上和原本实现也没有区别:

struct Action: Modeled {
    var storage = ModelStorage()
}

extension Action {
    var testString: String {
        get { self[testStringProperty] }
        set { self[testStringProperty] = newValue }
    }

    private var testStringProperty: ModelProperty<String> {
        .init(keyPath: \Self.testString, defaultValue: "default")
    }
}

var testAction = Action()
print(testAction.testString) // "default"
testAction.testString = "123"
print(testAction.testString) // "123"

对了,Modeled 中的 subscript 其实属于简化代码的操作,如果去掉这部分代码,那么在 testString 的 get 和 set 中,使用 self.storage[testStringProperty] 也是可以的,不影响主要逻辑。