让我们使用 Swift 来给那些老旧的 Objective-C API 注入新的活力吧!在 try! Swift 的本次讲演当中,Daniel Eggert 给出了一个使用 Core Data 的示例,展示了如何通过使用协议和协议扩展,让代码更具有可读性、更少出错。
今天我要谈论的是 现代化的 Core Data 。不过,本次讲演的核心并不是 Core Data;这次讲演是关于如何在 现代化的 Swift 代码 当中使用那些老旧的 API 的( Swift 用起来非常有趣,我们同样也希望让老旧的 API 也同样有趣! )。
本次讲演主要有两个目标:1) 让代码更易读,2) 让代码更少出错。为此,我们将使用两个工具:协议和协议扩展来帮助我们实现目的。
我们将使用 Core Data 来作为示例;这个是个绝佳的例子,因为 Core Data 已经有 12 年的历史了,专门为 Objective-C 而生,专门为 Objective-C 而写,并且它是动态的,而且还不是类型安全的。在 Swift 当中,我们并不希望出现太多的动态内容,并且我们也希望使用类型安全的内容。让我们看一看如何将这两个世界桥接在一起。去年我同 Florian 一起写了一本书;我将为大家展示书中的几个例子,因为它是百分百用 Swift 写的,但是写的内容全是关于 Core Data 的。
我们希望保持既有 API 的设计理念( 我们同样还希望我们的代码既优秀又易读 ),但是代码又不脱离 Core Data 的设计,还要求易于使用。
在 Core Data 当中,实体 (Entity) 和类 (Class) 之间拥有非常强的动态耦合 (dynamic coupling) 关系。实体是您所定义的数据模块所在的地方;类( 您的 Swift 类 )是您自定义逻辑的地方。通常情况下,实体和类之间是一对一映射的:一个实体直接映射到一个类上。因为 Core Data 和 Objective-C 的历史遗留问题,这段代码看起来会非常奇怪,特别是您想要在 Core Data 中插入一个新对象的时候( 如下所示 )。
let city = NSEntityDescription .insertNewObjectForEntityForName("City", inManagedObjectContext: moc) as! City
这里有三个东西是我很不喜欢的:1) 这段代码真的长,2) 您需要使用 “city” 字符串,这导致编译器不能在我输入错误的时候帮助我指出这个错误,以及 3) 最后我们要执行类型强制转换( 这看起来非常丑,我们不喜欢在 Swift 代码当中出现这种东西! )。
let city: City = moc.insertObject()
如果我们要插入一个 City 对象,我们只需要调用 insertObject()
即可。这样做的话,Swift 编译器就可以帮助我们做很多繁琐的工作:
首先,我们需要创建一个协议 ManagedObjectType
;该协议定义了 entityName ( 也就是之前的那坨字符串 )。
protocol ManagedObjectType { static var entityName: String { get } }
我们回到我们的 City 类当中来( ManagedObject
类),这时候我们希望让 City 类能够实现这个协议。我们对 City 类进行了扩展,然后实现了这个协议,也就是说, entityName 为 “City”。
final class City: ManagedObject { @NSManaged public var name: String @NSManaged public var mayor: Person @NSManaged public var population: Int32 } extension City: ManagedObjectType { static let entityName = "City" }
我们已经将 City 实体与 City 类建立了关联( 如上所示 );现在我们就可以对上下文 (context) 进行扩展,然后添加我们之前所使用到的方法:
extension NSManagedObjectContext { func insertObject<A: ManagedObject where A: ManagedObjectType>() -> A { guard let obj = NSEntityDescription .insertNewObjectForEntityForName(A.entityName, inManagedObjectContext: self) as? A else { fatalError("Entity /(A.entityName) does not correspond to /(A.self)") } return obj } ... }
( 我不会详细介绍所有的细节 )这本来是我们之前所撰写的旧有代码,但是现在我们将其很好地封装了起来。我们可以在这里提取 City 字符串( 我们此前就获取得了 ),同样还可以执行相应的类型转换。这些东西都可以很好地藏在一个地方。
一旦我们使用了这类既优秀又易读的代码的话,那么几乎是不可能会犯错的。
let city: City = moc.insertObject()
键值编码 (Key Value Coding) 是一个 很有历史的玩意儿 了,尤其是在 Swift 代码当中,它显得与其格格不入。键值编码是非常动态化的,在十二年前它就是所谓的热门。它被 Core Data 用于键值观察 (Key value observing),但是它很容易出现错别字和错误,并且它并不是类型安全的( 我们不喜欢这种情形! )。让我们看一看在 Core Data 中它是什么样的:
final class City: ManagedObject { @NSManaged public var name: String @NSManaged public var mayor: Person } func hasFaultForRelationshipNamed(name: String)
如果我们回顾一下我们的 City 类,我们可能会想要使用这个 Core Data 方法,即 hasFaultForRelationshipNamed
,这意味着我们必须要传递某个参数进去,而这个参数是一个字符串。我们可能会这样用:
final class City: ManagedObject { @NSManaged public var name: String @NSManaged public var mayor: Person } func doSomething(city: City) { if city.hasFaultForRelationshipNamed("mayor") { // Do something... } }
我们执行了一些“操作”,我们必须要将匹配我们属性的字符串 “mayor” (对应 mayor 属性)传递到这个方法当中。再次强调,编译器并不能帮助我们识别这里出现的错误;如果这里发生了错误,那么在运行时它就会发生崩溃。
为了向您展示这种用法为何非常糟糕,Core Data 还包含了这些全都在使用键值编码的方法( 具体请查看视频 )……我们想要对其进行改进( 具体请查看下方内容 )。
if city.hasFaultForRelationshipNamed(.mayor) { // Do something... }
这种写法非常易读( 与之前的例子类似 ),但是我们还是希望让编译器能够检查 (.mayor)
是合法的键值,并且 Xcode 还能够自动补全。 我们该如何办到这一点呢?
我们以协议开始,即 KeyCodeable
;它只有一个别名 Key :
protocol KeyCodable { typealias Key: RawRepresentable }
我们回到 City 类当中,我们对其进行扩展,让其实现该协议。
final class City: ManagedObject { @NSManaged public var name: String @NSManaged public var mayor: Person }
现在我们在 City 当中拥有了一个嵌套的类型,也就是说 Key 是属于 City 的,我们在其中包含了它的两个属性( name 和 mayor ),将其定义为枚举值。
final class City: ManagedObject { @NSManaged public var name: String @NSManaged public var mayor: Person } extension City: KeyCodable { public enum Key: String { case name case mayor } }
接下来我们使用协议扩展( 如同我们之前做的那样 )来构建一个简单的版本。 hasFaultForRelationshipNamed
方法现在接收的是 Key 类型而不是字符串类型。
extension KeyCodable where Self: ManagedObject, Key.RawValue == String { func hasFaultForRelationshipNamed(key: Key) -> Bool { hasFaultForRelationshipNamed(key.rawValue) } [...] }
接下来我们就可以在下面实现这个方法了,它接受 Key 类型为参数,编译器会告诉我们输入是否有效,并且 Xcode 还会为我们提供自动补全功能。现在我们已经实现了类型安全的键值编码。
if city.hasFaultForRelationshipNamed(.mayor) { // Do something... }
对于 City 类来说,如果我们想要检索所有城市的话,我们通常会这么做:
final class City: ManagedObject { @NSManaged public var name: String @NSManaged public var mayor: Person @NSManaged public var population: Int32 } let request = NSFetchRequest(entityName: "City") let sd = NSSortDescriptor(key: "population", ascending: true) request.sortDescriptors = [sd]
这看起来并不是很糟糕(只有三行代码)……但是仍然还是有些不那么如意。我们可以对其进行优化!:muscle:
final class City: ManagedObject { @NSManaged public var name: String @NSManaged public var mayor: Person @NSManaged public var population: Int32 } let request = City.sortedFetchRequest
我们只需简单地请求 City 类,直接给我们 sortedFetchRequest 就可以了。所有的逻辑都已经完全封装到里面了;再强调一遍,这种做法更容易阅读,并且更难以犯错误。 我们该如何实现这一点呢?
protocol ManagedObjectType { static var defaultSortDescriptors: [NSSortDescriptor] { get } }
我们使用相同的协议,向里面添加 defaultSortDescriptors
属性,它负责将类与类之间的逻辑顺序关联起来。
对于 City 类来说我们只需要这样做:让 City 类实现我们的这个协议,这样它的默认排序将会按照人口进行排序。
extension City: ManagedObjectType { static var defaultSortDescriptors: [NSSortDescriptor] { return [NSSortDescriptor(key: City.Key.population.rawValue, ascending: true)] } }
接下来我们就可以实现这个很优雅的 sortedFetchRequest 方法了,只要使用协议扩展就行(用另一个扩展),我们可以将 entityName 提取出来( 因为我们在之前已经完成了这个操作 ),我们获取这个 defaultSortDescriptors
,接着返回检索请求即可。
extension ManagedObjectType { static var sortedFetchRequest: NSFetchRequest { let request = NSFetchRequest(entityName: entityName) request.sortDescriptors = defaultSortDescriptors return request } }
4:优点:moneybag::moneybag::moneybag:
使用短短一行代码: let request = City.sortedFetchRequest
,我们就完成了对 City 创建检索请求的操作。如果我们有一个 Person
类的话: let request = Person.sortedFetchRequest
同样也是有效的,并且它看起来很优雅、很简洁、通俗易懂。我们一直尽力在让代码更加易懂。
在 Core Data 中,我们通常在检索的时候使用谓词 (Predicates);我们可以将两者合二为一,然后创建一个新的方法: sortedFetchRequestWithPredicateFormat
:
let request = City.sortedFetchRequestWithPredicateFormat("%K >= %ld", City.Key.population.rawValue, 1_000_000) extension ManagedObjectType { public static func sortedFetchRequestWithPredicateFormat( format: String, args: CVarArgType...) -> NSFetchRequest { request = sortedFetchRequest() let predicate = withVaList(args) { NSPredicate(format: format, arguments: $0) } request.predicate = defaultPredicate return request } }
我们使用既有的 sortedFetchRequest 方法(我们之前就创建的),然后创建一个谓词,然后将其返回即可。我们可以使用这种方式让代码更加易读。
NSValueTransformer 是 Foundation API 的一部分,它同样也可以用在 Core Data 当中,并且还可以用于进行绑定操作(AppKits)。当然,在 Swift 世界当中这玩意儿也是非常诡异的:您需要使用继承才能够使用它,并且它还不是类型安全的。
让我们来看一个例子:我们需要使用一个 UUID,并且是使用字符串来进行表示的,当然也有可能是从服务器获得的一串字符串,然后我们希望将其转变为 UUID,也就是将字符串与原字节之间相互转换。如果用传统方法的话,您可能会这么做:
final class UUIDValueTransformer: NSValueTransformer { override static func transformedValueClass() -> AnyClass { return NSUUID.self } override class func allowsReverseTransformation() -> Bool { return true } override func transformedValue(value: AnyObject?) -> AnyObject? { return (value as? String).flatMap { NSUUID(UUIDString: $0) } } override func reverseTransformedValue(value: AnyObject?) -> AnyObject? { return (value as? NSUUID).flatMap { $0.UUIDString } } } let transformer = UUIDValueTransformer()
您需要使用继承,然后实现这四个方法,然后将其实例化。这看起来并不是很糟糕,但是我们可以做得更好:
我们希望有这样一个 ValueTransformer ,其中的闭包可以将字符串转换为 NSUUID ,另一个闭包可以将 NSUUID 转回字符串:
let transformer = ValueTransformer(transform: { return NSUUID(UUIDString: $0) }, reverseTransform: { return $0.UUIDString })
需要注意的是:我们并不需要知道正在转换的类型是什么;相反,Swift 编译器可以帮助我们很好地做到这一点。首先,第一部分是这样:
class ValueTransformer<A: AnyObject, B: AnyObject>: NSValueTransformer { typealias Transform = A? -> B? typealias ReverseTransform = B? -> A? private let transform: Transform private let reverseTransform: ReverseTransform init(transform: Transform, reverseTransform: ReverseTransform) { self.transform = transform self.reverseTransform = reverseTransform super.init() } }
这是一个包含两个类型 A 和 B 的泛型类,也就是我们需要相互转换的两个类型。然后我们有两个闭包,从 A 转换为 B,以及从 B 转换为 A。我们同样还实现了这四个方法。
然而,我们可以以这种非常优雅的方式来实现和实例化 NSValueTransformer ( 非常的现代化,也非常的 Swift 化 )。
let transformer = ValueTransformer(transform: { return NSUUID(UUIDString: $0) }, reverseTransform: { return $0.UUIDString })
闭包 (Blocks) 与我们所使用的某些 API 相比,是一个非常新颖的东西。Core Data 已经有十二年的历史了,而闭包可能只有 5 到 6 年的历史;一旦我们换上 Swift 的话,使用闭包将变为一件很有意思的事情。
我想要给大家展示的例子是存储:
make changes make some more changes make even more changes try moc.save()
在 Core Data 中我们有一个方法是用来保存您所做的更改的,它的名字很简单:save。您或许会执行某些更改,当您结束更改之后,您就会通知上下文去保存您所做的更改。这个方法相当简单,但是我们还可以做得更好。
moc.performChanges { make changes make some more changes make even more changes }
我们通知上下文我们希望做一些改变,然后我们就将我们所做的所有改变封装到了一个简单的闭包当中。这样做很容易理解,因为我们可以看到所有的更改都封装到了一个闭包里面。
这是一个非常好的模式,清晰易读,很难犯错,并且实现也是非常的简单:
extension NSManagedObjectContext { public func performChanges(block: () -> ()) { performBlock { block() self.saveOrRollback() } } }
我们添加了这个 performChanges 方法,它只是简单地调用这个闭包然后执行存储即可。如果您创建了类似的闭包封装的话,那么 UIApplication API 会变得更加易用。
Core Data 使用 NSManagedObjectContextObjectsDidChangeNotification
来观察变化,这让我们能够使用灵活的方式来构建代码。只要 Core Data 当中有对象发生了变化(无论是谁做出的更变操作,也无论它为什么这样做),NSNotificaiton 都会触发,这样我们就可以将我们的代码与之关联,保证 UI 能够实时更新。
通常情况下,我们会这样多次实现这个功能:
func observe() { let center = NSNotificationCenter.defaultCenter() center.addObserver( self, selector: "cityDidChange:", name: NSManagedObjectContextObjectsDidChangeNotification, object: city) } @objc func cityDidChange(note: NSNotification) { guard let city = note.object as? City else { return } if city.deleted { navigationController?.popViewControllerAnimated(true) } else { nameLabel.text = city.name } }
我们使用了 NSNotificationCenter.defaultCenter()
,为其添加了一个观察者,设置 selector ,然后将通知名称传递进去(在本例当中,我们传递的是我们想要观察的 city 对象名称)。我们随后就使用 cityDidChange 方法,我们将对象从 notificaiton 当中提取出来,然后检查其是否是 City 类型。 我们基本完成了观察的操作,但是我们可以做到更好 。
observer = ManagedObjectObserver(object: city) { [unowned self] type in switch type { case .Delete: self.navigationController?.popViewControllerAnimated(true) case .Update: self.nameLabel.text = city.name } }
我们创建了一个 ManagedObjectObserver ,传递进去 City 对象,然后接着,当对象发生变更的时候,这个闭包都会运行。我们就会进行检查:“它是否被删除掉了?”。然后我们就弹出视图控制器。如果 city 发生了改变,我们就用新的 city.name 来更新 nameLabel 的文本值。这是非常易读、也是非常容易理解的。
我们该如何实现这个功能呢? 这里我就要偷个懒了,给大家展示一下图片 :
extension NSManagedObjectContext { public func addObjectsDidChangeNotificationObserver(handler: ObjectsDidChangeNotification -> ()) -> NSObjectProtocol { let nc = NSNotificationCenter.defaultCenter() let name = NSManagedObjectContextObjectsDidChangeNotification return nc.addObserverForName(name, object: self, queue: nil) { handler(ObjectsDidChangeNotification(note: $0)) } } }
我们通过添加这个辅助器来观察通知。我们获取默认的通知中心,然后添加通知名称从而添加实际的观察期。在最后一行,我们使用了一个封装,这让我们能够享受到类型安全的优点。这个封装是一个简单的 Swift 结构体,它其中唯一的一个属性就是它所封装的通知本身,它的名称就是 ObjectsDidChangeNotification
。
为了使用这个结构体,我们需要添加一个类型安全的属性。这个通知当中的 userInfo 字典包含有内容,我们将会用一种类型安全的方法将其提取出来。如果您需要获取这些对象的话,那么我们在这个辅助类结构体上还提供了类型安全的插入对象方法:
public struct ObjectsDidChangeNotification { private let notification: NSNotification init(note: NSNotification) { assert(note.name == NSManagedObjectContextObjectsDidChangeNotification) notification = note } public var insertedObjects: Set<ManagedObject> { return objectsForKey(NSInsertedObjectsKey) } public var updatedObjects: Set<ManagedObject> { return objectsForKey(NSUpdatedObjectsKey) } public var deletedObjects: Set<ManagedObject> { return objectsForKey(NSDeletedObjectsKey) } [...] }
突然之间,我们的代码就更加 Swift 化,也更加易于使用了。这些都是这个辅助器当中的一些例子,我们通过创建它们来让代码变得更加 Swift 化。
我们使用了协议和协议扩展来让我们的代码更加易读。
我们同样还是用了其他的一些小技巧,但是其主要依据是:您可以在您的代码当中创建一些小的辅助类,从而让生活更加美好,同时也让别人能够更容易阅读您的代码。使用旧有的 API 是很不错的,因为这些使用方式已经得到了实战检验;这些代码已经存在了很多年……并且出现的问题都已经得到了修复。但是,我们可以让这些 API 更加好用( 并且让我们的生活更加美好 )。
当我们创建完这些辅助类之后,最重要的一点就是 保持原来设计理念 。我们要让别人能够轻松地阅读我们的代码,就算它们不知道这些封装里面是如何实现的,这都是无所谓的。
问:我看到一些人使用 Core Data 的时候是使用结构体而不是 NSManagedObject,它们使用结构体进行了封装,我自己也对其进行了实验,我很喜欢这种做法,不过很明显,我对 Core Data 的内部实现就不甚了解了,这是不是一个很糟糕的主意呢?感觉这些 Swift 化的方式可能会让我们失去 Core Data 原本的灵魂,您对此有什么看法呢?我们是否应该这样做呢?
Daniel:这是一个很好的问题,Chris。通常在 Core Data 当中您应该设置一些属性。在这个 City 类当中我们设置了一个 name 属性和一个 mayor 属性,然后将其放到 NSManagedObject 子类当中。很多人一直在尝试将这些数据放到 Swift 结构体当中,这样就可以更加 Swift 风格化。Core Data 需要大量使用管理对象 (managed object) 和持久化存储协调器 (Persistence store coordinator),从而才能为您带来性能方面的优势。一旦您将其转变为结构体,那么您就丧失了性能方面的提升。关于这一点我可以讲半个小时,但是它们之间的最重大区别就在于性能。如果您的对象数量不多的话,那么将其转变到结构体当中是一个挺好的选择,但是您必须要意识到:一旦您的应用很庞大的话,那么这个做法很可能是一个糟糕的主意,因为您的性能将会被严重拖缓。这就是我的一个简要的回答。
问:您能回到前面的幻灯片那里吗,也就是您说有既有 API 的地方。您在那里故意写错了 “existing” 这个单词 (“exiting),我想知道的是,您是否认为这个既有的 API 将会消失,被 Swift 当中更容易的 API 所取代吗?
Daniel:没错,这也是一个很好的问题。我当然不是这么认为的,这正是我所想提及的一点。Core Data 在很多年前就出现了,它在 Swift 世界当中是非常奇怪的。但是您需要记住,这类代码已经被大量使用了,不仅仅是在成千上万个 iOS 应用当中,在此之前,Mac 上很多应用都使用了 Core Data。苹果在它们的应用中也大量使用这个 API,并且十二年来它们还有一个团队专门负责修复这个 API 的 BUG。如果苹果说:“我们要做一个新的东西来替代 Core Data”,那么您在 2028 年就可以成功看到这一幕场景了,并且它会变得和 Core Data 一样牢固。我觉得替代 Core Data 并不现实,当然如果您需要在应用中存储内容的话,您需要评估一下 Core Data 是否满足您的需求。它同样还有其他的解决方案,您只需要将合适的砖头放到合适的位置上就可以了。如果 Core Data 能够满足大家的需求,那么我不会认为苹果会去写一个东西去替代它。它的功能已经比较完善了。
See the discussion on Hacker News .