测试已测试 | ✓ |
语言语言 | SwiftSwift |
许可 | MIT |
发布最后发布 | 2016年11月 |
SwiftSwift 版本 | 3.0.1 |
SPM支持 SPM | ✓ |
由 Károly Lőrentey 维护。
依赖 | |
BTree | ~> 4.0 |
SipHash | ~> 1.0 |
GlueKit 是一个 Swift 框架,用于创建可观察对象并以有趣和有用的方式操作它们。它被称为 GlueKit,因为它允许您将东西粘合在一起。
GlueKit 包含 Cocoa 的 键值编码 和 键值观察 子系统的类型安全的类似物,使用纯 Swift 编写。除了提供基本的观察机制外,GlueKit 还支持完整的 键路径 观察,即可以一次性观察从特定实体开始的一组属性。例如,您可以观察一个人的最好朋友的最喜欢的颜色,这个颜色可能会在一个人得到新的最好朋友或朋友改变主意喜欢哪种颜色时发生变化。
(注意,尽管 GlueKit 的键是函数,所以它们不像 KVC 的基于字符串的键和键路径那么容易序列化。在 Swift 中实现可序列化的类型安全键是完全可能的;但这涉及到一些需要通过代码生成或核心语言增强(如属性行为或改进的反射功能)来处理的样板代码。)
与 KVC/KVO 类似,GlueKit 不仅支持观察单个值,还支持集合,如集合或数组。这包括对键路径观察的全面支持,例如,您可以将一个人的孩子的孩子作为一个单独的集合来观察。这些可观察的集合报告细粒度增量更改(例如,“'foo' 在索引 5 处插入”),允许您高效地对其更改作出反应。
除了键路径观察之外,GlueKit 还提供了一组丰富的可观察对象的转换和组合,这是一个比 KVC 的 集合运算符 更灵活和可扩展的 Swift 版本。例如,给定一个可观察的整数数组,您可以(高效地!)观察其元素的总和;您可以筛选出与特定谓词匹配的元素;您可以得到该数组与另一个可观察数组可观察的连接;您还可以做更多的事情。
您可以使用 GlueKit 的可观察数组高效地为 UITableView
或 UICollectionView
提供数据,包括为它们提供增量更改以支持动画更新。此功能大致相当于 Core Data 中 NSFetchedResultsController
所做的。
GlueKit 是使用纯 Swift 编写的;它不需要 Objective-C 运行时来支持其功能。然而,它确实提供了易于使用的适配器,可以将与 KVO 兼容的 NSObjects 键路径转换为 GlueKit 可观察对象。
GlueKit 尚未正式发布。其 API 仍在变化,文档严重过时且不完整。然而,该项目已接近一个完整的 1.0 版本的功能集;我预期在 2016 年底前能有第一个有用的发布版本。
卡罗利在布达佩斯的 Functional Swift 会议 2016 上做了关于 GlueKit 的演讲。观看视频 或 阅读幻灯片。
对于 Swift 包管理器,请在您的 Package.swift
文件中依赖列表中添加以下条目
.Package(url: "https://github.com/lorentey/GlueKit.git", branch: master)
如果您不使用 CocoaPods、Carthage 或 SPM,则需要克隆 GlueKit、BTree 和 SipHash,并将它们的 xcodeproj
文件添加到您的项目工作区中。您可以随意将这些克隆放在任何位置,但如果您使用 Git 进行应用程序开发,设置为应用程序顶层 Git 仓库的子模块是一个好主意。
要将您的应用程序二进制文件链接到 GlueKit,只需在 Xcode 中将 BTree 项目的 GlueKit.framework
、BTree.framework
和 SipHash.framework
添加到应用程序目标的“常规”页面中的“嵌入式二进制文件”部分。只要 GlueKit 和 BTree 项目文件在您的工作区中引用,这些框架就会列在您点击目标“嵌入式二进制文件”列表中的“+”按钮时打开的“选择要添加的项目”表单中。
除了将框架目标添加到嵌入式二进制文件之外,无需进行任何其他配置。
如果您想在应用程序之外单独对 GlueKit 进行工作,请使用 --recursive
选项克隆此存储库,打开 GlueKit.xcworkspace
,然后开始修改。
git clone --recursive https://github.com/lorentey/GlueKit.git GlueKit
open GlueKit/GlueKit.xcworkspace
一旦在项目中提供了 GlueKit,就需要在您想使用其功能的每个 .swift
文件的顶部导入它
import GlueKit
GlueKit 的一些构造函数可以与离散的响应式框架中的相同构造函数相匹配,例如 ReactiveCocoa、RxSwift、ReactKit、Interstellar 以及其他框架。有时 GlueKit 使用与相同概念相同的名称。但通常不是(抱歉)。
GlueKit 专注于创建一个有用的可观察模型,而不是试图将像可观察对象一样的事物与像任务一样的事物统一。GlueKit 明确不直接尝试对网络操作进行建模(尽管网络支持库当然可以使用 GlueKit 来实现其某些功能)。因此,GlueKit 的源/信号/流概念传输简单的值;它不将它们包装在 Event
中。
我选择创建 GlueKit 而不是仅仅使用已建立性更好且没有错误的库,有几个原因。
GlueKit概述描述了GlueKit的基本概念。
假设你正在编写一个错误跟踪应用程序,该应用程序有一个项目列表,每个项目都有自己的问题集合。使用GlueKit,你将使用Variable
来定义你的模型属性和关系。
class Project {
let name: Variable<String>
let issues: ArrayVariable<Issue>
}
class Account {
let name: Variable<String>
let email: Variable<String>
}
class Issue {
let identifier: Variable<String>
let owner: Variable<Account>
let isOpen: Variable<Bool>
let created: Variable<NSDate>
}
class Document {
let accounts: ArrayVariable<Account>
let projects: ArrayVariable<Project>
}
你可以使用一个let observable: Variable
,就像使用一个var raw: Foo
属性一样,但你需要在写raw
的地方写observable.value
。
// Raw Swift ===> // GlueKit
var a = 42 ; let b = Variable<Int>(42)
print("a = \(a)") ; print("b = \(b.value\)")
a = 7 ; b.value = 7
根据上面的模型,在Cocoa中,你可以在Document
实例中指定访问模型各个部分的关键路径。例如,为了获取所有未排序的问题所有者的电子邮件地址的大数组,你会使用Cocoa关键路径"projects.issues.owner.email"
。GlueKit也能做到这一点,尽管它使用一个特别构建的Swift闭包来表示关键路径。
let cocoaKeyPath: String = "projects.issues.owner.email"
let swiftKeyPath: Document -> AnyObservableValue<[String]> = { document in
document.projects.flatMap{$0.issues}.flatMap{$0.owner}.map{$0.email}
}
(类型声明包括在内,以清楚地说明GlueKit是完全类型安全的。Swift的类型推断可以自动找到这些,所以通常你会省略此类声明中的类型指定。)GlueKit的语法确实更加详细,但作为交换,它是类型安全的,更加灵活,同时也具有扩展性。此外,选择单个值(map
)或一组值(flatMap
)之间的视觉差异会通知你使用此关键路径可能比通常更昂贵。(GlueKit的关键路径实际上是观察者的组合。map
是一个用于构建一对一关键路径的组合器;还有许多其他有趣的组合器可用。)
在Cocoa中,你会使用KVC的访问器方法来获取当前的电子邮件列表。在GlueKit中,如果你给关键路径一个文档实例,它将返回一个AnyObservableValue
,它有一个可访问的value
属性。
let document: Document = ...
let cocoaEmails: AnyObject? = document.valueForKeyPath(cocoaKeyPath)
let swiftEmails: [String] = swiftKeyPath(document).value
在这两种情况下,你都会得到一个字符串数组。然而,Cocoa将其作为可选的AnyObject
返回,你需要自己解除包裹并强制转换到正确的类型(在做这件事的时候,你可能希望掩住鼻子)。糟糕!GlueKit知道结果将是什么类型,所以它直接给你。太好了!
无论是Cocoa还是GlueKit都不允许你更新此关键路径末尾的值;然而,如果你使用Cocoa,你只在运行时发现这一点,而如果你使用GlueKit,你将得到一个漂亮的编译器错误。
// Cocoa: Compiles fine, but oops, crash at runtime
document.setValue("[email protected]", forKeyPath: cocoaKeyPath)
// GlueKit/Swift: error: cannot assign to property: 'value' is a get-only property
swiftKeyPath(document).value = "[email protected]"
你可能很乐意知道一对一关键路径在Cocoa和GlueKit中都是可赋值的。
let issue: Issue = ...
/* Cocoa */ issue.setValue("[email protected]", forKeyPath: "owner.email") // OK
/* GlueKit */ issue.owner.map{$0.email}.value = "[email protected]" // OK
(在GlueKit中,你通常直接使用观察者组合器,而不是创建关键路径实体。所以从现在开始,我们将这样做。序列化类型安全的键路径需要额外的工作,这将更适合由构建在GlueKit之上的未来模型对象框架提供。)
更有趣的是,你可以请求在关键路径的值变化时得到通知。
// GlueKit
let c = document.projects.flatMap{$0.issues}.flatMap{$0.owner}.map{$0.name}.connect { emails in
print("Owners' email addresses are: \(emails)")
}
// Call c.disconnect() when you get bored of getting so many emails.
// Cocoa
class Foo {
static let context: Int8 = 0
let document: Document
init(document: Document) {
self.document = document
document.addObserver(self, forKeyPath: "projects.issues.owner.email", options: .New, context:&context)
}
deinit {
document.removeObserver(self, forKeyPath: "projects.issues.owner.email", context: &context)
}
func observeValueForKeyPath(keyPath: String?, ofObject object: AnyObject?,
change change: [String : AnyObject]?,
context context: UnsafeMutablePointer<Void>) {
if context == &self.context {
print("Owners' email addresses are: \(change[NSKeyValueChangeNewKey]))
}
else {
super.observeValueForKeyPath(keyPath, ofObject: object, change: change, context: context)
}
}
}
嗯,Cocoa这个名字有点绕口,但人们倾向于用自己的抽象方法来称呼它。在这两种情况下,只要项目列表发生变化,或者任何项目的 Issue 列表发生变化,或者任何 Issue 的所有者发生变化,或者某一个账户的电子邮件地址发生改变,就会打印出新的电子邮件集。
为了给出一个更贴近实际的例子,假设你想为项目概览屏幕创建一个视图模型,该屏幕可以显示当前所选择项目的各种有用数据。GlueKit的可观察组合器使得将来自我们模型对象的数据组合起来变得简单。视图模型中生成的字段本身也是可观察的,并能够对其依赖项的变化做出反应。
class ProjectSummaryViewModel {
let currentDocument: Variable<Document> = ...
let currentAccount: Variable<Account?> = ...
let project: Variable<Project> = ...
/// The name of the current project.
var projectName: Updatable<String> {
return project.map { $0.name }
}
/// The number of issues (open and closed) in the current project.
var isssueCount: AnyObservableValue<Int> {
return project.selectCount { $0.issues }
}
/// The number of open issues in the current project.
var openIssueCount: AnyObservableValue<Int> {
return project.selectCount({ $0.issues }, filteredBy: { $0.isOpen })
}
/// The ratio of open issues to all issues, in percentage points.
var percentageOfOpenIssues: AnyObservableValue<Int> {
// You can use the standard arithmetic operators to combine observables.
return AnyObservableValue.constant(100) * openIssueCount / issueCount
}
/// The number of open issues assigned to the current account.
var yourOpenIssues: AnyObservableValue<Int> {
return project
.selectCount({ $0.issues },
filteredBy: { $0.isOpen && $0.owner == self.currentAccount })
}
/// The five most recently created issues assigned to the current account.
var yourFiveMostRecentIssues: AnyObservableValue<[Issue]> {
return project
.selectFirstN(5, { $0.issues },
filteredBy: { $0.isOpen && $0.owner == currentAccount }),
orderBy: { $0.created < $1.created })
}
/// An observable version of NSLocale.currentLocale().
var currentLocale: AnyObservableValue<NSLocale> {
let center = NSNotificationCenter.defaultCenter()
let localeSource = center
.source(forName: NSCurrentLocaleDidChangeNotification)
.map { _ in NSLocale.currentLocale() }
return AnyObservableValue(getter: { NSLocale.currentLocale() }, futureValues: localeSource)
}
/// An observable localized string.
var localizedIssueCountFormat: AnyObservableValue<String> {
return currentLocale.map { _ in
return NSLocalizedString("%1$d of %2$d issues open (%3$d%%)",
comment: "Summary of open issues in a project")
}
}
/// An observable text for a label.
var localizedIssueCountString: AnyObservableValue<String> {
return AnyObservableValue
// Create an observable of tuples containing values of four observables
.combine(localizedIssueCountFormat, issueCount, openIssueCount, percentageOfOpenIssues)
// Then convert each tuple into a single localized string
.map { format, all, open, percent in
return String(format: format, open, all, percent)
}
}
}
(注意,上述某些操作尚未实现。请继续关注!)
每当模型更新或选择另一个项目或账户时,受影响的视图模型中的 Observable 会相应地进行重新计算,并将更新的值通知给其订阅者。GlueKit 以极具效率的方式做到了这一点——例如,关闭项目中的 Issue 只需简单减少 openIssueCount
内的计数器;而不需要从头开始重新计算 Issue 数量。(显然,如果用户切换到新的项目,这一变化将触发从零开始重新计算该项目的 Issue 数量。)Observable 实际上只有在有订阅者时才会进行计算。
一旦有了这个视图模型,视图控制器可以简单地将其 Observable 连接到视图层次结构中显示的各种标签。
class ProjectSummaryViewController: UIViewController {
private let visibleConnections = Connector()
let viewModel: ProjectSummaryViewModel
// ...
override func viewWillAppear() {
super.viewWillAppear()
viewModel.projectName.values
.connect { name in
self.titleLabel.text = name
}
.putInto(visibleConnections)
viewModel.localizedIssueCountString.values
.connect { text in
self.subtitleLabel.text = text
}
.putInto(visibleConnections)
// etc. for the rest of the observables in the view model
}
override func viewDidDisappear() {
super.viewDidDisappear()
visibleConnections.disconnect()
}
}
在 viewWillAppear
中设置连接可以确保在项目概览显示在屏幕上时,视图模型的复杂观察器组合保持最新。
ProjectSummaryViewModel
中的 projectName
属性被声明为 Updatable
,因此您可以修改其值。这样做会更新当前项目的名称。
viewModel.projectName.value = "GlueKit" // Sets the current project's name via a key path
print(viewModel.project.name.value) // Prints "GlueKit"