Slug keith smiley cover cn

拥抱常量的未来

解析既有的异构数据 (Heterogeneous Data) 一直是 Swift 社区中的一个重要的话题。这 2 年来涌现出了很多不同的解决方法,Keith 将会探讨我们现在的处境,以及 Swift 如今的语言特性是如何借助不可变模型 (Immutable Model) 来提供一个更清爽、更安全的解决方案的。


概述 (0:00)

我是 Keith Smiley,我在旧金山的 Lyft 工作。在本文当中,我会解决两个相关的问题:解析 Swift 中的异构数据 (我将偏向于 JSON),以及 Swift 中的常量不变性 (Immutability)。最后,我还会谈一谈您为什么需要使用 Mapper 这个开源库。

Mapper 并不是所谓的 JSON 解析器 (1:03)

首先我要说的是,Mapper 对 JSON 解析实际上并没有做任何处理,比如像 NSJSONSerialization 那样获取一个字符串然后生成一个字典。Mapper 也不允许您从模型对象中创建一个 JSON,因为在如今的 Mapper 工作原理机制当中,我们没有找到一个既是我们所喜欢的,同时也是合适的解决方法。我要重申一遍,我们并不会具体到处理 JSON 层级:

{
"user": {
    "id": "123",
    "photoURL": "http://example.com/123.jpg",
    "phone": {
      "verified": false
    }
  }
}

Mapper 的初始版本 (Swift 1.0 之前) 有不少缺点:我们没有在我们的构造器中对属性进行初始化,所有的东西都是变量,可以发生变化。我们同样还存在有可选属性或者默认值。

可选属性的确是个很不错的东西,例如,photoURL 可以是可选值,因为不是所有用户都会拥有头像,但是考虑到诸如 User.id 字符串之类的设置,这些就不应该为空的。我们虽然可以也将其设置为可选的,然后在代码中小心地进行处理就可以了,但是我们并不知道在 else 语句当中应该做些什么——这不是我们所支持的情况。我们同样也可以让其成为一个普通的、非可选的、带有默认值的字符串 (例如 -1 或者之类的东西)。在我们的应用当中,如果您试图为某个用户执行网络请求的时候,我们需要在路径中,或者在验证过程中使用诸如 ID 之类的东西,如果使用诸如 -1 之类的默认值往往会和没有 ID 这种情况一样,导致应用无法正常运行。当我们从 JSON 中创建用户的时候,我们想要确认我们得到了一个有效的用户 ID;否则的话,我们应该要完全忽略这个设置。

struct User: Mappable {
    var id: String?
    var photoURL: NSURL?
    var verified: Bool = true

init() {}

    mutating func map(mapper: Mapper) {
        id       « mapper["id"]
        photoURL « mapper["photoURL"]
        verified « mapper["phone.verified"]
    }
}

Receive news and updates from Realm straight to your inbox

因为我们没有在构造器中对属性进行初始化,您会注意到,这样很容易会创建出一个”空”用户出来。您可以调用没有参数的构造器方法,这样您仍然可以获得回调值 (导致这个问题的来源有很多,例如说传递了一个错误的 JSON 进入,但是仍然返回了一个 User)。我们希望能够确保这个时候可以说:”这个 JSON 是非法的,让我们忽略它吧!”。

另一个限制就是库的复杂程度。Mapper 是一个协议:

protocol Mapper {
    func baseType<T>(field: T?) -> T?
    func baseTypeArray<T>(field: [T]?) -> [T]?
    // ...
    subscript(keyPath: String) -> Mapper { get }
}

我们得到了一大堆 baseType 函数,它们定义了如何从 JSON 中获取诸如可选值 T? 和数组 [T] 之类特定类型的方式,此外还提供了下标 (subscript) 语法,接受一个字符串作为参数,然后返回一个 Mapper 的实例。我们同样还自定义了运算符。

func « <T>(inout left: T?, mapper: Mapper) {
    left = mapper.baseType(left)
}

func « <T>(inout left: [T]?, mapper: Mapper) {
    left = mapper.baseTypeArray(left)
}

对于下标语法来说,我需要在 Mapper 其自身内部修改这个 currentValue 值,然后正如您期望的那样,无需通过设定强类型信息,就可以将其设置给 JSON,并且返回给 self。这也就是说,baseType 函数已经在您进入到该运算符的时候就已经调用过了。

class MapperFromJSON: Mapper {
    var JSON: NSDictionary
    var currentValue: AnyObject?

    // ...

    subscript(keyPath: String) -> Mapper {
        get {
            self.currentValue = self.JSON.valueForKeyPath(keyPath)
            return self
        }
    }
}

BaseType 函数将会抓取当前值 (参见下方代码),然后对其类型进行检索,然后返回期望的类型。这个长长的 switch 语句存放了多个不同的类型。我们有一个针对 NSURL 的自定义分支 (如果您拥有一个字符串,然后希望其成为一个 URL 的时候,您可以试图创建这样一个分支)。我们同样还有其他的分支,可以很好地处理字符串以及其他 Swift 原始类型。

func baseType<T>(field: T?) -> T? {
    let value = self.currentValue
    switch T.self {
        case is NSURL.Type where value is String:
            return NSURL(string: value as! String) as? T

        // ...

        default:
            return value as? T
    }
}

我们通过一些键定义了一个经纬度坐标,以将其作为子对象 (专门应用于我们的应用中的)。这种做法并不是很理想——因为这会产生很庞大的 switch 语句。因此,我们试图写一个新的 Mapper。我们不想要为了减少实现的复杂性,就破坏了这个库的接口。

case is CLLocationCoordinate2D.Type:
    if let castedValue = value as? [String: Double],
      let latitude = castedValue["lat"],
      let longitude = castedValue["lng"]
    {
        return CLLocationCoordinate2D(latitude: latitude,
            longitude: longitude) as? T
}

    return nil

通过开源的 JSON 解析库,您可以看到很多重复的类型信息。您必须要重新定义这些类型,例如对于一个字符串类型来说,最后您必须要调用 .string 方法,这看起来很糟糕,因为必须得进行复制。Swift 的这个版本中,下标语法是无法添加泛型的。不过我们没有必要去做这件事,因为我们可以使用返回类型推断来进行替代。我们正试图通过 Mapper 去解决这些问题。

id = JSON["id"].string

今日的 Mapper (8:15)

Mapper 发展到今天,我们所有的属性都是不可变的。这运行起来是没问题的,因为我们会在构造器中对它们进行配置。构造器如果失败的话会抛出错误:如果 ID 不存在于 JSON 当中,或者如果它不是一个字符串的话,我们就会以抛出错误结束 (您无法获得返回的用户对象)。

我们同样还避免了”两个 JSON 对象”的发生。我们在构造器中将键编码为属性定义,这意味着我们无法通过调用构造器来反转这个进程。在旧有的 Mapper 当中,我们包含了下标语法定义以及自定义运算符:我们可以在一个已存在的对象上再次调用一遍此函数,然后返回 JSON。我们还可以使用不同的协议或者库来将其复制,不过这种做法我们是不希望出现的。但是这并不是我们通过 API 来更新模型对象的方式。

struct User: Mappable {
    let id: String
    let photoURL: NSURL?
    let verified: Bool

    init(map: Mapper) throws {
        try id       = map.from("id")
            photoURL = map.optionalFrom("photoURL")
            verified = map.optionalFrom("phone.verified") ?? false
    }
}

我们也希望避免过于复杂的实现方式。库将会从 JSON 中获取值域,给定一个特定的字符串,然后就可以获取正确的类型了。函数的实现方式也是类似的,除了您得到的是可选值 T? 之外。

func from<T>(field: String) throws -> T {
    if let value = self.JSONFromField(field) as? T {
        return value
    }

    throw MapperError()
}

我们想要将定义的那些自定义类型分散开来,因此我们创建了 Convertible 协议。如果将其加到 NSURL 的定义中的话,就是:试图获取一个字符串,试图创建一个 URL,然后如果成功的话将其返回;否则,就抛出错误。这是这个存在于 Mapper 库当中的 Convertible 协议唯一需要实现的方法 (其他所有东西都是专门针对于我们应用制定的)。我们在我们的模型层中定义了一个经纬度坐标,但是我们就已经没有必要将其放到开源库当中了。

extension NSURL: Convertible {
    static func fromMap(value: AnyObject?) throws -> NSURL {
        if let string = value as? String,
          let URL = NSURL(string: string)
        {         
            return URL
        }

        throw MapperError()
    }
}

借助泛型函数,我们可以执行相关的转换操作。在下面,我们拥有一个对象 (AppInfo) 以及一个容器,用于控制我们应用中的各种组件,尤其是字符串。”Hints” 存放的用户登录后的一些气泡提示,我们可以通过新的流程来对用户进行引导。服务器通过这个 “Hints” 键,发送给我们一个带有各种提示信息的数组,但是我们真正想要的是我们在上方定义的字典类型,一个 ID 对应一个提示,这样我们就可以从视图控制器中对其进行访问了;我们还可以检查 ID 和展示出来的提示是否满足匹配。这个 toDictionary 转换函数获取一个闭包,这个闭包定义了我们从我们所创建的对象中,该如何获取键值的方式。$0.id 用以产生用在字典当中的 hintID

struct AppInfo {
    let hints: [HintID: Hint]

    init(map: Mapper) throws {
        try hints = map.from("hints",
            transformation: Transform.toDictionary { $0.id })
    }
}

我们拥有定义成相似协议一致性的各种函数 (Swift 并不总是对此很友好;很难告知哪一个函数将要被调用,这非常让人困扰)。不过,所得到的 API 确实很吸引人,并且仍然比之前的实现要简单很多。

func from<T>(field: String,
  transformation: AnyObject? throws -> T) rethrows -> T
{
    return try transformation(self.JSONFromField(field))
}

迎接常量的未来 (13:13)

我认为大家都会同意,就某种意义上来说,常量是一个非常好的东西,因此我不会为其特别争辩什么。相反,我会提到新增的 Mapper 是如何变化的,我们是如何处理模型对象的,以及我们是如何在整个应用当中处理常量模型对象的。

无言模型 (13:47)

关于不变性的最佳示例就是无言模型 (dumb models) 了,将上面的模型转变之后会变成:

struct User: Mappable {
    let id: String
    let photoURL: NSURL?

    init(map: Mapper) throws {
        try id       = map.from("id")
            photoURL = map.optionalFrom("photoURL")
    }
}

这些模型用以建立服务器和客户端之间的映射关系,并且它们不存在任何潜在的疑难杂症。我们不能够在这里通过 didSet 执行某些操作 (而在这里您可以对某个属性进行设置,然后就可以改变这个对象上的某些其他状态),在 didSet 中执行操作可能会导致模型中许多潜在问题的发生。我们通过编译器特性将这个操作进行限制,因为我们正在初始化这个 id 属性,因此即使这个属性是个变量,也不应该有任何的 didSet 被调用。

当我们的应用当中遍布着变量的时候,我们很可能会有很多的 didSet 语句。或许某个属性会对其他属性进行修改,这会导致不确定的情况发生。这种新的方法有助于使代码清晰:您的模型对象只有一个简单的接口。用户无法知道这个模型是如何被创建的 (除了从 “Mapper” 中之外) 或者它是如何被更新的。我们同样也将我们所有的模型移到了另一个单独的框架当中。

代码的味道 (15:03)

在我们所有的模型上使用不可变属性的另一个好处是,保证代码拥有一个良好的味道 (code smell),尤其是当您在查看提交请求的时候。如果您看到:

-    let pickup: Place?
+    var pickup: Place?

…这很明显您正在做的工作是错误的,而我们就应该去找到一个更好的解决方案 (而不是让一切都变为变量)。

独立的创建过程 (15:32)

作为另一个优点,我们现在对于如何使用模型有了一定的限制。在我们原来的 Ride 模型当中,也就是在我们让一切东西变成常量之前 (参见下方的示例),这很容易导致用户创建一个空的 Ride 模型出来:我们可以调用一个空的构造器方法,然后从中获取一个 Ride 实例。既然我们可以采用常量来避免这个问题,并且由于 Ride 和请求 Ride 这个操作共享相似的属性,因此我们可以在两个地方都重用这个模型。因为 Pickup 是变量,这个操作很容易。我们可以在一个 Place 当中设置一个 Pickup,然后将其传递出去 (并最终去请求一个 Ride)。

struct Ride: Mappable {
    var pickup: Place?
}

这会影响到我们应用程序的其余部分。我们用一个叫做 RideManger 的对象来管理当前的 Ride。它当中有很多的单向观察者 (one-way observers),用以更新视图层次或者执行网络调用。接下来,我们就可以使用这么一个”黑科技”,它可以执行诸如决定是否对 pickup 状态进行修改:

RideManager.ride.pickup = Place(name: "Realm")

我们可以说,在应用当中发生了某些改变 (比如说,使用用户的当前位置更新了 pickup 的位置)。我们将 pickup 位置变成了现在这个对应 Realm 的位置,然后在 RideManager 本身当中对这个状态进行了改变。在这种情况下,会发生什么事情呢?它是否应该将变化传递给 UI,更新服务器数据,还是两者一起做,还是两者都不要做?如果用户在路程当中的时候我们执行了这种操作,因为我们对其没有任何限制,而这种操作实际上并没有发生过,这怎么办呢?

这个”黑科技” 会更新 Ridemanager,这会触发管理其本身的 didSet (由于它本身包含了自己的 ride 属性,一旦 ride 发生了改变,那么就会触发 didSet)。这听起来似乎是没有什么缺点,那么您想想那些所有的观察者的时候,事情就大条了。

我们会重新触发所有的观察者。它们都能够执行预期的行为 (例如,当它们得到了一个新的 ride 的时候——如果用户正处于行程当中了,那么我们仍然将其更新为 Realm 的 pickup 地址)。那么是否应该改变 UI,以适应这个 pickup 位置的变化呢?但是万一用户已经被接送了又怎么办?这里没有办法来阻止这种情况的发生。

通过将我们所有的模型完全变成不可变的常量,我们就可以在编译器层面下将这些数据锁定下来。当用户在行程当中的时候,我们是没有办法去更新 pickup 位置的,即使我们希望这么做。行程数据是从服务器传来的,这同时也是唯一能够存在的行程。在我们这里,服务器是真实信息的唯一来源。

我们同样还需要一种新的方式来请求搭车,因此我们创建了一个一次性模型 (不过我只展示了 pickup 属性):我们的 Ride 模型可能有将近 40 多个属性 (包含了接送位置、乘客信息、司机信息等等)。

struct RideRequest {
    var pickup: Place?
}

在请求搭车和行程当中之间,只有一点点的重叠部分。如果您正在请求搭车的话,您只需要使用几个属性就足够了;如果您正处于行程当中的话,您就必须要在人们之间将信息进行传递。对于这种情况,在代码中没有简洁的方式来表达。

现在,服务器端是没有任何的对应部分的——请求搭车是个完完全全的客户端操作。因为您只需要通过几个步骤就可以对这个 ride 进行修改。因此,对于这个行程就不会有更多意想不到的变化发生了 (特别是是否拥有行程决定了我们应用的状态)。我们同样还避免了复制那些,您或许想或许不想在某一个确定的时间和地点使用的这类属性。

简洁性检查清单 (20:02)

当您在写代码的时候,您可以仔细考虑一下:

  1. 我该如何实现这个功能?
  2. 我能否用更简单的方式完成它?
  3. 我能否用更简洁的方式完成它?
  4. 我能否不这么做?

简洁性是 Mapper 的目标所在。由于我们必须要使用模型对象,因此我们无法达成这里的第 4 点目标,但是随着 Swift 的发展,我们希望能够让其变得更简洁。

问题时间到 (21:33)

问:您是如何处理不变性模型当中的数据一致性的?比如说,对于某个 ride 对象来说,有一个用户与之建立了联系,然后用户用某种方式改变了这个对象,比如说用户编辑了他们的头像信息。那么这个 ride 对象在接收到这个变化通知后应该如何处理呢?在此之前您可能会使用类似 KVO 的东西来实现这个功能。

Keith:我们有一个非常好的解决方案,因为我觉得这是特定于我们应用和我们实现方式的一个问题。当您对属性进行更新的时候,您会从服务器得到所有被改变的信息 (以及相关的变化信息)。我们的应用是完全由服务器驱动的。我们对模型对象没有做任何的持久化处理;如果我们获取到了一个完全不同于之前版本的新东西,那么我们就会改变整个 UI,以及我们应用当中其他相关的东西,以便能够真实反应这个状态的变化。对一般情况下,我们对这种问题并没有很好的解决方案 (也就是您会对数据进行存储,并且某些客户端状态您可能会对其进行恢复),不过我们目前的这个解决方案对我们来说很有用。

About the content

This content has been published here with the express permission of the author.

Keith Smiley

Keith is an iOS engineer at Lyft in San Francisco. Previously, he coded at Thoughtbot, and has occasionally clicked the big green button for CocoaPods.

4 design patterns for a RESTless mobile integration »

close