epoxy源码笔记2:在扩展中定义存储属性
常规的在扩展中定义存储属性的方法是借助 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
语法其实也是对 storage
的 subscript
的调用。
但是这里就复杂了一些,同时出现了 EpoxyModelStorage
和 EpoxyModelProperty
两个类型。让我们先看一下 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]
类型来对待。
再回看 EpoxyModelStorage
的 subscript
实现,至此其实我们已经能明白 Epoxy 在 extension 中定义存储属性的原理:其实就是用 KeyPath 做 key,将存储属性对应的值存储到内部的一个字典属性中。
看到这里会不会有点失望?有的人还会说这是脱裤子放屁,理由如下:
- 在需要 extension 定义存储属性的类型里手动定义一个
[String: Any]
,然后在 extension 里操作这个字典不也能达到一样的效果吗?还至于费这么大劲写这么多封装? - 而且一点也不灵活,不像是ObjC一样随写随用,无需更改原类型的定义。
- 还有,因为 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
有三个属性:keyPath
、defaultValue
和 updateStrategy
。
keyPath
和 defaultValue
配合范型可以很好的解决 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
的必要性之一。
解决疑问
针对上述三个问题:
- 为何不手动定义
[String: Any]
?epoxy 属于过度封装 - 缺乏灵活性,而且需要更改原类型的定义。对于无法更改定义的类型,则无法使用这个方法。
- 因为 protocol 是 public 的,导致
storage
会被暴露给外界,造成一定程度的隐患。
针对第一个问题,用 protocol 规范属性定义在 Swift 中绝对不算是过度封装,而是很常见的方法;因为无法直接使用 KeyPath
类型作为 Map 的Key,为了保持类型安全,在上层调用中也无法直接使用 AnyKeyPath
,所以必然需要自定义一个类型来做调用链中的 “索引”。
而且其他两个问题确实是实际存在的。特别是第二个问题,除非使用 objc_getAssociatedObject
来实现相关协议,否则则无法使用这套方法。
至于第三个问题,考虑到外部对该类型的扩展性,暴露 storage
属性给外界是必然的,否则外部如何给这个类型增加新的存储属性呢?除非你明确不允许外部给这个类型增加存储属性,那么你可以使用一个 internal type
包一层 Map。
简化代码
现在让我们学以致用,考虑一下如何简化代码。
关键类型是三个协议:EpoxyModeled
、EpoxyModelStorage
和 EpoxyModelProperty
,简化肯定也是针对这三个类型进行简化。
但是其实可以删减的空间并不多,仅仅在你不需要 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]
也是可以的,不影响主要逻辑。