阅读 615

为什么使用枚举作为配置项(enum as configuration)是反开发模式的

翻译自: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可以定制什么类型的样式、配置项或者行为?可以开始重构代码啦。🤓

关注下面的标签,发现更多相似文章
评论
说说你的看法