解析既有的异构数据 (Heterogeneous Data) 一直是 Swift 社区中的一个重要的话题。这 2 年来涌现出了很多不同的解决方法,Keith 将会探讨我们现在的处境,以及 Swift 如今的语言特性是如何借助不可变模型 (Immutable Model) 来提供一个更清爽、更安全的解决方案的。
See the discussion on Hacker News .
Transcription below provided by Realm: a replacement for SQLite & Core Data that makes building iOS apps a joy. Check out the docs!
Get new videos & tutorials — we won’t email you for any other reason, ever.
About the Speaker: Keith Smiley
Keith 是 Lyft 的一名 iOS 工程师,在旧金山工作,他有时间的时候会去向 CocoaPods 贡献代码。
@SmileyKeith
我是 Keith Smiley,我在旧金山的 Lyft 工作。在本文当中,我会解决两个相关的问题:解析 Swift 中的异构数据 (我将偏向于 JSON),以及 Swift 中的常量不变性 (Immutability)。最后,我还会谈一谈您为什么需要使用 Mapper 这个开源库。
首先我要说的是,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"] } }
因为我们没有在构造器中对属性进行初始化,您会注意到,这样很容易会创建出一个”空”用户出来。您可以调用没有参数的构造器方法,这样您仍然可以获得回调值 ( 导致这个问题的来源有很多 ,例如说传递了一个错误的 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 发展到今天,我们所有的属性都是不可变的。这运行起来是没问题的,因为我们会在构造器中对它们进行配置。构造器如果失败的话会抛出错误:如果 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)) }
我认为大家都会同意,就某种意义上来说,常量是一个非常好的东西,因此我不会为其特别争辩什么。相反,我会提到新增的 Mapper 是如何变化的,我们是如何处理模型对象的,以及我们是如何在整个应用当中处理常量模型对象的。
关于不变性的最佳示例就是 无言模型 (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” 中之外) 或者它是如何被更新的。我们同样也将我们所有的模型移到了另一个单独的框架当中。
在我们所有的模型上使用不可变属性的另一个好处是,保证代码拥有一个 良好的味道 (code smell) ,尤其是当您在查看提交请求的时候。如果您看到:
- let pickup: Place? + var pickup: Place?
…这很明显您正在做的工作是错误的,而我们就应该去找到一个更好的解决方案 (而不是让一切都变为变量)。
作为另一个优点,我们现在对于如何使用模型有了一定的限制。在我们原来的 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 进行修改。因此,对于这个行程就不会有更多意想不到的变化发生了 (特别是是否拥有行程决定了我们应用的状态)。我们同样还避免了复制那些,您或许想或许不想在某一个确定的时间和地点使用的这类属性。
当您在写代码的时候,您可以仔细考虑一下:
简洁性是 Mapper 的目标所在。由于我们必须要使用模型对象,因此我们无法达成这里的第 4 点目标,但是随着 Swift 的发展,我们希望能够让其变得更简洁。
问:您是如何处理不变性模型当中的数据一致性的?比如说,对于某个 ride 对象来说,有一个用户与之建立了联系,然后用户用某种方式改变了这个对象,比如说用户编辑了他们的头像信息。那么这个 ride 对象在接收到这个变化通知后应该如何处理呢?在此之前您可能会使用类似 KVO 的东西来实现这个功能。
Keith:我们有一个非常好的解决方案,因为我觉得这是特定于我们应用和我们实现方式的一个问题。当您对属性进行更新的时候,您会从服务器得到所有被改变的信息 (以及相关的变化信息)。我们的应用是完全由服务器驱动的。我们对模型对象没有做任何的持久化处理;如果我们获取到了一个完全不同于之前版本的新东西,那么我们就会改变整个 UI,以及我们应用当中其他相关的东西,以便能够真实反应这个状态的变化。对一般情况下,我们对这种问题并没有很好的解决方案 (也就是您会对数据进行存储,并且某些客户端状态您可能会对其进行恢复),不过我们目前的这个解决方案对我们来说很有用。
See the discussion on Hacker News .
Get new videos & tutorials — we won’t email you for any other reason, ever.