Swift 中的設計模式 #1 工廠方法與單例方法

SwiftGG翻譯組發表於2018-09-10

作者:Andrew Jaffee,原文連結,原文日期:2018-07-24 譯者:BigLuo;校對:pmstnumbbbbb;定稿:Forelax

“Gang of Four” (“GoF”) Erich Gamma,Richard Helm,Ralph Johonson,和 John Vlissides 在他們“設計模式:物件導向軟體設計複用的基本原理” 的重要著作裡整理了大概 23 種經典的設計模式 。本文會介紹 GoF 總結的兩種建立型(creational)模式:工廠方法單例方法

軟體開發一直在努力地模擬真實世界的場景,希望通過建立工具的方式來加強人類的場景體驗。財富管理工具,例如:像亞馬遜或者 eBay 這樣的銀行 App 和購物輔助工具,相比十年前確實給消費者帶來了更大的生活便利。回顧我們的發展路程。當應用變的更加強大易用時,應用的開發也已變的更加複雜

所以開發者也開創出了一系列最佳實踐。一些很流行的名字,像物件導向程式設計面向協議程式設計值語義 (value semantics)區域性推斷 (local reasoning)將大塊程式碼分解成具有良好介面定義的小段程式碼(比如使用 Swift 的擴充套件),以及 語法糖。還有我沒提及,但卻是最重要的、值得重視的實踐之一,設計模式的使用。

設計模式

設計模式是開發者管理軟體複雜性的重要工具。作為常見的模板技術,它很好地對軟體中類似的、復現的、容易識別的問題進行了概念化抽象。將它當作一個最佳實踐應用到你日常會遇到的那些程式設計場景中,例如,在不瞭解類簇實現細節的情況下建立一個類簇相關的物件。設計模式主要是用於經常發生的那些問題場景中。它們頻繁被使用是因為這些問題很普遍,讓我用一個具體的例子來幫助你們理解吧。

設計模式討論的並不是某些具體的問題,比如”如何迭代包含 11 個整數(Int)的 Swift 陣列“。針對這類問題,GoF 定義了迭代器模式(Iterator Pattern),這是一個通用的模式,描述如何在不確定資料型別的情況下遍歷一個資料列表。設計模式不是語言編碼。它是用於解決相同軟體場景問題的一套實用的指導規則。

還記得嗎,之前我在 AppCoda 介紹過 “Model-View-ViewModel” or “MVVM” 與非常著名的 “Model-View-Controller” or “MVC” 設計模式,這兩個模式深受 Apple 和 iOS 開發者喜愛。

這兩種模式一般用在整個應用中。MVVM 和 MVC 是架構(architectural)設計模式,用於將 UI 從應用資料程式碼和展示邏輯中分離出來(如:MVC),以及將應用的資料從核心資料流程或者業務邏輯中分離(如:MVVM)。 而 GoF 設計模式本質上更具體,旨在解決基於程式程式碼中的具體問題。在一個應用裡面你也許會用到 3 種、7 種或者 12 種 GoF 設計模式。除了迭代器例子,代理模式也是設計模式中另一個很好的例子, 儘管它在 GoF 列出的 23 種設計模式中並未被具體介紹。

當 GoF 的這本書作為大量開發者的聖經而存在時,也不乏有它的詆譭者,我們在文章的結尾處討論這個話題。

設計模式的類別

GoF 將 23 種設計模式整理分為 3 類,“建立型”、“結構型”和“行為型”。本教程討論建立型模式類別中的兩種(工廠模式與單例)。如同例項物件和類的實現,模式的作用是讓複雜物件的建立變得簡單、易於理解、易於維護,隱藏細節。

**隱藏複雜度(封裝)**是聰明的程式設計師最主要的目標之一。例如,物件導向(OOP)類能提供非常複雜的,強大且成熟的函式而不需要知道任何關於類內部間的工作方式。在建立型模式中,開發者甚至不需要知道類的屬性和方法,但如果需要,程式設計師可以看到其介面 - 在 Swift 中的協議中 - 或對那些感興趣的類進行擴充套件。你會在我的第一個“工廠方法”的例子中明白我的意思。

工廠方法設計模式

如果你已經探索過 GoF 設計模式或在 OOP 的世界裡花費了很多時間,你大概至少聽說過“抽象工廠”、“工廠”,或者“工廠方法”模式。“確切”的命名可能有很多爭議,不過下面我要介紹的這個例子最接近的命名是工廠模式。

在這個範例中,你通過工廠方法建立物件,而不需要知道類的構造器和關於類和類層次結構的任何資訊。這帶來了很大的方便。可以用少量的程式碼建立 UI 和它的相關功能。我的工廠方法專案案例,在 GitHub 可下載,展示了在複雜類層次結構中,如何輕鬆的使用物件。

Swift 中的設計模式 #1 工廠方法與單例方法

大多數成功的應用都有風格一致的主題 。為保證應用主題風格統一,假設應用中所有的 shapes 有著相同的顏色和尺寸,這樣就可以和主題保持一致——也就是塑造品牌。這些圖形用在自定義按鈕上,或者作為登入流程的介面背景圖都是不錯的。

假設設計團隊同意使用我的程式碼作為應用的主題背景圖片。下面來看看我的具體程式碼,包括協議、類結構和(UI 開發人員不需要關心的)工廠方法。

ShapeFactory.swift 檔案是一個用於在檢視控制器內繪製形狀的協議。因為可用於各種目的,所以它的訪問級別是 public:

// 這些值被圖形設計團隊預先選定
let defaultHeight = 200
let defaultColor = UIColor.blue
 
protocol HelperViewFactoryProtocol {
    
    func configure()
    func position()
    func display()
    var height: Int { get }
    var view: UIView { get }
    var parentView: UIView { get }
    
}
複製程式碼

還記得嗎? UIView 類有一個預設的矩形屬性 frame ,所以我可以輕鬆的建立出形狀基類 Square:

fileprivate class Square: HelperViewFactoryProtocol {
    
    let height: Int
    let parentView: UIView
    var view: UIView
    
    init(height: Int = defaultHeight, parentView: UIView) {
        
        self.height = height
        self.parentView = parentView
        view = UIView()
        
    }
    
    func configure() {
        
        let frame = CGRect(x: 0, y: 0, width: height, height: height)
        view.frame = frame
        view.backgroundColor = defaultColor
        
    }
    
    func position() {
        
        view.center = parentView.center
        
    }
 
    func display() {
        
        configure()
        position()
        parentView.addSubview(view)
        
    }
    
} 
複製程式碼

注意到我根據 OOP 的設計思想來構建複用程式碼,這樣能讓 shape 層級更加簡化和可維護。CircleRectangle 類是 Square 類的特化 (另外你可以看到,從正方形出發繪製圓形是多麼簡單。)

fileprivate class Circle : Square {
    
    override func configure() {
        
        super.configure()
        
        view.layer.cornerRadius = view.frame.width / 2
        view.layer.masksToBounds = true
        
    }
    
} 
 
fileprivate class Rectangle : Square {
    
    override func configure() {
        
        let frame = CGRect(x: 0, y: 0, width: height + height/2, height: height)
        view.frame = frame
        view.backgroundColor = UIColor.blue
        
    }
    
} 
複製程式碼

我使用 fileprivate 來強調工廠方法模式背後的一個目的:封裝。你可以看到不用改變下面工廠方法的前提下,對 shape 類的層級結構進行修改和擴充套件是很容易的。這是工廠方法的程式碼,它們讓物件的建立如此簡單且抽象。

enum Shapes {
    
    case square
    case circle
    case rectangle
    
}

class ShapeFactory {
    
    let parentView: UIView
    
    init(parentView: UIView) {
        
        self.parentView = parentView
        
    }
    
    func create(as shape: Shapes) -> HelperViewFactoryProtocol {
        
        switch shape {
            
        case .square:
            
            let square = Square(parentView: parentView)
            return square
            
        case .circle:
            
            let circle = Circle(parentView: parentView)
            return circle
            
        case .rectangle:
            
            let rectangle = Rectangle(parentView: parentView)
            return rectangle
            
        }
        
    } 
    
} 

// 公共的工廠方法來展示形狀
func createShape(_ shape: Shapes, on view: UIView) {
    
    let shapeFactory = ShapeFactory(parentView: view)
    shapeFactory.create(as: shape).display()
    
}

// 選擇公共的工廠方法來展示形狀
// 嚴格來說,工廠方法應該返回相關類中的一個。
func getShape(_ shape: Shapes, on view: UIView) -> HelperViewFactoryProtocol {
    
    let shapeFactory = ShapeFactory(parentView: view)
    return shapeFactory.create(as: shape)
    
}
複製程式碼

注意到:我已經寫下一個類工廠和兩個工廠方法來讓你思考。嚴格說,一個工廠方法應該返回對應類的物件,這些類有著共同的基類或者協議。我的目的是在檢視上繪製一個形狀,所以我更傾心使用 createShape(_:view:) 這個方法。提供這種可選方式(該方法),在需要時可用於試驗和探索新的可能性。

最後,我展示了兩個工廠方法繪製形狀的使用方式。UI 開發者不用知道形狀類是如何被編碼出來的。尤其是他/她不必為形狀類如何被初始化而擔憂。ViewController.swift 檔案中的程式碼很容易閱讀。

import UIKit
 
class ViewController: UIViewController {
    
    override func viewDidLoad() {
        super.viewDidLoad()
        //在載入檢視後進行新增設定,一般是從nib
        
    }
 
    override func didReceiveMemoryWarning() {
        super.didReceiveMemoryWarning()
        // 廢棄掉那些可以被重新建立的資源
    }
 
    @IBAction func drawCircle(_ sender: Any) {
        
	// 僅僅用於繪製形狀
        createShape(.circle, on: view)
        
    }
    
    @IBAction func drawSquare(_ sender: Any) {

	// 繪製圖形
        createShape(.square, on: view)
        
    }
    
    @IBAction func drawRectangle(_ sender: Any) {

	// 從工廠獲取一個物件並使用它來繪製一個形狀
        let rectangle = getShape(.rectangle, on: view)
        rectangle.display()
        
    }
    
} 
複製程式碼

單例設計模式

大部分 iOS 開發者熟悉單例模式。回想一下 UNUserNotificationCenter.current()UIApplication.sharedFileManager.default 如果你想要傳送通知,或者在 Safari 裡面開啟一個 URL,或者操作 iOS 檔案,你必須分別使用它們各自的單例。單例可以很好的用於保護共享資源,提供有且僅有一個物件例項進入一些系統,並且支援物件執行一些應用級型別的協作。正如我們將要看到的,單例也可以用來封裝 iOS 內建的其它單例,新增一些值操作功能。

作為一個單例,我們需要確保這個類:

  • 宣告和初始化一個 static 的類的常量屬性,然後命名那個屬性為 shared 來表明這個類的例項是一個單例(預設是共有的);
  • 為我們想要控制和保護的一些資源宣告一個私有的屬性。且只能通過 shared 共享;
  • 宣告一個私有初始化方法,只有我們的單例類能夠初始化它,在 init 的內部,初始化我們想要用於控制的共享資源;

通過定義一個 shared 靜態常量來建立一個類的 private 初始化方法。我們要確保這個類只有一個例項,該類只能初始化一次,並且共享的例項在應用的任何地方都能獲取。就這樣我們建立了一個單例

這個單例專案的程式碼,在 GitHub 可下載,展示了一個開發者如何安全的、高效的儲存使用者的偏好。這是個簡單的 Demo,該 Demo 能夠記錄使用者的密碼文字,偏好設定可設定為可見或隱藏。不過事後發現,這個功能並不是個好想法,我只是需要一個例子來向你展示我程式碼的工作機制。這段程式碼完全是出於教學的目的。我建議你永遠不要讓你的密碼暴露。你可以看到使用者可以設定他們的的密碼偏好 — 且密碼偏好被儲存在 UserDefaults:

Swift 中的設計模式 #1 工廠方法與單例方法

當使用者關閉應用並且再次開啟後,注意到他/她的密碼偏好被記錄了:

Swift 中的設計模式 #1 工廠方法與單例方法

讓我向你展示 PreferencesSingleton.swift 檔案中的程式碼片段,在行內註釋裡,你將會看到我想準確表達的意思。

class UserPreferences {

	// 用類的初始化方法建立一個靜態的,常量例項。
    static let shared = UserPreferences()
    
	// 這是一個私有的,收我們保護的資源共享的。
    private let userPreferences: UserDefaults
    
	// 一個私有的初始化方法只能被類本身呼叫。
    private init() {
        
	// 獲取 iOS 共享單例。我們在這裡包裝了它。
        userPreferences = UserDefaults.standard
        
    }
 
} // end class UserPreferences
複製程式碼

應用啟動的時候需要初始化靜態屬性,但是全域性變數預設是懶載入。你可能會擔心上面這段程式碼在執行的時候出錯,不過就我對 Swift 的瞭解來說,這段程式碼完全沒問題。

你也許會問,“為什麼要通過包裝另一個UserDefaults 單例的方式來建立一個單例?” 首先,我主要目的是要向你展示在 Swift 中建立和使用單例的最佳做法。 使用者偏好是一個資源型別,應該有一個單一的入口。所以在這個例子中,很明顯我們應該使用 UserDefaults。其次,想一下你曾多少次看到在應用中 UserDefaults 被濫用。

在一些專案應用程式碼中,我看到 UserDefaults(或者之前的 NSUserDefaults)的使用缺乏條理和原由。使用者偏好屬性對應的每個鍵都是字串引用。剛才,我在程式碼中發現了一個 bug。我把“switch”拼寫成了“swithc”,由於我對程式碼進行了複製和貼上,在發現問題前,我已經建立了不少“swithc”的例項。 如果其他開發者在這個應用開始或者繼續使用“switch”作為一個鍵來儲存對應的值呢?應用的當前狀態是無法被正確儲存的。 我們經常使用 UserDefaults 的 strings 以鍵值對映的方式儲存應用的狀態。這是一個好的寫法。這可以讓值的意思清晰明確、簡單易懂,還便於記憶。但也不是說通過 strings 來描述是沒有任何風險的。

在我討論的“swithc”與“switch”中。大多數人可能已經明白了被稱為“stringly-typed”的那些程式碼, 用 strings 作為唯一的識別符號會產生細微的不同,最終會因為拼寫錯誤帶來災難性的錯誤。Swift 編譯器不能幫助我們避免“stringly-typed”這類的錯誤。

解決“stringly-typed”錯誤的方式在於把 Swift enum 設定成 string 型別。這麼做不僅可以讓我們標準化字串的使用,而且可讓我們對其進行分類管理。讓我們再次回到 PreferencesSingleton.swift:

class UserPreferences {
    
    enum Preferences {
        
        enum UserCredentials: String {
            case passwordVisibile
            case password
            case username
        }
        
        enum AppState: String {
            case appFirstRun
            case dateLastRun
            case currentVersion
        }
 
    } // end enum Preferences
複製程式碼

我們從單例模式的定義開始,向你介紹清楚在我的應用中,為什麼使用一個單例來封裝 UserDefaults。我們可以通過新增值的方式來增添新的功能,但通過簡單的對 UserDefaults 的包裝卻能增強程式碼的健壯性。在獲取和設定使用者偏好時,你腦中應該要馬上想到進行錯誤校驗。在這裡,我想實現一個關於使用者偏好的功能,設定密碼的可見性。看到下面的程式碼。內容都在 PreferencesSingleton.swift 檔案:

import Foundation
 
class UserPreferences {
    
    enum Preferences {
        
        enum UserCredentials: String {
            case passwordVisibile
            case password
            case username
        }
        
        enum AppState: String {
            case appFirstRun
            case dateLastRun
            case currentVersion
        }
 
    } // end enum Preferences
    	
    // 建立一個靜態、常量例項並初始化
    static let shared = UserPreferences()
    
    // 這是一個私有的,被保護的共享資源
    private let userPreferences: UserDefaults
    
    // 只有類本身能呼叫的一個私有初始化方法
    private init() {
        // 獲取 iOS 共享單例。我們在這裡包裝它
        userPreferences = UserDefaults.standard
 
    }
    
    func setBooleanForKey(_ boolean:Bool, key:String) {
        
        if key != "" {
            userPreferences.set(boolean, forKey: key)
        }
        
    }
    
    func getBooleanForKey(_ key:String) -> Bool {
        
        if let isBooleanValue = userPreferences.value(forKey: key) as! Bool? {
            print("Key \(key) is \(isBooleanValue)")
            return true
        }
        else {
            print("Key \(key) is false")
            return false
        }
        
    }
    
    func isPasswordVisible() -> Bool {
        
        let isVisible = userPreferences.bool(forKey: Preferences.UserCredentials.passwordVisibile.rawValue)
        
        if isVisible {
            return true
        }
        else {
            return false
        }
        
    }
複製程式碼

來到 ViewController.swift 檔案,你將看到,訪問並使用結構良好的單例是多麼的容易:

import UIKit
 
class ViewController: UIViewController {
    
    @IBOutlet weak var passwordTextField: UITextField!
    @IBOutlet weak var passwordVisibleSwitch: UISwitch!
    
    override func viewDidLoad() {
        super.viewDidLoad()
	// 在載入檢視後(一般通過 nib 來進行)進行其它的額外設定。
        
        if UserPreferences.shared.isPasswordVisible() {
            passwordVisibleSwitch.isOn = true
            passwordTextField.isSecureTextEntry = false
        }
        else {
            passwordVisibleSwitch.isOn = false
            passwordTextField.isSecureTextEntry = true
        }
        
    } 
 
    override func didReceiveMemoryWarning() {
        super.didReceiveMemoryWarning()
	// 可以銷燬那些能被重新建立的資源
    }
    
    @IBAction func passwordVisibleSwitched(_ sender: Any) {
        
        let pwdSwitch:UISwitch = sender as! UISwitch
        
        if pwdSwitch.isOn {
            passwordTextField.isSecureTextEntry = false
            UserPreferences.shared.setPasswordVisibity(true)
        }
        else {
            passwordTextField.isSecureTextEntry = true
            UserPreferences.shared.setPasswordVisibity(false)
        }
        
    } 
複製程式碼

結論

有些評論家聲稱設計模式在一些程式語言中的使用缺乏證明,相同的設計模式在程式碼中反覆出現是很槽糕的一件事情。我並不同意這個說法。期望一個程式語言對每件事情的處理都有其對應的特性是很愚蠢的。這很可能會導致一個臃腫的語言,像 C++ 一樣正在變得更大、更復雜,以致很難被學習、使用與維護。認識並解決反覆出現的問題是人的一種積極性格並且這確實值得我們強化。有一些事情,人們嘗試卻失敗了很多次,通過學習總結前人經驗,對一些相同的問題進行抽象和標準化,讓這些好的解決方案散播出去的方面,設計模式成為了一個成功案例。

像 Swift 這樣的簡單緊湊的語言和設計模式這樣一系列最佳實踐的組合是一個理想中的、令人開心的方法。風格統一的程式碼一般來說都具有較好的可讀性和易維護性。不過也要記住,在數以百萬的開發者不斷地討論和分享下,設計模式也在不斷的發展變化,這些美好事物被全球資訊網聯絡在一起,這種開發人員的討論持續的引領著集體智慧的自我調節。

本文由 SwiftGG 翻譯組翻譯,已經獲得作者翻譯授權,最新文章請訪問 swift.gg

相關文章