[譯] 使用 Swift 的 iOS 設計模式(第一部分)

iWeslie發表於2019-03-03

使用 Swift 的 iOS 設計模式(第一部分)

在這個由兩部分組成的教程中,你將瞭解構建 iOS 應用程式的常見設計模式,以及如何在自己的應用程式中應用這些模式。

更新說明:本教程已由譯者針對 iOS 12,Xcode 10 和 Swift 4.2 進行了更新。原帖由教程團隊成員 Eli Ganem釋出。

iOS設計模式 — 你可能已經聽過這個術語,但是你知道這意味著什麼嗎?儘管大多數開發人員可能都認為設計模式非常重要,關於這個主題的文章並不多,我們開發人員在編寫程式碼時有時不會過多地關注設計模式。

設計模式是軟體設計中常見問題的可重用解決方案。它們的模板旨在幫助你編寫易於理解和重用的程式碼。它們還可以幫助你建立低耦合度的程式碼,以便你能更改或替換程式碼中的元件而避免很多麻煩。

如果你對設計模式不熟悉,那麼我有個好訊息要告訴你!首先,由於 Cocoa 的架構方式以及它鼓勵你使用的最佳實踐,你已經使用過了大量的 iOS 設計模式。其次,本教程將快速幫助你理解 Cocoa 中常用的所有重要(還有不那麼重要)的 iOS 設計模式。

在這個由兩部分組成的教程中,你將建立一個音樂應用程式,用於顯示你的專輯及其相關資訊。

在開發此應用程式的過程中,你將熟悉最常見的 Cocoa 設計模式:

  • 建立型:單例。
  • 結構型:MVC、裝飾、介面卡和外觀。
  • 行為型:觀察者和備忘錄。

不要誤以為這是一篇關於理論的文章,你將在音樂應用中使用大多數這些設計模式。在本教程結束時,你的應用將如下所示:

How the album app will look when the design patterns tutorial is complete

讓我們開始吧!

入門

下載 入門專案,解壓縮 ZIP 檔案的內容,並在 Xcode 中開啟 RWBlueLibrary.xcodeproj

請注意專案中的以下內容:

  1. 在 storyboard 裡,ViewController 有三個 IBOutlet 連線了 TableView,還有撤消和刪除按鈕按鈕。
  2. Storyboard 有 3 個元件,為方便起見我們設定了約束。頂部元件是用來顯示專輯封面的。專輯封面下方是一個 TableView,其中列出了與專輯封面相關的資訊。 最後,工具欄有兩個按鈕,一個用於撤消操作,另一個用於刪除你選擇的專輯。Storyboard 如下所示:

swiftDesignPatternStoryboard

  1. 有一個沒有實現的初始 HTTP 客戶端類(HTTPClient),供你稍後填寫。

注意:你知道嗎,只要你建立新的 Xcode 專案,就已經充滿了設計模式了嘛?模型-檢視-控制器,代理,協議,單例 — 這些設計模式都是現成的!

MVC – 設計模式之王

mvcking

模型 - 檢視 - 控制器(MVC)是 Cocoa 的構建模組之一,它無疑是所有設計模式中最常用的。它將應用內物件按照各自常用角色進行分類,並提倡將程式碼基於角色進行解耦。

這三個角色是:

  • 模型(Model):Model 是你的應用中持有並定義如何運算元據的物件。例如,在你的應用程式中,模型是 Album 結構體,你可以在 Album.swift 中找到它。大多數應用程式將具有多個型別作為其模型的一部分。
  • 檢視(View):View 是用來展示 model 的資料並管理可與使用者互動的控制元件的物件,基本上可以說是所有 UIView 派生的物件。 在你的應用程式中,檢視是 AlbumView,你可以在 AlbumView.swift 中找到它。
  • 控制器(Controller):控制器是協調所有工作的中介。它訪問模型中的資料並將其與檢視一起顯示,監聽事件並根據需要運算元據。你能猜出哪個類是你的控制器嗎?沒錯,就是 ViewController

你的 App 要想規範地使用 MVC 設計模式,就意味著你 App 中每個物件都可以劃分為這三個角色其中的某一個。

通過控制器(Controller)可以最好地描述檢視(View)到模型(Model)之間的通訊,如下圖所示:

mvc0

模型通知控制器任何資料更改,反過來,控制器更新檢視中的資料。然後,檢視可以向控制器通知使用者執行的操作,控制器將在必要時更新模型或檢索任何請求的資料。

你可能想知道為什麼你不能拋棄控制器,並在同一個類中實現檢視和模型,因為這看起來會容易得多。

這一切都將歸結為程式碼分離和可重用性。理想情況下,檢視應與模型完全分離。如果檢視不依賴於模型的特定實現,那麼可以使用不同的模型重用它來呈現其他一些資料。

例如,如果將來你還想將電影或書籍新增到庫中,你仍然可以使用相同的 AlbumView 來顯示電影和書籍物件。此外,如果你想建立一個與專輯有關的新專案,你可以簡單地重用你的 Album 結構體,因為它不依賴於任何檢視。這就是MVC的力量!

如何使用 MVC 設計模式

首先,你需要確保專案中的每個類都是Controller、Model 或 View,不要在一個類中組合兩個角色的功能。

其次,為了確保你符合這種工作方法,你應該建立三個資料夾來儲存你的程式碼,每個角色一個。

點選 **File\New\Group(或者按 Command + Option + N)**並把改組名為 Model。重複相同的過程以建立 View 和 Controller 組。

現在將 Album.swift 拖拽到 Model 組。將 AlbumView.swift 拖拽到 View 組,最後將 ViewController.swift 拖拽到 Controller 組。

此時專案結構應如下所示:

[譯] 使用 Swift 的 iOS 設計模式(第一部分)

如果沒有所有這些資料夾,你的專案看起來會好很多。顯然,你可以擁有其他組和類,但應用程式的核心將包含在這三個類別中。

現在你的元件已組織完畢,你需要從某個位置獲取相簿資料。你將建立一個 API 類,在整個程式碼中使用它來管理資料,這提供了討論下一個設計模式的機會 — 單例(Singleton)。

單例模式

單例設計模式確保給定類只會存在一個例項,並且該例項有一個全域性的訪問點。它通常使用延遲載入來在第一次需要時建立單個例項。

注意:Apple 使用了很多這個方法。例如:UserDefaults.standardUIApplication.sharedUIScreen.mainFileManager.default 都返回一個單例物件。

你可能想知道為什麼你關心的是一個類有不只一個例項。程式碼和記憶體不是都很廉價嗎?

在某些情況下,只有一個例項的類才有意義。例如,你的應用程式只有一個例項,裝置也只有一個主螢幕,因此你只需要一個例項。再者,採用全域性配置處理程式類,他更容易實現對單個共享資源(例如配置檔案)的執行緒安全訪問,而不是讓許多類可能同時修改配置檔案。

你應該注意什麼?

注意事項:這種模式有被初學者和有經驗的開發著濫用(或誤用)的歷史,因此我們將 Joshua Greene 的 Design Patterns by Tutorials 一書中的一段簡述摘錄至此,其中解釋了使用這種模式的一些需要注意的事項。

單例模式很容易被濫用。

如果你遇到一種想要使用單例的情況,請首先考慮是否還有其他的方法來完成你的任務。

例如,如果你只是嘗試將資訊從一個檢視控制器傳遞到另一個檢視控制器,則不適合使用單例。但是你可以考慮通過初始化程式或屬性傳遞該模型。

如果你確定你確實需要一個單例,那麼考慮擴充單例是否會更有意義。

有多個例項會導致問題嗎?自定義例項會有用嗎?你的答案將決定你是否更好地使用真正的單例或其擴充。

用單例時遇到問題的最常見的原因是測試。如果你將狀態儲存在像單例這樣的全域性物件中,則測試順序可能很重要,並且模擬它們會很煩人。這兩個原因都會使測試成為一種痛苦。

最後,要注意“程式碼異味”,它表明你的用例根本不適合使用單例。例如,如果你經常需要許多自定義例項,那麼你的用例可能會更好地作為常規物件。

[譯] 使用 Swift 的 iOS 設計模式(第一部分)

如何使用單例模式

為了確保你的單例只有一個例項,你必須讓其他任何人都無法建立例項。Swift 允許你通過將初始化方法標記為私有來完成此操作,然後你可以為共享例項新增靜態屬性,該屬性在類中初始化。

你將通過建立一個單例來管理所有專輯資料從而實現此模式。

你會注意到專案中有一個名為 API 的組,這是你將所有將為你的應用程式提供服務的類的地方。右鍵單擊該組並選擇 New File,在該組中建立一個新檔案,選擇 iOS > Swift File。將檔名設定為 LibraryAPI.swift,然後單擊 Create

現在開啟 LibraryAPI.swift 並插入程式碼:

final class LibraryAPI {
  // 1
  static let shared = LibraryAPI()
  // 2
  private init() {

  }
}
複製程式碼

以下是詳細分析:

  1. 其中 shared 宣告的常量使得其他物件可以訪問到單例物件 LibraryAPI
  2. 私有的初始化方法防止從外部建立 LibraryAPI 的新例項。

你現在有一個單例物件作為管理專輯的入口。接下來建立一個類來持久化庫裡的資料。

現在在 API 組裡建立一個新檔案。 選擇 iOS > Swift File。將類名設定為 PersistencyManager.swift,然後單擊 Create

開啟 PersistencyManager.swift 並新增以下程式碼:

final class PersistencyManager {

}
複製程式碼

在括號裡面新增以下程式碼:

private var albums = [Album]()
複製程式碼

在這裡,你宣告一個私有屬性來儲存專輯資料。該陣列將是可變的,因此你可以輕鬆新增和刪除專輯。

現在將以下初始化方法新增到類中:

init() {
  //Dummy list of albums
  let album1 = Album(title: "Best of Bowie",
                     artist: "David Bowie",
                     genre: "Pop",
                     coverUrl: "https://s3.amazonaws.com/CoverProject/album/album_david_bowie_best_of_bowie.png",
                     year: "1992")
    
  let album2 = Album(title: "It's My Life",
                     artist: "No Doubt",
                     genre: "Pop",
                     coverUrl: "https://s3.amazonaws.com/CoverProject/album/album_no_doubt_its_my_life_bathwater.png",
                     year: "2003")
    
  let album3 = Album(title: "Nothing Like The Sun",
                     artist: "Sting",
                     genre: "Pop",
                     coverUrl: "https://s3.amazonaws.com/CoverProject/album/album_sting_nothing_like_the_sun.png",
                     year: "1999")
    
  let album4 = Album(title: "Staring at the Sun",
                     artist: "U2",
                     genre: "Pop",
                     coverUrl: "https://s3.amazonaws.com/CoverProject/album/album_u2_staring_at_the_sun.png",
                     year: "2000")
    
  let album5 = Album(title: "American Pie",
                     artist: "Madonna",
                     genre: "Pop",
                     coverUrl: "https://s3.amazonaws.com/CoverProject/album/album_madonna_american_pie.png",
                     year: "2000")
    
  albums = [album1, album2, album3, album4, album5]
}
複製程式碼

在初始化程式中,你將使用五個示例專輯填充陣列。如果上述專輯不符合你的喜好,可以隨便使用你喜歡的音樂替換它們。

現在將以下函式新增到類中:

func getAlbums() -> [Album] {
  return albums
}
  
func addAlbum(_ album: Album, at index: Int) {
  if albums.count >= index {
    albums.insert(album, at: index)
  } else {
    albums.append(album)
  }
}
  
func deleteAlbum(at index: Int) {
  albums.remove(at: index)
}
複製程式碼

這些方法允許你獲取,新增和刪除專輯。

編譯你的專案,確保所有內容能正確地通過編譯。

此時,你可能想知道 PersistencyManager 類的位置,因為它不是單例。你將在下一節中看到 LibraryAPIPersistencyManager 之間的關係,你將在其中檢視 外觀(Facade) 設計模式。

外觀模式

[譯] 使用 Swift 的 iOS 設計模式(第一部分)

外觀設計模式為複雜子系統提供了單一介面。你只需公開一個簡單的統一 API,而不是將使用者暴露給一組類及其 API。

下圖說明了這個概念:

facade2

API 的使用者完全不知道它其中的複雜性。這種模式在大量使用比較複雜或難理解的類時是比較理想的。

外觀模式將使用系統介面的程式碼與你隱藏的類的實現進行解耦,它還減少了外部程式碼對子系統內部工作的依賴性。 如果外觀下的類可能會更改,那這仍然很有用,因為外觀類可以在幕後發生更改時保留相同的 API。

舉個例子,如果你想要替換後端服務,那麼你不必更改使用 API 的程式碼,只需更改外觀類中的程式碼即可。

如何使用外觀模式

目前,你擁有 PersistencyManager 在本地儲存專輯資料,並使用 HTTPClient 來處理遠端通訊。專案中的其他類不應該涉及這個邏輯,因為它們將隱藏在 LibraryAPI 的外觀後面。

要實現此模式,只有 LibraryAPI 應該包含 PersistencyManagerHTTPClient 的例項。其次,LibraryAPI 將公開一個簡單的 API 來訪問這些服務。

設計如下所示:

facade3

LibraryAPI 將暴露給其他程式碼,但會隱藏應用程式其餘部分的 HTTPClientPersistencyManager 複雜性。

開啟 LibraryAPI.swift 並將以下常量屬性新增到類中:

private let persistencyManager = PersistencyManager()
private let httpClient = HTTPClient()
private let isOnline = false
複製程式碼

isOnline 決定了是否應使用對專輯列表所做的任何更改來更新伺服器,例如新增或刪除專輯。實際上 HTTP 客戶端並不是與真實伺服器工作,僅用於演示外觀模式的用法,因此 isOnline 將始終為 false

接下來,將以下三個方法新增到 LibraryAPI.swift

func getAlbums() -> [Album] {
  return persistencyManager.getAlbums()    
}
  
func addAlbum(_ album: Album, at index: Int) {
  persistencyManager.addAlbum(album, at: index)
  if isOnline {
    httpClient.postRequest("/api/addAlbum", body: album.description)
  }  
}
  
func deleteAlbum(at index: Int) {
  persistencyManager.deleteAlbum(at: index)
  if isOnline {
    httpClient.postRequest("/api/deleteAlbum", body: "\(index)")
  }   
}
複製程式碼

我們來看看 addAlbum(_:at:)。該類首先在本地更新資料,然後如果網路有連線,則更新遠端伺服器。這是外觀模式的核心優勢,當你要編寫 Album 之外的某個類新增一個新專輯時,它不知道,也不需要知道類背後的複雜性。

注意:在為子系統中的類設計外觀時,請記住,除非你正在構建單獨的模組並使用訪問控制,否則不會阻止客戶端直接訪問這些“隱藏”的類。不要吝嗇訪問控制的程式碼,也不要假設所有客戶端都必須使用那些與外觀使用它們方法相同的類。

編譯並執行你的應用程式。你將看到兩個空檢視和一個工具欄。頂部的 View 將用於顯示你的專輯封面,底部 View 將用於顯示與該專輯相關的資訊列表。

Album app in starting state with no data displayed

你需要一些東西能在螢幕上顯示專輯的資料,這是你下一個設計模式的完美實踐:裝飾(Decorator)

裝飾模式

裝飾模式動態地向物件新增行為和職責而無需修改其中程式碼。它是子類化的替代方法,通過用另一個物件包裝它來修改類的行為。

在 Swift 中,這種模式有兩種非常常見的實現:擴充套件代理

擴充

新增擴充套件是一種非常強大的機制,允許你向現有類,結構體或列舉型別新增新功能,而無需子類化。你可以擴充套件你無法訪問的程式碼並增強他們的功能也非常棒。這意味著你可以將自己的方法新增到 Cocoa 類,如 UIViewUIImage

Swift 擴充套件與裝飾模式的經典定義略有不同,因為擴充套件不包含它擴充套件的類的例項。

如何使用擴充

想象一下,你希望在 TableView 中顯示 Album 例項的情況:

swiftDesignPattern3

專輯的標題來自哪裡?Album 是一個模型,因此它不關心你將如何呈現資料。你需要一些外部程式碼才能將此功能新增到 Album 結構體中。

你將建立 Album 結構體的擴充套件,它將定義一個返回可以在 UITableView 中容易使用的資料結構的新方法。

開啟 Album.swift 並在檔案末尾新增以下程式碼:

typealias AlbumData = (title: String, value: String)
複製程式碼

此型別定義了一個元組,其中包含表檢視顯示一行資料所需的所有資訊。現在新增以下副檔名以訪問此資訊:

extension Album {
  var tableRepresentation: [AlbumData] {
    return [
      ("Artist", artist),
      ("Album", title),
      ("Genre", genre),
      ("Year", year)
    ]
  }
}
複製程式碼

AlbumData 陣列將更容易在 TableView 中顯示。

注意:類完全可以覆蓋父類的方法,但是對於擴充套件則不能。擴充套件中的方法或屬性不能與原始類中的方法或屬性同名。

考慮一下這個模式有多強大:

  • 你可以直接在 Album 中使用屬性。
  • 你已新增到 Album 結構體並且不用修改它。
  • 此次簡單的操作將允許你返回一個類似 UITableViewAlbum

代理

外觀設計模式的另一個實現是代理,它是一種讓一個物件代表或協同另外一個物件工作的機制。UITableView 很貪婪,它有兩個代理型別屬性,一個叫做資料來源,另一個叫代理。它們做的事情略有不同,例如 TableView 將詢問其資料來源在特定部分中應該有多少行,但它會詢問其代理在行被點選時要執行的操作。

你不能指望 UITableView 知道你希望在每個 section 中有多少行,因為這是特定於應用程式的。因此,計算每個 section 中的行數的任務會被傳遞到資料來源。這允許 UITableView 的類獨立於它顯示的資料。

以下是你建立新 UITableView 時所發生的事情的偽解釋:

Table:我在這兒!我想做的就是顯示 cell。嘿,我有幾個 section 呢? Data source:一個! Table:好的,好的,很簡單!第一個 section 中有多少個 cell 呢? Data source:四個! Table:謝謝!現在,請耐心點,這可能會有點重複。我可以在第 0 個 section 第 0 行獲得 cell 嗎? Data source:可以,去吧! Table:現在第 0 個 section,第 1 行呢?

未完待續...

UITableView 物件完成顯示錶檢視的工作。但是最終它需要一些它沒有的資訊。然後它轉向其代理和資料來源,併傳送一條訊息,要求提供其他資訊。

將一個物件子類化並重寫必要的方法似乎更容易,但考慮一下你只能基於單個類進行子類化。如果你希望一個物件成為兩個或更多其他物件的代理,你就無法通過子類化實現此目的。

注意:這是一個重要的模式。Apple 在大多數 UIKit 類中使用這種方法: UITableViewUITextViewUITextFieldUIWebViewUICollectionViewUIPickerViewUIGestureRecognizerUIScrollView。 這個清單還將不斷更新。

如何使用代理模式

開啟 ViewController.swift 並把這些私有的屬性新增到類:

private var currentAlbumIndex = 0
private var currentAlbumData: [AlbumData]?
private var allAlbums = [Album]()
複製程式碼

從 Swift 4 開始,標記為 private 的變數可以在型別和所述型別的任何擴充套件之間共享相同的訪問控制範圍。如果你想瀏覽 Swift 4 引入的新功能,請檢視 What’s New in Swift 4

你將使 ViewController 成為 TableView 的資料來源。在類定義的右大括號之後,將此擴充套件新增到 ViewController.swift 的末尾:

extension ViewController: UITableViewDataSource {

}
複製程式碼

編譯器會發出警告,因為 UITableViewDataSource 有一些必需的函式。在擴充套件中新增以下程式碼讓警告消失:

func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
  guard let albumData = currentAlbumData else {
    return 0
  }
  return albumData.count
}

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
  let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath)
  if let albumData = currentAlbumData {
    let row = indexPath.row
    cell.textLabel?.text = albumData[row].title
    cell.detailTextLabel?.text = albumData[row].value
  }
  return cell
}
複製程式碼

tableView(_:numberOfRowsInSection:) 返回要在 tableView 中顯示的行數,該行數與專輯“裝飾”表示中的專案數相匹配。

tableView(_:cellForRowAtIndexPath:) 建立並返回一個帶有 title 和 value 的 cell。

注意:你實際上可以將方法新增到主類宣告或擴充套件中,編譯器並不關心資料來源方法實際上存在於 UITableViewDataSource 擴充套件中。對於閱讀程式碼的人來說,這種組織確實有助於提高可讀性。

接下來,使用以下程式碼替換 viewDidLoad()

override func viewDidLoad() {
  super.viewDidLoad()
 
  //1
  allAlbums = LibraryAPI.shared.getAlbums()

  //2
  tableView.dataSource = self		
}
複製程式碼

以下是上述程式碼的解析:

  1. 通過 API 獲取所有專輯的列表。請記住,我們的計劃是直接使用 LibraryAPI 的外觀而不是直接用 PersistencyManager
  2. 這是你設定 UITableView 的地方。你宣告 ViewController 是 UITableView 資料來源,因此,UITableView 所需的所有資訊都將由 ViewController 提供。請注意,如果在 storyboard 中建立了 TableView,你實際上可以在那裡設定代理和資料來源。

現在,將以下方法新增到 ViewController 裡:

private func showDataForAlbum(at index: Int) {
    
  // defensive code: make sure the requested index is lower than the amount of albums
  if index < allAlbums.count && index > -1 {
    // fetch the album
    let album = allAlbums[index]
    // save the albums data to present it later in the tableview
    currentAlbumData = album.tableRepresentation
  } else {
    currentAlbumData = nil
  }
  // we have the data we need, let's refresh our tableview
  tableView.reloadData()
}
複製程式碼

showDataForAlbum(at:) 從專輯陣列中獲取所需的專輯資料。當你想要重新整理資料時,你只需要在 UITableView 裡呼叫 reloadData。這會導致 TableView 再次呼叫其資料來源方法,例如重新載入 TableView 中應顯示的 section 個數,每個 section 中的行數以及每個 cell 的外觀等等。

將以下行新增到 viewDidLoad() 的末尾:

showDataForAlbum(at: currentAlbumIndex)
複製程式碼

這會在應用啟動時載入當前專輯。由於 currentAlbumIndex 設定為 0,因此顯示該集合中的第一張專輯。

編譯並執行你的專案,你的應用啟動後螢幕上應該會顯示如下圖:

Album app showing populated table view

TableView 設定資料來源完成!

寫在最後

為了不使用硬編碼值(例如字串 Cell)汙染程式碼,請檢視 ViewController,並在類定義的左大括號之後新增以下內容:

private enum Constants {
  static let CellIdentifier = "Cell"
}
複製程式碼

在這裡,你將建立一個列舉充當常量的容器。

注意:使用不帶 case 的列舉的優點是它不會被意外地例項化並只作為一個純名稱空間。

現在只需用 Constants.CellIdentifier 替換 "Cell"

接下來該幹嘛?

到目前為止,事情看起來進展很順利!你知道了 MVC 模式,還有單例,外觀和裝飾模式。你可以看到 Apple 在 Cocoa 中如何使用它們以及如何將模式應用於你自己的程式碼。

如果你想要檢視或比較,那請看 最終專案

庫存裡還有很多:本教程的第二部分還有介面卡,觀察者和備忘錄模式。如果這還不夠,我們會有一個後續教程,在你重構一個簡單的 iOS 遊戲時會涉及更多的設計模式。

如果發現譯文存在錯誤或其他需要改進的地方,歡迎到 掘金翻譯計劃 對譯文進行修改並 PR,也可獲得相應獎勵積分。文章開頭的 本文永久連結 即為本文在 GitHub 上的 MarkDown 連結。


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOS前端後端區塊鏈產品設計人工智慧等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章