更現代的 Swift API 設計

Python實戰社羣

Java實戰社羣

長按識別下方二維碼,按需求添加

掃碼關注添加客服

進Python社羣▲

掃碼關注添加客服

進Java社羣

作者:zvving,iOS 開發者,現就職於字節跳動音樂團隊

Sessions: https://developer.apple.com/videos/play/wwdc2019/415/

本文發表於 2019/06/13 《WWDC19 內參》

前言

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[1]

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(&amp;_texture) { _texture = Texture(copying: _texture) }
   _texture[keyPath: keyPath] = newValue
  }
 }
}

實現思路與之前的代碼完全一致,只是引入 dynamicMemberLookup 動態提供對 Texture 的屬性訪問,這樣無論 Class Texture 有多少屬性,幾行代碼輕鬆支持。

需要留意的是 Xcode 11 完全支持 dynamicMemberLookup,代碼提示也毫無壓力

至此,似乎『完美解決』了Struct 中嵌套 Class 的拷貝問題。

此處賣個關子,後面還有更簡潔的實現。先來看看 PropertyWrapper。

PropertyWrapper

Swift Evolution: SE-0258[2]

實際項目中有些屬性的初始化性能開銷較大,我們常常會用到懶加載:

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(&quot;text has not yet been set!&quot;)
   }
   return value
  }
  set { textStorage = newValue }
 }
}

看起來不錯。支持延遲外部賦值又有檢查機制。唯一(而且是很重要)的問題是實現太臃腫。每個有同樣邏輯的屬性都需要大段重複代碼。

還記得我們說過的:保持使用者的清晰度是 API 設計的第一要義

我們更傾向於使用者看到這樣的代碼:

@LateInitialized public var text: String

非常棒!定義本身清晰說明語義。更棒的是,這裏的屬性註解,完全支持自定義。

PropertyWrapper 顧名思義:屬性包裝器,沒錯,從 Swift5.1 開始,屬性用這種方式支持自定義註解。

我們看看如何實現:

實現 @LateInitialized 註解,我們需要定義一個打上@propertyWrapper註解的 struct LateInitialized<Value> ????,代碼如下:

// Implementing a Property Wrapper 
@propertyWrapper
public struct LateInitialized<Value> {
 
 private var storage: Value? 

 public var value: Value {
  get {
   guard let value = storage else {
    fatalError(&quot;value has not yet been set!&quot;)
   }
    return value
  }
   set { storage = newValue }
 }
}

實現原理也不復雜:

@LateInitialized 修飾屬性定義時,如:

@LateInitialized public var text: String

編譯器會把屬性代碼展開,生成如下代碼:

// Compiler-synthesized code… 
var $text: LateInitialized<String> = LateInitialized<String>()
public var text: String {
 get { $text.value }
 set { $text.value = newValue }
}

二者完全等價。

你可以把 $text 看成 wrappedText,又一次爲了代碼更清晰,蘋果把 $ 專用在屬性註解場景,表達 wrapped 語義。

除此之外,PropertyWrapper 還支持自定義構造器:

@UserDefault(key: &quot;BOOSTER_IGNITED&quot;, defaultValue: false)
static var isBoosterIgnited: Bool 

@ThreadSpecific
var localPool: MemoryPool 

@Option(shorthand: &quot;m&quot;, documentation: &quot;Minimum value&quot;, defaultValue: 0) // 命令行參數
var minimum: Int

還記得前面 Struct Material 中嵌套 Class 的拷貝問題 的例子嗎?

@CopyOnWrite:用 PropertyWrapper 帶來的思路

通過自定義 @CopyOnWrite 註解,我們可以更優雅的解決這個問題:

@propertyWrapper
struct CopyOnWrite<Value: Copyable> {
    init(initialValue: Value) {
        store = initialValue
    }

    private var store: Value

    var value: Value {
        mutating get {
            if !isKnownUniquelyReferenced(&amp;store) {
                store = store.copy()
            }
            return store
        }
        set { store = newValue }
    }
}

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

extension Texture: Copyable { ... }

// Copyable 具體實現略

代碼不必過多解釋,相信大家都能看懂。

PropertyWrapper 在 SwiftUI DSL 中的應用

SwiftUI 是 WWDC19 的最大亮點,來看一個典型的 View 聲明:

struct Topic {
  var title: String = &quot;Hello World&quot;
  var content: String = &quot;Hello World&quot;
}

struct TopicViewer: View {

 @State private var isEditing = false
 @Binding var topic: Topic
 
 var body: some View {
  VStack {
   Text(&quot;Title: #\(topic.title)&quot;)
   if isEditing {
    TextField($topic.content) 
   }
  }
  }
}

@State, @Binding, $topic.title?是不是似曾相識?

這些屬性都是基於 PropertyWrapper 來實現的(或者說再加上 dynamicMemberLookup)。

這裏以 @Binding的大致實現爲例:

@propertyWrapper @dynamicMemberLookup

public struct Binding<Value> {

 public var value: Value {
  get { ... }
  nonmutating set { ... }
 }
 
 public subscript<Property>(dynamicMember keyPath: WritableKeyPath<Value, Property>) {
  ...
 }
}

屬性定義展開過程如下:

@Binding var topic: Topic 

// 等價於

var $topic: Binding<Topic> = Binding<Topic>()
public var topic: Topic {
 get { $topic.value }
 set { $topic.value = newValue }
}

再來看看使用時的區別:

topic                // Topic instance 
topic.title          // String instance 

$topic      // Binding<Topic> instance 
$topic.title      // Binding<String> instance 
$topic[dynamicMember: \Topic.title]  // Binding<String> instance 

留意最後幾行實例對應的類型:

  • 看到 $ 不要意外,這是取屬性註解類型的實例,會想剛提到的代碼展開 $topic 語義就是 wrappedTopic

  • Struct Binding 實現了 dynamicMemberLookup,$topic.title 可以正常調用,並且與 $topic[dynamicMember: \Topic.title] 完全等價

  • 屬性註解是 Struct,對應方法,屬性,以及其它協議都可以支持,這裏有很多的可能性還有待挖掘

我迫不及待把 PropertyWrapper 用在我們項目中,至少簡化幾百行屬性相關的模板代碼,更關鍵的是,這會帶來能清晰的屬性定義。

如何使用協議和泛型,讓代碼更少困擾

這裏用新推出的向量數據 SIMD[3] 做示例,通用性不是很強,這裏不贅述。大體思想是:

  • 不要無腦的從協議開始 Coding

  • 從實際的使用場景開始分析問題,從嘗試合併重複代碼開始下手

  • 優先嚐試組合已有的協議,新協議會有新的理解成本

  • 更多的在協議中使用泛型,解決通用問題

總結

討論了這麼多,還記得最前面提到的嗎:保持使用者的清晰度是 API 設計的第一要義! 這個 session 討論的問題和新概念無不圍繞着這一目標:

  • DynamicMemberLookup 簡化動態成員屬性調用

  • PropertyWrapper 讓屬性可以自定義註解,統一屬性模板代碼並且提供文檔化的書寫方式

  • 設計 $value 表達 wrappedValue 語義

簡潔的背後往往蘊涵着複雜的探索和巧妙的設計過程。

這個 session 更側重介紹 Swift 語言細節的的設計理念,希望這些理念能幫助你用 Swift 在項目中設計出更現代、清晰度更高的 API。

參考資料

[1]

細說 Swift 4.2 新特性:Dynamic Member Lookup: https://juejin.im/post/5b24c9896fb9a00e69608a71

[2]

Swift Evolution: SE-0258: https://github.com/apple/swift-evolution/blob/master/proposals/0258-property-delegates.md

[3]

SIMD: https://developer.apple.com/documentation/swift/simd

程序員專欄 掃碼關注填加客服 長按識別下方二維碼進羣

近期精彩內容推薦:  

 朋友入職中軟一個月(外包華爲)就離職了!

 再見,胡阿姨!再見,共享單車!

 一代經典銷聲匿跡:WinXP徹底再見了!

 2021年1月編程語言排行榜


在看點這裏好文分享給更多人↓↓

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章