Tryswift daniel eggert cover

现代化的 Core Data

让我们使用 Swift 来给那些老旧的 Objective-C API 注入新的活力吧!在 try! Swift 的本次讲演当中,Daniel Eggert 给出了一个使用 Core Data 的示例,展示了如何通过使用协议和协议扩展,让代码更具有可读性、更少出错。


概述 (00:00)

今天我要谈论的是现代化的 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 的设计理念,但是让其更易于使用 (02:09)

我们希望保持既有 API 的设计理念(我们同样还希望我们的代码既优秀又易读),但是代码又不脱离 Core Data 的设计,还要求易于使用。

实体与类 (02:31)

在 Core Data 当中,实体 (Entity) 和类 (Class) 之间拥有非常强的动态耦合 (dynamic coupling) 关系。实体是您所定义的数据模块所在的地方;类(您的 Swift 类)是您自定义逻辑的地方。通常情况下,实体和类之间是一对一映射的:一个实体直接映射到一个类上。因为 Core Data 和 Objective-C 的历史遗留问题,这段代码看起来会非常奇怪,特别是您想要在 Core Data 中插入一个新对象的时候(如下所示)。

插入新对象 (03:10)

let city = NSEntityDescription
  .insertNewObjectForEntityForName("City",
    inManagedObjectContext: moc) as! City

这里有三个东西是我很不喜欢的:1) 这段代码真的长,2) 您需要使用 “city” 字符串,这导致编译器不能在我输入错误的时候帮助我指出这个错误,以及 3) 最后我们要执行类型强制转换(这看起来非常丑,我们不喜欢在 Swift 代码当中出现这种东西!)。

优化 (03:37)

let city: City = moc.insertObject()

如果我们要插入一个 City 对象,我们只需要调用 insertObject() 即可。这样做的话,Swift 编译器就可以帮助我们做很多繁琐的工作:

1:创建一个协议

首先,我们需要创建一个协议 ManagedObjectType;该协议定义了 entityName也就是之前的那坨字符串)。

protocol ManagedObjectType {
  static var entityName: String { get }
}

2:让我们的类实现这个协议

我们回到我们的 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"
}

3:向上下文中添加扩展

我们已经将 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
  }

  ...
}

Receive news and updates from Realm straight to your inbox

我不会详细介绍所有的细节)这本来是我们之前所撰写的旧有代码,但是现在我们将其很好地封装了起来。我们可以在这里提取 City 字符串(我们此前就获取得了),同样还可以执行相应的类型转换。这些东西都可以很好地藏在一个地方。

4:最终结果

一旦我们使用了这类既优秀又易读的代码的话,那么几乎是不可能会犯错的。

let city: City = moc.insertObject()

键值编码 (05:18)

键值编码 (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 还包含了这些全都在使用键值编码的方法(具体请查看视频)……我们想要对其进行改进(具体请查看下方内容)。

优化 (06:52)

if city.hasFaultForRelationshipNamed(.mayor) {
  // Do something...
}

这种写法非常易读(与之前的例子类似),但是我们还是希望让编译器能够检查 (.mayor) 是合法的键值,并且 Xcode 还能够自动补全。我们该如何办到这一点呢?

1:创建一个协议

我们以协议开始,即 KeyCodeable;它只有一个别名 Key

protocol KeyCodable {
  typealias Key: RawRepresentable
}

2:添加键值枚举

我们回到 City 类当中,我们对其进行扩展,让其实现该协议。

final class City: ManagedObject {
  @NSManaged public var name: String
  @NSManaged public var mayor: Person
}

现在我们在 City 当中拥有了一个嵌套的类型,也就是说 Key 是属于 City 的,我们在其中包含了它的两个属性(namemayor),将其定义为枚举值。

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
  }
}

3:使用协议扩展

接下来我们使用协议扩展(如同我们之前做的那样)来构建一个简单的版本。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...
}

通过添加默认方法让检索请求变得更完美 (08:33)

对于 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]

这看起来并不是很糟糕(只有三行代码)……但是仍然还是有些不那么如意。我们可以对其进行优化!💪

优化 (09:00)

我们想要的是什么样子的

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 就可以了。所有的逻辑都已经完全封装到里面了;再强调一遍,这种做法更容易阅读,并且更难以犯错误。我们该如何实现这一点呢?

1: ManagedObjectType 协议

protocol ManagedObjectType {
  static var defaultSortDescriptors: [NSSortDescriptor] { get }
}

我们使用相同的协议,向里面添加 defaultSortDescriptors 属性,它负责将类与类之间的逻辑顺序关联起来。

2:实现协议

对于 City 类来说我们只需要这样做:让 City 类实现我们的这个协议,这样它的默认排序将会按照人口进行排序。

extension City: ManagedObjectType {
  static var defaultSortDescriptors: [NSSortDescriptor] {
    return [NSSortDescriptor(key: City.Key.population.rawValue, ascending: true)]
  }
}

3:协议扩展

接下来我们就可以实现这个很优雅的 sortedFetchRequest 方法了,只要使用协议扩展就行(用另一个扩展),我们可以将 entityName 提取出来(因为我们在之前已经完成了这个操作),我们获取这个 defaultSortDescriptors,接着返回检索请求即可。

extension ManagedObjectType {
  static var sortedFetchRequest: NSFetchRequest {
    let request = NSFetchRequest(entityName: entityName)
    request.sortDescriptors = defaultSortDescriptors
    return request
  }
}

4:优点💰💰💰

使用短短一行代码: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 (11:12)

NSValueTransformerFoundation 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()

您需要使用继承,然后实现这四个方法,然后将其实例化。这看起来并不是很糟糕,但是我们可以做得更好:

优化 (12:18)

我们希望有这样一个 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
})

封装 Block API - 存储 (13:54)

闭包 (Blocks) 与我们所使用的某些 API 相比,是一个非常新颖的东西。Core Data 已经有十二年的历史了,而闭包可能只有 5 到 6 年的历史;一旦我们换上 Swift 的话,使用闭包将变为一件很有意思的事情。

我想要给大家展示的例子是存储:

make changes
make some more changes
make even more changes

try moc.save()

在 Core Data 中我们有一个方法是用来保存您所做的更改的,它的名字很简单:save。您或许会执行某些更改,当您结束更改之后,您就会通知上下文去保存您所做的更改。这个方法相当简单,但是我们还可以做得更好。

优化 (14:40)

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 会变得更加易用。

NSNotification - 观察变化(15:47)

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 类型。我们基本完成了观察的操作,但是我们可以做到更好

优化 (17:04)

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 化。

总结 (19:22)

我们使用了协议和协议扩展来让我们的代码更加易读。

我们同样还是用了其他的一些小技巧,但是其主要依据是:您可以在您的代码当中创建一些小的辅助类,从而让生活更加美好,同时也让别人能够更容易阅读您的代码。使用旧有的 API 是很不错的,因为这些使用方式已经得到了实战检验;这些代码已经存在了很多年……并且出现的问题都已经得到了修复。但是,我们可以让这些 API 更加好用(并且让我们的生活更加美好)。

当我们创建完这些辅助类之后,最重要的一点就是保持原来设计理念。我们要让别人能够轻松地阅读我们的代码,就算它们不知道这些封装里面是如何实现的,这都是无所谓的。

问答时间到! (16:32)

问:我看到一些人使用 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 能够满足大家的需求,那么我不会认为苹果会去写一个东西���替代它。它的功能已经比较完善了。

About the content

This talk was delivered live in March 2017 at try! Swift Tokyo. The video was recorded, produced, and transcribed by Realm, and is published here with the permission of the conference organizers.

Daniel Eggert

Daniel loves photography and lives in Berlin. He is one of the cofounders of objc.io. He has been working with all kinds of things related to Cocoa for more than ten years — mostly photo and image processing–related. Daniel worked at Apple for five years, and helped move Photos.app and Camera.app to Core Data.

4 design patterns for a RESTless mobile integration »

close