从期货到信号
我们将使用该Future
类型作为构建简单响应式实现的基础。虽然未来是围绕一次性回调的抽象,但是反应库中的信号会随着时间的推移对值进行建模。
如果调用仅提供一个结果,例如大多数网络调用或一些昂贵的计算,那么您将在异步API中使用未来。相反,如果您必须随时间处理多个值,则可以使用信号,例如可以多次轻触的按钮,或者对于每个新用户输入更改其值的文本字段。在信号的情况下,我们必须考虑一些其他问题,例如内存管理(以避免参考周期)和订阅管理(允许客户选择加入并选择不接收特定信号的值)。
从头开始编写一个小型反应库的动机实际上是在更深层次上理解反应式编程的概念。我们的目标不是编写生产就绪代码,而是在使用现有的反应库时,围绕幕后发生的事情。
到最后小编推荐一个群 691040931 里面有许多的iOS开发者在交流技术分享自己的心得,更有一些资料不定期的分享更新。
把未来变成信号
我们Future
从第36集的类型开始:
final class Future<A> {
var callbacks: [(Result<A>) -> ()] = []
var cached: Result<A>?
init(compute: (@escaping (Result<A>) -> ()) -> ()) {
compute(self.send)
}
private func send(_ value: Result<A>) {
assert(cached == nil)
cached = value
for callback in callbacks {
callback(value)
}
callbacks = []
}
func onResult(callback: @escaping (Result<A>) -> ()) {
if let value = cached {
callback(value)
} else {
callbacks.append(callback)
}
}
}
作为第一步,我们删除结果的缓存,因为这对信号不再有意义,至少不是这种形式。我们还删除了初始化程序,将类型重命名Future
为Signal
,send
现在公开,并重命名onResult
为subscribe
。最后,我们不会在send
方法结束时清除回调,因为我们现在希望能够发送多个值。这简化了代码:
final class Signal<A> {
var callbacks: [(Result<A>) -> ()] = []
func send(_ value: Result<A>) {
for callback in callbacks {
callback(value)
}
}
func subscribe(callback: @escaping (Result<A>) -> ()) {
callbacks.append(callback)
}
}
现在我们可以尝试Signal
:
let signal = Signal<String>()
signal.subscribe { result in
print(result)
}
signal.send(.success("Hello World"))
的Signal
实现是现在很简单,但也很有限。一旦我们进一步构建这种类型,我们将不得不再增加一些复杂性。
分离发送/订阅API
我们要做的第一个改进是将Signal
API分成发送部分和接收部分。这是反应库中非常常见的模式,因为它允许您控制谁可以将新值发送到信号中,哪些只允许订阅。
为此,我们添加一个静态方法pipe
to Signal
,它返回这两个东西:一个我们可以发送新值的函数,以及信号本身,它是只读的。您可以非常简单地获取方法的名称:返回的元组可以被认为是具有两端的物理管道。在一端,您可以插入新值(也称为“接收器”),在另一端,您可以观察到出现的内容:
final class Signal<A> {
// ...
static func pipe() -> ((Result<A>) -> (), Signal<A>) {
let signal = Signal<A>()
return (signal.send, signal)
}
private func send(_ value: Result<A>) {
// ...
}
}
随着pipe
方法的到位,我们能够send
再次私有化。现在只能通过该pipe
方法公开发送新值的能力。
要尝试这些更改,我们只需稍微修改一下我们的测试代码:
let (sink, signal) = Signal<String>.pipe()
signal.subscribe { result in
print(result)
}
sink(.success("Hello World"))
将信号添加到文本字段
让我们把所有这些代码放到一个更现实的环境中。为此,我们将在操场上伪造一个视图控制器,并在以下位置订阅文本字段的信号viewDidLoad
:
class VC {
let textField = NSTextField()
var stringSignal: Signal<String>?
func viewDidLoad() {
stringSignal = textField.signal()
stringSignal?.subscribe {
print($0)
}
}
}
var vc: VC? = VC()
vc?.viewDidLoad()
vc?.textField.stringValue = "17"
为了使这段代码有效,我们必须signal
在扩展名中添加方法NSTextField
。在这种方法中,我们可以利用上面创建的发送/订阅分离 - 文本字段能够向信号发送新值,而任何其他代码只能观察到:
extension NSTextField {
func signal() -> Signal<String> {
let (sink, result) = Signal<String>.pipe()
KeyValueObserver(object: self, keyPath: #keyPath(stringValue)) { str in
sink(.success(str))
}
return result
}
}
在这种方法中,我们不希望文本字段引用信号,而是信号应引用文本字段(通过键值观察器)。这样做的优点是它更灵活,因为我们必须子类化文本字段或使用相关对象来存储文本字段本身中信号的引用。
的KeyValueObserver
类是KVO一个简单的包装每次观察属性更改调用一个函数。它还管理观察生命周期:一旦键值观察器实例消失,它就会停止观察文本字段。只要键值观察器处于活动状态,观察对象就可以保证也存在,因为观察者实例拥有对被观察对象的强引用。
由于我们还没有抓住KeyValueObserver
实例,它会立即被取消,观察也会停止。解决此问题的最简单方法是将观察者存储在信号的属性中。我们objects
现在称这个属性:
final class Signal<A> {
var objects: [Any] = []
// ...
}
extension NSTextField {
func signal() -> Signal<String> {
let (sink, result) = Signal<String>.pipe()
let observer = KeyValueObserver(object: self, keyPath: #keyPath(stringValue)) { str in
sink(.success(str))
}
result.objects.append(observer)
return result
}
}
只要信号存活,观察者就会活着。由于信号存储在视图控制器的属性中,因此只要视图控制器在周围,就会发送新值。
处理参考周期
不幸的是,我们偶然引入了一个参考周期,这使得信号永远不会被释放。
参考周期有时难以调试,因为许多对象可能参与创建周期。帮助诊断这些循环的一种非常简单的技术是向类中添加print语句deinit
。例如,我们可以使用我们的视图控制器类来尝试:
class VC {
// ...
deinit {
print("Removing vc")
}
}
var vc: VC? = VC()
vc?.viewDidLoad()
vc?.textField.stringValue = "17"
vc = nil
将vc
变量设置为后nil
,视图控制器将被取消分配并"Removing vc"
在控制台中打印。
16:33但是,当我们向该Signal
类型添加相同的调试代码时,它不会在控制台中打印出来。这清楚地表明信号实例永远不会被释放:
final class Signal<A> {
// ...
deinit {
print("deiniting signal")
}
}
Xcode的内存调试器
进一步诊断问题的一个非常有用的工具是Xcode的内存调试器。由于这在操场上不起作用,我们只需将代码复制/粘贴到命令行项目中并继续在那里工作。
现在我们在设置vc
变量nil
并运行项目后设置了一个断点。一旦调试器在此行停止,我们就可以打开内存调试器:
在侧边栏中,我们看到所有活着的物体,例如Signal<String>
不再存在的物体。在右侧,我们看到参考周期中涉及的所有对象使信号保持活动:信号通过其objects
属性引用键值观察者,键值观察者通过几个中间步骤引用信号。
这种可视化对诊断问题非常有帮助。但是,有时内存调试器无法生成这样的图形,但它仍然会显示侧边栏中存活的对象。
勾画出参考周期
另一个有用的方法是勾画出纸上所有物体的参考。我们这样做了很多,以了解我们如何创建参考周期。在手头的示例中,图表如下所示:
image.png在左上角我们有视图控制器,它强烈引用文本字段和字符串信号(如箭头所示)。字符串信号使用print语句引用闭包,以及用于观察文本字段以进行更改的键值观察器。键值观察者反过来对文本字段有强烈的引用。
当你查看图的左侧部分时,这可能看起来像一个参考周期(视图控制器 - 字符串信号 - 键值观察者 - 文本字段 - 视图控制器)。但是,并非所有引用都指向同一方向,因此我们还没有问题。
键值观察器还引用了在观察到的属性发生变化时调用的闭包。在我们的例子中,这是将新值提供给接收器的功能。反过来,接收器强烈地引用信号,因为接收器只是信号的send
方法。所以这是我们的参考周期。
修复参考周期
为了解决这个问题,我们必须考虑哪些参考文献可能是一个弱参考。例如,我们不能从信号到观察者的弱引用,因为观察者不再被任何东西强烈引用。对于观察者引用的闭包也是如此。因此,我们将通过从闭包到信号弱的引用来打破周期:
image.png为此,我们必须修改我们Signal
的pipe
方法的实现。而不是返回signal.send
,我们将把它包装在一个signal
弱捕获的闭包中:
static func pipe() -> ((Result<A>) -> (), Signal<A>) {
let signal = Signal<A>()
return ({ [weak signal] value in signal?.send(value) }, signal)
}
通过这个微小的改变,参考周期是固定的,我们可以从控制台的打印输出"Removing signal"
以及内存调试器中看到。
在这种情况下,参考周期是由执行中的错误引起的Signal
。但是,也有一些情况是API的使用者不负责创建周期。例如,如果我们self
在闭包中引用我们传递给subscribe
调用viewDidLoad
,我们将在视图控制器和信号之间创建一个引用循环。为避免这种情况,我们必须指定weak self
或unowned self
在闭包的捕获列表中。