36fcc83ee1183d916d347362539392bd
更现代的 Swift API 设计

前言

本文内容基于 WWDC19 - 415 Modern Swift API Design 整理。

Swift 是一门简洁同时富有表现力的语言,这其中隐藏着众多的设计细节。

本文通过提出一个 Struct 的语义问题,在寻找答案的过程中逐步介绍这些概念:

  • DynamicMemberLookup 应用
  • PropertyWrapper 的实现原理
  • SwiftUI DSL 中 PropertyWrapper 的应用

来一起看看更现代的 API 背后的设计过程。

WWDC19 部分 sessions 和示例代码中的 PropertyDelegate 即是 PropertyWrapper,后续会统一命名为 PropertyWrapper

Clarity at the point of use

最大化使用者的清晰度是 API 设计的第一要义

  • Swift 的模块系统可以消歧义
  • Swift-only Framework 命名不再有前缀
  • C & Objective-C 符号都是全局的

提醒:

每一个源文件都会将所有 import 模块汇总到同一个命名空间下。你依旧应该谨慎的对待命名,以确保同一命名在复杂上下文依旧有清晰的语义。

选择 Struct 还是 Class?

相很多类似问题一样,你需要重新思考二者的语义。

默认情况下,你应该优先选择 Struct。除非你必须要用到 Class 的特性。

比如这些需要使用 Class 的场景:

  • 需要引用计数或者关心析构过程
  • 数据需要集中管理或共享
  • 比较操作很重要,有类似 ID 的独立概念

很多文章有过讨论,这里不作过多介绍,下面我们看看实际问题。

Struct 中嵌套 Class 的拷贝问题

无论是基于历史问题还是要对不同类型数据组合使用,常常碰到 Struct 和 Class 组合嵌套的情况。

  • Class 中存在 Struct,这种情况再正常不过,使用时也不会带来什么问题,不必讨论
  • Struct 中存在 Class,这种情况破坏了 Struct 的语义,运行时拷贝也可能带来不符合预期的情况,下面重点讨论这个问题。

定义如代码所示,Struct Material 有一个成员属性 texture 是 Class 类型:

struct Material { 
    public var roughness: Float 
    public var color: Color 
    public var texture: Texture 
} 

class Texture { 
    var isSparkly: Bool
} 

当 Material 实例发生拷贝时,会发生什么?

很显然,两个 Material 实例持有同一个的 texture,所有 texture 引用所做的任何修改都会对两个 Struct 产生影响,这破坏了 Struct 本身的语义。

今天我们重点看看如何解决这个问题。

一个思路:把 texture 设为不可变类型?

如图所示,并没有什么作用。

texture 对象的属性依旧可以被修改,一个标记 immutable 的实例属性还能被修改,这会带来更多困扰。

另一个思路:修改时拷贝

struct Material { 
    private var _texture: Texture 

    public var texture { 
        get { _texture } 
        set { _texture = Texture(copying: newValue) }
    } 
}

隐藏存储属性,开放计算属性。在计算属性被赋值时进行拷贝。

针对修改 Material 实例的 texture 属性这一场景,的确会生成单独的拷贝。然而除此之外,有太多的问题。

  • texture 实例的内部属性,依旧可能被意外修改
  • Material 发生写时拷贝时,被拷贝的存储属性 _texture 依旧是同一个

再一个思路:模仿 Copy On Write

既然我们连 Texture 的内部属性都要控制,开放 texture 访问带来太多问题,索性完全禁用 texture 的外部访问,把 texture 的属性(如 isSparkly)提升到 Material 属性层级,在访问 isSparkly 时,确保 _texture 引用唯一。

struct Material { 
    private var _texture: Texture 

    public var isSparkly: Bool { 
        get { 
      if !isKnownUniquelyReferenced(&_texture) { // 确保 _texture 引用计数为 1
                _texture = Texture(copying: _texture) 
            } 
      return _texture.isSparkly 
    } 
        set { 
            _texture.isSparkly = newValue 
        } 
    } 
}

这样的确完整实现了 Struct Material 语义。哪怕 Material 写时拷贝有多个 _texture 引用,在访问 isSparkly 属性时也会发生拷贝,确保每个 Material 实例的 _texture 属性唯一。

唯一(而且是很重要)的问题是如果 Class Texture 属性很多,会引入大量相似代码。『可行』不代表『可用』。

没关系,我们再试试引入 DynamicMemberLookup。

初试 DynamicMemberLookup

DynamicMemberLookup 具体概念可以参考卓同学的这篇文章:细说 Swift 4.2 新特性:Dynamic Member Lookup

DynamicMemberLookup 是 Swift4.2 引入的新特性,使用在什么场景一度让人困惑。这里恰好能解决我们的问题。先上代码:

@dynamicMemberLookup
struct Material {

    public var roughness: Float
    public var color: Color

    private var _texture: Texture

    public subscript<T>(dynamicMember keyPath: ReferenceWritableKeyPath<Texture, T>) -> T {
        get { _texture[keyPath: keyPath] }
        set {
            if !isKnownUniquelyReferenced(&_texture) { _texture = Texture(copying: _texture) }
            _texture[keyPath: keyPath] = newValue
        }
    }
}

实现思路与之前的代码完全一致,只是引入 dynamicMemberLookup 动态提供对 Texture 的属性访问,这样无论 Class Texture 有多少属性,几行代码轻松支持。

需要留意的是 Xcode 11 完全支持 dynamicMemberLookup,代码提示也毫无压力

至此,似乎『完美解决』了Struct 中嵌套 Class 的拷贝问题。

此处卖个关子,后面还有更简洁的实现。先来看看 PropertyWrapper。

PropertyWrapper

Swift Evolution: SE-0258

实际项目中有些属性的初始化性能开销较大,我们常常会用到懒加载:

public lazy var image: UIImage = loadDefaultImage()

如果不用 lazy 关键字,我们也可以这样实现:

public struct MyType {
    private var imageStorage: UIImage? = nil
  public var image: UIImage {
    mutating get {
            if imageStorage == nil {
                imageStorage = loadDefaultImage()
            }
      return imageStorage!
    }
        set { imageStorage = newValue }
    }
}

基于同样的思路,也会有另一些场景,比如需要的是延迟外部赋值,期望未赋值调用时抛出错误:

public struct MyType {

    var textStorage: String? = nil

    public var text: String {
        get {
            guard let value = textStorage else {
                fatalError("text has not yet been set!")
            }
            return value
        }
        set { textStorage = newValue }
    }
}

看起来不错。支持延迟外部赋值又有检查机制。唯一(而且是很重要)的问题是实现太臃肿。每个有同样逻辑的属性都需要大段重复代码。

还记得我们说过的:保持使用者的清晰度是 API 设计的第一要义

我们更倾向于使用者看到这样的代码:

```swift

top Created with Sketch.