GlueKit

Swift 3 License Platform

Build Status Code Coverage

Carthage compatible CocoaPod Version

⚠️ 警告 ⚠️本项目处于预发布状态。 目前正在进行积极的开发工作,将会导致API发生更改,在完成之前可能会/将导致代码中断。 请谨慎使用。

GlueKit 是一个 Swift 框架,用于创建可观察对象并以有趣和有用的方式操作它们。 它被称为 GlueKit,因为它允许你将东西粘在一起。

GlueKit 包含 Cocoa 的 键值编码键值观察 子系统的类型安全模拟,用纯 Swift 编写。 除了提供基本的观察机制外,GlueKit 还支持完整的键路径观察,其中从特定实体开始的属性序列一次性被观察。(例如,你可以观察一个人的最好的朋友最喜欢的颜色,这可能会在人有了新的最好的朋友时,或者当朋友改变了他们最喜欢的颜色时发生变化。)

(但请注意,GlueKit 的键是函数,因此它们不像 KVC 基于字符串的键和键路径那样容易序列化。在 Swift 中绝对可以实现可序列化的类型安全键;但这涉及一些样板代码,最好由代码生成或核心语言增强(例如属性行为或改进的反射能力)处理。)

与 KVC/KVO 一样,GlueKit 支持观察不仅单个值,还支持集合,如集合或数组。 这也包括对键路径观察的完全支持 - 例如,你可以将一个人的孩子们的孩子作为一个集合来观察。 这些可观察的集合报告细粒度的增量更改(例如,"'foo' 被插入到索引 5"),允许你有效地对它们的更改做出反应。

除了键路径观察之外,GlueKit 还为可观察对象提供了一组丰富的转换和组合,作为 KVC 集合运算符 的更灵活和可扩展的 Swift 版本。 例如,给定一个可观察的整数数组,你可以(有效地!)观察其元素的总和; 你可以过滤它以查找与特定谓词匹配的元素; 你可以获得它与另一个可观察数组的可观察的连接; 并且你可以做更多的事情。

你可以使用 GlueKit 的可观察数组有效地向 UITableViewUICollectionView 提供数据,包括为它们提供增量更改以进行动画更新。 此功能大致相当于 NSFetchedResultsController 在 Core Data 中所做的事情。

GlueKit 是用纯 Swift 编写的; 它的功能不需要 Objective-C 运行时。 但是,它确实提供了易于使用的适配器,可以将 NSObject 上与 KVO 兼容的键路径转换为 GlueKit 可观察对象。

GlueKit 尚未正式发布。 它的 API 仍在不断变化,并且文档已经过时且非常不完整。 但是,该项目正接近一个可以构成连贯的 1.0 版本的特性集; 我希望在 2016 年底之前发布一个有用的第一个版本。

演示

Károly 在布达佩斯 2016 年函数式 Swift 会议 期间发表了关于 GlueKit 的演讲。 观看视频阅读幻灯片

安装

CocoaPods

如果你使用 CocoaPods,你可以通过将其作为依赖项包含在你的 Podfile 中来开始使用 GlueKit

pod 'GlueKit', :git => 'https://github.com/attaswift/GlueKit.git'

(GlueKit 还没有正式版本;API 目前不完整且非常不稳定。)

Carthage

对于 Carthage,将以下行添加到你的 Cartfile

github "attaswift/GlueKit" "<commit-hash>"

(你必须使用特定的提交哈希,因为 GlueKit 还没有正式版本;API 目前不完整且非常不稳定。)

Swift Package Manager

对于 Swift Package Manager,将以下条目添加到你的 Package.swift 文件中的依赖项列表中

.Package(url: "https://github.com/attaswift/GlueKit.git", branch: master)

独立开发

如果你不使用 CocoaPods、Carthage 或 SPM,则需要克隆 GlueKit、BTreeSipHash,并将对它们的 xcodeproj 文件的引用添加到你项目的工作区。 你可以将克隆放置在你喜欢的任何位置,但如果你使用 Git 进行应用程序开发,则最好将它们设置为你的应用程序顶级 Git 存储库的子模块。

要将你的应用程序二进制文件与 GlueKit 链接,只需将 GlueKit.frameworkBTree.frameworkSipHash.framework 从 BTree 项目添加到 Xcode 中你的应用程序目标的一般页面的嵌入二进制文件部分。 只要 GlueKit 和 BTree 项目文件在你的工作区中被引用,这些框架将列在当你单击目标嵌入二进制文件列表的“+”按钮时打开的“选择要添加的项目”工作表中。

除了将框架目标添加到嵌入二进制文件之外,无需进行任何其他设置。

在 GlueKit 本身上工作

如果你想单独在 GlueKit 上做一些工作,而不在应用程序中嵌入它,只需使用 --recursive 选项克隆此仓库,打开 GlueKit.xcworkspace,然后开始破解。

git clone --recursive https://github.com/attaswift/GlueKit.git GlueKit
open GlueKit/GlueKit.xcworkspace

导入 GlueKit

一旦你使 GlueKit 在你的项目中可用,你需要在每个 .swift 文件的顶部导入它,在这些文件中你想使用它的功能

import GlueKit

类似的框架

GlueKit 的一些构造可以与离散响应式框架中的构造相匹配,例如 ReactiveCocoaRxSwiftReactKitInterstellar 等。 有时 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>
}

你可以像使用 var raw: Foo 属性一样使用 let observable: Variable<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("karoly@example.com", forKeyPath: cocoaKeyPath)
// GlueKit/Swift: error: cannot assign to property: 'value' is a get-only property
swiftKeyPath(document).value = "karoly@example.com"

你会很高兴知道一对一的键路径在 Cocoa 和 GlueKit 中都是可分配的

let issue: Issue = ...
/* Cocoa */   issue.setValue("karoly@example.com", forKeyPath: "owner.email") // OK
/* GlueKit */ issue.owner.map{$0.email}.value = "karoly@example.com"  // OK

(在 GlueKit 中,你通常只是直接使用可观察的组合器,而不是创建键路径实体。 所以我们现在就这样做。 可序列化的类型安全键路径需要额外的工作,最好由建立在 GlueKit 之上的潜在的未来模型对象框架提供。)

更有趣的是,你可以要求在键路径更改其值时收到通知。

// GlueKit
let c = document.projects.flatMap{$0.issues}.flatMap{$0.owner}.map{$0.name}.subscribe { 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 很难听,但人们倾向于将此包装在他们自己的抽象中。 在这两种情况下,每当项目列表更改,或属于任何项目的问题列表更改,或任何问题的所有者更改,或者如果单个帐户上的电子邮件地址更改时,都会打印一组新的电子邮件。

为了展示一个更实际的例子,假设你想为项目摘要屏幕创建一个视图模型,该屏幕显示有关当前所选项目的各种有用数据。 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 以一种令人惊讶的有效方式执行此操作---例如,关闭项目中的问题只会减少 openIssueCount 中的计数器; 它不会从头开始重新计算问题计数。 (显然,如果用户切换到新项目,该更改将触发从头开始重新计算该项目的问题计数。)可观察对象实际上在没有任何订阅者之前都不会计算任何东西。

一旦你有了这个视图模型,视图控制器就可以简单地将其可观察对象订阅到视图层次结构中显示的各种标签

class ProjectSummaryViewController: UIViewController {
    private let visibleConnections = Connector()
    let viewModel: ProjectSummaryViewModel
    
    // ...
    
    override func viewWillAppear() {
        super.viewWillAppear()
        
	    viewModel.projectName.values
	        .subscribe { name in
	            self.titleLabel.text = name
	        }
	        .putInto(visibleConnections)
	     
	    viewModel.localizedIssueCountString.values
	        .subscribe { 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"