為什麼使用列舉作為配置項(enum as configuration)是反開發模式的

蘇大盒子發表於2018-06-19

翻譯自:Enums as configuration: the anti-pattern

實現開閉原則

我經常看到有 Objective-C(偶爾也有 Swift)的設計中用到一種模式:使用列舉型別(enum)作為一個類的配置項。比方說,傳遞一個enumUIView來確定一個顯示的樣式。在這篇文章裡,我會解釋為什麼我認為這種做法是反設計模式的,並且我會給出一個更強健、模組化,擴充套件性更好的方式來解決這個問題。

配置項帶來的問題

我們先來看看列舉到底會產生什麼問題。假設我們有一個類用在不同的場景中,每一個場景需要一個略微不同的配置項。於是在不同的場景下這個類的行為應該也是不一樣的。這個類可能是一個view,一個網路客戶端類,或者其他。類實現好了以後,使用者可以指定或者根據不同的業務需求建立和配置這個類,而不需要去關心和修改這個類的任何實現細節。

提醒:接下來的例子用的是 Swift 3.0,但是對於 Objective-C 來說也是適用的。實際上我們討論的這個話題對於任何語言都是適用的。

舉一個簡單熟悉的例子——UITableViewCell。假設我們有個cell是由一張image、一組label和一個accessory view組成佈局的。由於這個佈局有一定的通用性,所以我們希望重用這個cell來顯示我們App中不同的介面。比方說我們給登入檢視設計了特定顏色、字型等配置的cell。然而當我們在設定檢視重用這個cell的時候,我們希望其顏色、字型等配置是不同的。用到這個cell的介面需要這個cell下的subview的layout是差不多的,但是要有不同的視覺效果。

用列舉來配置

根據上文中的問題,我們可能會設計下面這樣的程式碼:

enum CellStyle {
    case login
    case profile
    case settings
}

class CommonTableCell: UITableViewCell {
    var style: CellStyle {
        didSet {
            configureStyle()
        }
    }

    // ...

    func configureStyle() {
        switch cellStyle {
        case .login:
            // configure style for login view
            textLabel?.textColor = .red()
            textLabel?.font = .preferredFont(forTextStyle: UIFontTextStyleBody)

            detailTextLabel?.textColor = .blue()
            detailTextLabel?.font = .preferredFont(forTextStyle: UIFontTextStyleTitle3)

            accessoryView = UIImageView(image: UIImage(named: "chevron"))
        case .settings:
            // configure style for settings view
            textLabel?.textColor = .purple()
            textLabel?.font = .preferredFont(forTextStyle: UIFontTextStyleTitle1)

            detailTextLabel?.textColor = .green()
            detailTextLabel?.font = .preferredFont(forTextStyle: UIFontTextStyleCaption1)

            accessoryView = UIImageView(image: UIImage(named: "checkmark"))
         case .profile:
            // configure style for profile view
            // ...
        }
    }

    // ...
}

class SettingsViewController: UITableViewController {
   // ...

   func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
      // create and configure cell
      cell.style = .settings
      return cell
   }

   // ...
}
複製程式碼

我們建立了UITableViewCellUITableViewController的子類,並且定義了一個樣式的enum。並且在每個不同的VC下建立cell後我們設定了合適的樣式。很簡單,是吧?

為什麼列舉的設計很爛

當設計一個庫或者框架的時候,“列舉作為配置項”的模式通常對使用者來說是提升了靈活性的——“看看給你提供的這些配置項!”。毫無疑問這是一個出於好意的設計,但是不要被其表象矇蔽了。我們的目的是設計一個真正模組化和適配性好的API,但是得到的卻是一個有很多不必要的限制,難以維護並且非常容易出錯的結果。

這種設計模式“靈活”的原因在於你可以“設定任何你想要的樣式”,但是恰恰相反的是,列舉本身的定義就是不靈活的——列舉值的數量是有限的。在剛剛說到的例子當中就是,cell的樣式數量是有限的。如果你的App中有部分是這麼設計的話,每次你遇到一個新的場景需要用到這個cell,你需要增加一個caseCellStyle中並且更新那個龐大的switch語句。

如果這發生在一個庫中,使用者則沒有辦法去增加一個case到庫裡來定義他們自己的樣式。使用者不得不去給庫的作者發起一個pull request來增加一個列舉項。更進一步說,即使是庫的作者給列舉增加了一個項,從技術上來說對這個庫也是一個破壞性的改變——如果有一個使用者在程式的某個地方用switch語句用到了這個列舉,這個時候編譯器就會提示語法錯誤,因為在 Swift 中 switch 語句必須是完全的。

而在 Objective-C 中的情況會更糟糕——因為不完全的switch語句不會報錯,很容易遇到忽略掉的break;並錯誤地走到下一個case中。當然,你可以通過開啟clang的一些警告配置-Wcovered-switch-default-Wimplicit-fallthrough-Wassign-enum-Wswitch-enum,來減少這些問題。但是我不認為這樣就能解決問題。

這種方法脆弱且強制,會導致產生很多重複冗餘的程式碼。我們可以處理得更好一些。

配置模型

與其被列舉的種種問題折騰,我們不如用一種被稱為控制反轉(Inversion of Control,英文縮寫為IoC)的設計模式來讓我們的API更開放。繼續上面的例子,如果我們建立一個全新的模型來表示我們的cell樣式呢?程式碼如下:

struct CellStyle {
    let labelColor: UIColor
    let labelFont: UIFont
    let detailColor: UIColor
    let detailFont: UIFont
    let accessory: UIImage
}

class CommonTableCell: UITableViewCell {
    // ...

    func apply(style: CellStyle) {
        textLabel?.textColor = style.labelColor
        textLabel?.font = style.labelFont

        detailTextLabel?.textColor = style.detailColor
        detailTextLabel?.font = style.detailFont

        accessoryView = UIImageView(image: style.accessory)
    }

    // ...
}
複製程式碼

我們用一個struct替代列舉來表示我們的cell樣式。這樣做不僅僅清楚地定義了所有樣式的屬性,並且可以用一種更簡潔、更宣告性的方式,將這些屬性直接對映到cell上。並且,我們還可以把這個struct型別作為designated initializer的引數。

我們已經從這個類中移除了成噸的複雜程式碼,留下的只有更簡潔、易讀、易懂的程式碼。有一個定義清晰,樣式屬性和cell的屬性一一對應的結構體,我們不需要再維護那個巨大的switch語句,並且也不需要再面對其帶來的語法問題。同時,使用者不僅僅可以使用無限多的樣式,同時當有新的樣式需求時不再需要去修改類本身的程式碼,也不需要對封裝好的庫造成破壞性的改變。

預設和自定義屬性

這種設計更高階的另一個原因是我們可以以一種更純粹並且沒有破壞性的方式去設定預設值。Swift的一些特性在這裡簡直閃閃發亮——引數預設值、extensionstype inference。這門語言是如此的貼合這個設計模式,與之相比Objective-C就顯得笨重、乏味和冗餘了。

在Swift中,我們可以這樣設定預設值:

struct CellStyle {
    let labelColor: UIColor
    let labelFont: UIFont
    let detailColor: UIColor
    let detailFont: UIFont
    let accessory: UIImage

    init(labelColor: UIColor = .black(),
         labelFont: UIFont = .preferredFont(forTextStyle: UIFontTextStyleTitle1),
         detailColor: UIColor = .lightGray(),
         detailFont: UIFont = .preferredFont(forTextStyle: UIFontTextStyleCaption1),
         accessory: UIImage) {
        self.labelColor = labelColor
        self.labelFont = labelFont
        self.detailColor = detailColor
        self.detailFont = detailFont
        self.accessory = accessory
    }
}
複製程式碼

對於用到的庫已經用列舉來定義配置了,可以用extension來這樣處理:

extension CellStyle {
    static var settings: CellStyle {
        return CellStyle(labelColor: .purple(),
                         labelFont: .preferredFont(forTextStyle: UIFontTextStyleTitle1),
                         detailColor: .green(),
                         detailFont: .preferredFont(forTextStyle: UIFontTextStyleCaption1),
                         accessory: UIImage(named: "checkmark")!)
    }
}

// usage:
cell.apply(style: .settings)
複製程式碼

正如在前面提到的,使用者可以通過增加一個extension更簡單地去得到他想要的樣式。甚至他們還可以選擇只過載其中的一部分預設屬性:

extension CellStyle {
    static var custom: CellStyle {
        // uses default fonts
        return CellStyle(labelColor: .blue(),
                         detailColor: .red(),
                         accessory: UIImage(named: "action")!)
    }
}
複製程式碼

配置項作為行為

我們之前的例子是集中在設定一個view的樣式,我需要強調的是這個強大的模式還可以用與其他的行為。假設一個類用於響應網路。這個類的配置項可以指定協議、重連和失敗策略、快取大小等等。在以前你可能定義一大串獨立的屬性,而現在你可以把這些屬性打包到一個整體中,並提供預設值和允許自定義。

真實的案例

機智的讀者可能會想到,URLSessionURLSessionConfiguration不就是這麼設計的麼?這也是這個API能取代過時的NSURLConnection的原因之一。我們來看看URLSessionConfiguration提供的三個配置項:.default,.ephemeral,和.background(withIdentifier:)。它同樣允許你自定義屬性,想象一下如果用列舉來設計的話侷限性會有多大。

我們來看看另一個例子——UIPresentationController。這個API讓我們通過建立自定義的presentation controllers來定製VC的展示。以前這個API受限於其是用列舉設計的。唯一能用的只有一個叫UIModelPresentationStyle的列舉定義。正如我們之前分析的,這對於使用者來說太不靈活了。但是UIKit並沒有在其新版的API裡100%地修復這個問題。仍然有部分的公共API依賴於UIModelPresentationStyle的值:

func adaptivePresentationStyle(for traitCollection: UITraitCollection) -> UIModalPresentationStyle
複製程式碼

這個方法要求你返回一個UIModelPresentationStyle的值來指定UITraitCollection的樣式。我們在這裡能做的僅僅就是隨意地返回一個UIModelPresentationStyle。如果你對這個例子感興趣,可以在這裡找到我對這些API的研究.

最後一個例子,讓我們看看 JSQMessagesViewController的升級進化。這個庫很老的一個版本中,提供了一個列舉來決定時間戳在訊息介面的顯示樣式,JSMessagesViewTimestampPolicy。而現在,在訊息氣泡中的文字顯示方式顯示時機,是由一個data sourcedelegate來決定的。使用者不僅僅可以精確地確定何時顯示這些label,還能狗配置時間戳的顯示樣式。API僅僅是要求使用者配置一些文字就行了。你可能會注意到這個例子中並沒有用到我們上面提到的配置項的struct物件。取而代之的是用了dataSourcedelegate來擔當這個角色——這正是我們通過反轉控制的模式為使用者提供更強大簡潔的API設定配置項的另一種方法。

結論

這篇文章是open/closed principle(開閉原則) — the “O” in SOLID的一種實現。

軟體實體應當對擴充套件開放,對修改關閉。就是說,這個實體的原始碼可以擴充套件,但是不能被修改。

我們已經看到嘗試用列舉的設計來實現這個原則對使用者來說限制頗多,並且易出錯切難以維護。但是使用配置項物件或者data sourcedelegate則可以簡化程式碼,杜絕錯誤且易於維護,同時提供了一個模組化和可擴充套件的API給使用者,避免了破壞性的改變。 你的App可以定製什麼型別的樣式、配置項或者行為?可以開始重構程式碼啦。?

相關文章