天天看點

Swift 聲明式程式設計

<b>本文講的是Swift 聲明式程式設計,</b>

<b></b>

在我第一份 iOS 開發工程師的工作中,我編寫了一個 XML 解析器和一個簡單的布局工具,兩個東西都是基于聲明式接口。XML 解析器是基于 <code>.plist</code> 檔案來實作 Objective-C 類關系映射。而布局工具則允許你利用類似 HTML 一樣标簽化的文法來實作界面布局(不過這個工具使用的前提是已經正确使用<code>AutoLayout</code> &amp; <code>CollectionViews</code>)。

盡管這兩個庫都不完美,它們還是展現了聲明式代碼的四大優點:

關注點分離: 我們在使用聲明式風格編寫的代碼時聲明了意圖,進而無需關注具體的底層實作,可以說這樣的分離是自然發生的。

減少重複的代碼: 所有聲明式代碼都共用一套樣式實作,這裡面很多屬于配置檔案,這樣可以減少重複代碼所帶來的風險。

優秀的 API 設計: 聲明式 API 可以讓使用者自行定制已有實作,而不是将已有實作做一種固定的存在看待。這樣可以保證修改程度降至最小。

不管是對于某一種資料結構的描述,或者是對某個功能的實作,在編寫過程中,我最常使用的類型還是一些簡單的結構體。聲明不同的類型,主要是基于泛型類,然後這些東西負責實作具體的功能或者完成必要的工作。我們在 PlanGrid 開發過程中采用這種方法來編寫我們得 Swift 代碼。這種開發方式已經對對代碼可讀性的提升還有開發人員的效率提升上産生了巨大的影響。

本文我想讨論的是 PlanGrid 應用中所使用的 API 設計,它原本使用 NSOperationQueue 實作,現在使用了一種更接近聲明式的方法-讨論這個 API 應該可以展示聲明式程式設計風格在各方面的好處。

我們重新設計的 API 用來将本地變化(也可能是離線發生的)與 API 伺服器進行同步。我不會讨論這種變化追蹤方法的細節,而是将精力放在網絡請求的生成和執行上。

在這篇文章裡,我想專注于一個特定的請求類型上:上傳本地生成的圖檔。出于多種因素的考慮(超出本文讨論範圍),上傳圖檔的操作包括三次請求:

向 API 伺服器發起請求,API 伺服器将會響應,響應内容為向 AWS 伺服器上傳圖檔所需資訊。

上傳圖檔至 AWS (使用上次請求得到的資訊)。

向 API 伺服器發起請求以确認圖檔上傳成功。

既然我們有包括這些請求序列的上傳任務,我們決定将其抽象成一個特殊的類型,并讓我們的上傳架構支援它。

我們決定引入一個單獨的類型來對網絡請求序列進行描述。這個類型将被我們的上傳者類使用,上傳者類的作用是将描述轉化為實在的網絡請求(要提醒你們的是我們不會在本篇文章中讨論上傳者類的實作)。

接下來這個類型是我們控制流的精髓:我們有一個請求序列,序列中的每個請求都可能依賴于前一個請求的結果。

小貼士: 接下來的代碼裡的一些類型的命名方式看起來有點奇怪,但是它們中大多數是根據應用專屬術語集來命名的(如: Operation )。

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

public typealias PreviousRequestTuple = (

request: PushRequest,

response: NSURLResponse,

responseBody: JsonValue?

)

/// A sequence of push requests required to sync this operation with the server.

/// As soon as a request of this sequence completes,

/// `PushSyncQueueManager` will poll the sequence for the next request.

/// If `nil` is returned for the `nextRequest` then

/// this sequence is considered complete.

public protocol OperationRequestSequence: class {

/// When this method returns `nil` the entire `OperationRequestSequence`

/// is considered completed.

func nextRequest(previousRequest: PreviousRequestTuple?) throws -&gt; PushRequest?

}

通過調用 <code>nextRequest:</code> 方法來讓請求序列生成一個請求時,我們提供了一個對前一個請求的引用,包括 <code>NSURLResponse</code> 和 JSON 響應體(如果存在的話)。每一個請求的結果都可能在下一次請求時産生((将會傳回一個 <code>PushRequest</code> 對象),除了沒有下一次請求(傳回 <code>nil</code> )或者在請求過程中發生了一些以外的情況導緻沒有傳回必要的響應以外(請求序列在該情況下 <code>throws</code> )。

值得注意的是, PushRequest 并不是這個傳回值類型的理想名。這個類型隻是描述一個請求的詳情(結束符,HTTP 方法等等),其并不參與任何實質性的工作。這是聲明式設計中很重要的一個方面。

你可能已經注意到了這個協定依賴于一個特定 <code>class</code> ,我們這樣做是因為我們意識到<code>OperationRequestSequence</code> 其是一個狀态描述類型。它需要能夠捕獲并使用前面的請求所産生的結果(比如:在第三個請求裡可能需要擷取第一個請求的響應結果)。這個做法參考了 <code>mutating</code> 方法的結構,不得不說這樣的行為貌似讓這部分有關上傳操作的代碼變得更為複雜了(是以說重新指派變化結構體并不是一件那麼簡單的事兒)

在基于 <code>OperationRequestSequence</code> 協定實作了我們第一個請求序列後,我們發現相比實作<code>nextRequest</code> 方法來說,簡單地提供一個數組來儲存請求鍊更合适。于是我們便添加了<code>ArrayRequestSequence</code> 協定來提供了一個請求數組的實作:

public typealias RequestContinuation = (previous: PreviousRequestTuple?) throws -&gt; PushRequest?

public protocol ArrayRequestSequence: OperationRequestSequence {

var currentRequestIndex: Int { get set }

var requests: [RequestContinuation] { get }

extension ArrayRequestSequence {

public func nextRequest(previous: PreviousRequestTuple?) throws -&gt; PushRequest? {

let nextRequest = try self.requests[self.currentRequestIndex](previous: previous)

self.currentRequestIndex += 1

return nextRequest

這個時候,我們定義了一個新的上傳序列,這隻是很微小的一點工作。

作為一個小例子,讓我們看看用來上傳快照的上傳序列吧(在 PlanGrid 中,快照指的是在圖檔中繪制的可導出的藍圖或者注釋):

17

18

19

20

21

22

23

24

25

26

27

28

29

30

31

32

33

34

35

36

37

38

39

40

41

42

43

44

45

46

47

48

49

50

51

52

53

54

55

56

57

58

59

60

61

62

63

64

65

66

67

68

69

70

71

72

73

74

75

76

77

78

79

80

81

82

83

84

85

/// Describes a sequence of requests for uploading a snapshot.

final class SnapshotUploadRequestSequence: ArrayRequestSequence {

// Removed boilerplate initializer &amp;

// instance variable definition code...

// This is the definition of the request sequence

lazy var requests: [RequestContinuation] = {

return [

// 1\. Get AWS Upload Package from API

self._allocationRequest,

// 2\. Upload Snapshot to AWS

self._awsUploadRequest,

// 3\. Confirm Upload with API

self._metadataRequest

]

}()

// It follows the detailed definition of the individual requests:

func _allocationRequest(previous: PreviousRequestTuple?) throws -&gt; PushRequest? {

// Generate an API request for this file upload

// Pass file size in JSON format in the request body

return PushInMemoryRequestDescription(

relativeURL: ApiEndpoints.snapshotAllocation(self.affectedModelUid.value),

httpMethod: .POST,

jsonBody: JsonValue(values:

[

"filesize" : self.imageUploadDescription.fullFileSize

),

operationId: self.operationId,

affectedModelUid: self.affectedModelUid,

requestIdentifier: SnapshotUploadRequestSequence.allocationRequest

func _awsUploadRequest(previous: PreviousRequestTuple?) throws -&gt; PushRequest? {

// Check for presence of AWS allocation data in response body

guard let allocationData = previous?.responseBody else {

throw ImageCreationOperationError.MissingAllocationData

// Attempt to parse AWS allocation data

self.snapshotAllocationData = try AWSAllocationPackage(json: allocationData["snapshot"])

guard let snapshotAllocationData = self.snapshotAllocationData else {

// Get filesystem path for this snapshot

let thumbImageFilePath = NSURL(fileURLWithPath:

SnapshotModel.pathForUid(

self.imageUploadDescription.modelUid,

size: .Full

// Generate a multipart/form-data request

// that uploads the image to AWS

return AWSMultiPartRequestDescription(

targetURL: snapshotAllocationData.targetUrl,

fileURL: thumbImageFilePath,

filename: snapshotAllocationData.filename,

requestIdentifier: SnapshotUploadRequestSequence.snapshotAWS,

formParameters: snapshotAllocationData.fields

func _metadataRequest(previous: PreviousRequestTuple?) throws -&gt; PushRequest? {

// Generate an API request to confirm the completed upload

httpMethod: .PUT,

jsonBody: self.snapshotMetadata,

requestIdentifier: SnapshotUploadRequestSequence.metadataRequest

在實作的過程中你應該注意這樣幾件事情:

這裡面幾乎沒有指令式代碼。大多數的代碼都通過執行個體變量和前次請求的結果來描述網絡請求。

代碼并不調用網絡層,也沒有任何上傳操作的類型資訊。它們隻是對每個請求的詳情進行了描述。事實上,這段代碼沒有能被觀測到的副作用,它隻更改了内部狀态。

這段代碼裡可以說沒有任何的錯誤處理代碼。這個類型隻負責處理該請求序列中發生的特定錯誤(比如前次請求并未傳回任何結果等)。而其餘的錯誤通常都在網絡層予以處理了。

我們使用 <code>PushInMemoryRequestDescription</code>/<code>AWSMultipartRequestDescription</code> 來對我們對自己的 API 伺服器或者是對 AWS 伺服器發起請求的行為進行抽象。我們的上傳代碼将會根據情況在兩者之前進行切換,對兩者使用不同的 URL 會話配置,以免将我們自有 API 伺服器的認證資訊發送至 AWS 。

我不會詳細讨論整個代碼,但是我希望這個例子能充分展現我之前提到過的聲明式設計方法的一系列優點:

關注點分離: 上面編寫的類型隻有描述一系列請求這一單一功能。

減少重複的代碼: 上面編寫的類型裡面隻包含對請求進行描述的代碼,并不包含網絡請求及錯誤處理的代碼。

優秀的 API 設計: 這樣的 API 設計能有效的減輕開發者的負擔,他們隻需要實作一個簡單的協定以確定後續産生的請求是基于前一個請求結果的即可。

良好的可讀性: 再次聲明,以上代碼非常集中;我們不需要在樣闆代碼的海洋裡遊泳,就可以找到代碼的意圖。那也說明,為了更快地了解這段代碼,你需要對我們的抽象方式有一定的了解。

現在可以想想如果利用 <code>NSOperationQueue</code> 來替代我們的方案會怎麼樣?

采用 <code>NSOperationQueue</code> 的方案複雜了很多,是以在這篇文章裡給出相對應的代碼并不是一個很好的選擇。不過我們還是可以讨論下這種方案。

關注點分離在這種方案中難以實作。和對請求序列進行簡單抽象不同的是,<code>NSOperationQueue</code> 中的<code>NSOperations</code> 對象将負責網絡請求的開關操作。這裡面包含請求取消和錯誤處理等特性。在不同的位置都有相似的上傳代碼,同時這些代碼很難進行複用。在大多數上傳請求被抽象成一個<code>NSOperation</code> 的情況下,使用子類并不是一個好選擇,雖然說我們得上傳請求隊列被抽象成為一個被 <code>NSOperationQueue</code>所裝飾的 <code>NSOperation</code> 。

<code>NSOperationQueue</code> 中的無關資訊相當多。。代碼中随處可見對網絡層的操作和調用 <code>NSOperation</code> 中的特定方法,比如 <code>main</code> 和 <code>finish</code> 方法。在沒有深入了解具體的 API 調用規則前,很難知道具體操作是用來做什麼的

這種 API 所采用的處理方式,某種意義上讓開發者的開發體驗變得更差了。和簡單的實作相對應的協定不同的是,在 Swift 中如果采用上述的開發方式,人們需要去了解一些約定俗成的規定,盡管這些規定可能并不強制要求你遵守。

這種處理方式将會顯著增加開發者的負擔。與實作一個簡單協定不同的是,在新版本的 Swift 中實作這樣的代碼的話,我們需要去了解一些特有的約定。盡管很多被記載下來的約定并不是與程式設計相關的。

由于一些其他原因,該 API 可能會導緻一些與網絡請求的錯誤報告相關的 bug 。為了避免每個請求操作都執行自己的錯誤報告代碼,我們将其集中在一個地方進行處理。錯誤處理代碼将會在請求結束之後開始執行。然後代碼将會檢查請求類型中的 error 屬性的值是否存在。為了及時地回報錯誤資訊,開發者需要及時在操作完成之前設定 <code>NSOperation</code> 中的 <code>error</code> 屬性的值。由于這是一個非強制性約定導緻一堆新代碼忘記設定其屬性的值,可能會導緻諸多錯誤資訊的遺失。

是以啊,我們很期待我們介紹的這樣一種新的方式能幫助開發者們在未來編寫上傳及其餘功能的代碼。

聲明式的程式設計方法已經對我們的程式設計技能和開發效率産生了巨大的影響。我們提供了一種受限的 API ,這種 API 用途單一且不會留下一堆迷之 Bug 。我們可以避免使用子類及多态等一系列手段,轉而使用基于泛型類型的聲明式風格代碼來替代它。我們可以寫出優美的代碼。我們所編寫的代碼都是能很友善的進行測試的(關于這點,程式設計愛好者們可能覺得在聲明式風格代碼中測試可能不是必要的。)是以你可能想問:“别告訴我這是一種完美無瑕的程式設計方式?”

首先,在具體的抽象過程中,我們可能會花費一些時間與精力。不過,這種花費可以通過仔細設計 API ,并并通過提供一些測試,代替用例實作功能,為使用者提供參考。

其次,請注意,聲明式程式設計并不是适用于任何時間任何業務的。要想适用聲明式程式設計,你的代碼庫裡至少要有一個用相似方法解決了多次的問題。如果你嘗試在一個需要高度可定制化的應用裡使用聲明式程式設計, 然後你又對整個代碼進行了錯誤的抽象,那麼最後你會得到如同亂麻一般的半聲明式代碼。對于任何的抽象過程而言,過早地進行抽象都會造成一大堆令人費解的問題。

聲明式 API 有效地将 API 使用者身上的壓力轉移至 API 開發者身上,對于指令式 API 則不需要這樣。為了提供一組優秀的聲明式 API ,API 的開發者必須確定接口的使用與接口的實作細節進行嚴格的隔離。不過嚴格遵循這樣要求的 API 是很少的。React 和 GraphQL 證明了聲明式 API 能有效提升團隊編碼的體驗。

其實我覺得,這隻是一個開端,我們會慢慢發現在複雜的庫中所隐藏複雜的細節和對外提供的簡單易用的接口。期待有一天,我們能利用一個基于聲明式程式設計的 UI 庫來建構我們的 iOS 程式。

<b>原文釋出時間為:2016年10月30日</b>

<b>本文來自雲栖社群合作夥伴掘金,了解相關資訊可以關注掘金網站。</b>