简介
现代语言中,一般使用函数式编程或者 DSL(领域特定语言) 的方式来实现声明式的编程方式。
(DSL),其中一个典型的代表是 SQL
指令式编程更偏向于是“写给计算机的语言”,而相对地,
声明式编程则更偏向于“写给人看的语言”。
指令式是教会计算机“怎么做”,
声明式是教会计算机“做什么”。
声明式的UI
声明式 UI 都有如下特点:
- 代表 UI 层的 View 并不是真实负责渲染的传统意义的视图层级,而是一个“虚拟的” 对 View 组织关系的描述 (声明)。
- 决定 UI 的用户状态 State 被存储在某个或某几个对象中。
- 用一个函数描述 View,这个函数的输入参数是 State,即 View = f(State)。
- 框架在 State 改变时,调用上述函数获取对应新的 State 的 View,并与当前的 View 进行差分计算,并重新渲染更改的部分。
SwfitUI 和 Combine简介
Combine是由纯Swift 编写的。是基于响应式编程,用于处理数据流的框架。
你好,SwiftUI
字体大小
1 | .font(.title) |
使用预定义的字体可以让我们不需要额外的工作就能适配 Dynamic Type
特性
数据状态和绑定
纯函数式事件响应循环
纯函数指的是,返回值只由调用时的参数决定,而不依赖于任何系统状态,也不改变其作用域之外的变量状态的函数。
Store,Action,Reducer
如果你对 Redux 或者 Flux,甚至是它们的思想来源 Elm 有了解的话,会对这些命名感到十分亲切。
以 Redux 为代表的状态管理和组件通讯架构,在近来的前端开发中很受欢迎。它的基本思想和步骤如下:
- 将 app 当作一个状态机,状态决定用户界面。
- 这些状态都保存在一个 Store 对象中,被称为 State。
- View 不能直接操作 State,而只能通过发送 Action 的方式,间接改变存储在 Store 中的 State。
- Reducer 接受原有的 State 和发送过来的 Action,生成新的 State。
- 用新的 State 替换 Store 中原有的状态,并用新状态来驱动更新界面。
首先,除了通过 Action 外,也可以通过 Binding 来改变状态。
其次,我们希望 Reducer 具有纯函数特性,我们在 PokeMaster app 的架构中,选择在 Reducer 处理当前 State 和 Action 后,除了返回新的 State 以外,再额外返回一个 Command 值,并让 Command 来执行所需的副作用。
操作回溯和数据共享
对于 @State 修饰的属性的访问,只能发生在 body 或者 body 所调用的方法中。你不能在外部改变 @State 的值,它的所有相关操作和状态改变都应该是和当前 View 挂钩的。
Combine和异步编程
传统的 Cocoa 和 UIKit 提供了一系列的异步 API
- 闭包回调:这是现代 Cocoa 开发中最常见的异步方式,比如
URLSession
就提供了闭包的网络请求方法:dataTask(with:completionHandler:)
中的completionHandler
就是这样 - Delegate:Protocol-Delegate 是 Cocoa 中的一种常见设计模式。
UITableView
中相关的UITableViewDataSource
和UITableViewDelegate
都是这种模式的体现。另外,URLSession
除了提供基于闭包回调的方法以外,也存在基于protocol-delegate
的另一套 API。 - Notification:在异步操作完成时,可以通过 NotificationCenter 的相关 API
发送一个通知 (Notification),如果有观察者注册想要接收该通知,那么这个
通知将被传递给观察者并调用相关代码。
异步编程的本质是响应未来的事件流,可以
用一种通用的方式“抹去” 不同异步 API 的区别,让事件发生这一核心概念暴露出来。在异步操作中某个事件发生时,将这个事件和与其相关的数据“发布” 出来。而对这个事件感兴趣的代码可以订阅这个事件,来进行后续操作。
响应式异步编程的抽象和特点:
异步操作在合适的时机发布事件,这些事件带有数据,使用一个或多个操作来处理这些事件以及内部的数据。
在末端,使用一个订阅者来“消化” 这个事件和数据,并进一步驱动程序的其他部分(比如 UI 界面) 的运行。
上面这些对于事件和数据的操作,以及末端的订阅,都是在
事件发生之前完成的。一开始我们就将这些设定好,之后它可以以预设的方式响应源源不断发生的事件流。
Combine 基础
通过对事件处理的操作进行组合 (combine) ,来对异步事件进行自定义处理 (这也正是 Combine 框架的名字的由来)。Combine 提供了一组声明式的Swift API,来处理随时间变化的值。这些值可以代表用户界面的事件,网络的响应,计划好的事件,或者很多其他类型的异步数据。
Combine 中最重要的角色有三种,恰好对应了这三种操作:负责发布事件的 Publisher,负责订阅事件的 Subscriber,以及负责转换事件和数据的 Operator。
Publisher
在 Combine 中,我们使用 Publisher
协议来代表事件的发布者。
Publisher 协议中所必须的内容十分简单,它包括两个关联类型 (associatedtype) 以及一个 receive 方法:
1 | public protocol Publisher { |
Publisher 最主要的工作其实有两个:发布新的事件及其数据,以及准备好被Subscriber
订阅。
Output 定义了某个 Publisher 所发布的值的类型,Failure
则定义可能产生的错误的类型。
Publisher 可以发布三种事件:
- 类型为 Output 的新值:这代表事件流中出现了新的值。
- 类型为 Failure 的错误:这代表事件流中发生了问题,事件流到此终止。
- 完成事件:表示事件流中所有的元素都已经发布结束,事件流到此终止。
事件图
图中从左向右的横轴表示时间,圆圈表示每个 output 的值,时间轴最后的竖线代表了 finished
事件。下面的图示中,Publisher 依次发布了 20,40,60,80 和 100 五
个值,并在之后正常完成:
有限事件流和无限事件流
最终会终结的事件流称为有限事件流,而将不会发出 failure 或者 finished 的事件流称为无限事件流。
有限事件流最常见的一个例子是网络请求的相关操作,无限事件流则正好相反,这类 Publisher 永远不会发出 failure 或者 finished。一个典型的例子是 UI 操作
Operator
想在一个 Text 上表示某个按钮被按下的总次数的话,就需要对这些 output 事件进行统计。在 Combine 中,我们可以使用 scan
来完成累加的工作。scan 让我们提供一个暂存值,每次事件发生时我们有机会执行一个闭包来更新这个暂存值,并准备好在下一次事件时使用它。同时,这个暂存值也将被
作为新的 Publisher
事件被发送出去:
1 | let buttonClicked: AnyPublisher<Void, Never> |
用 E 的圆圈来表示按钮点击事件,上面的代码将把 buttonClicked 转换为一个发布点击次数的新的 Publisher,它所发布的数据的类型是 Int
1 | let buttonClicked: AnyPublisher<Void, Never> |
每个 Operator 的行为模式都一样:它们使用上游 Publisher 所发布的数据作为输入,以此产生的新的数据,然后自身成为新的 Publisher,并将这些新的数据作为输出,发布给下游。
通过一系列组合,我们可以得到一个响应式的 Publisher 链条:当链条最上端的 Publisher 发布某个事件后,链条中的各个 Operator 对事件和数据进行处理。在链条的末端我们希望最终能得到可以直接驱动 UI 状态的事件和数据。
这样,终端的消费者可以直接使用这些准备好的数据,而这个消费者的角色由
Subscriber
来担任。
Subscriber
和 Publisher 类似,Combine 中的 Subscriber 也是一个抽象的协议,它定义了某个类型想要成为订阅者角色时所需要满足的条件:
1 | public protocol Subscriber { |
sink
1 | let buttonClicked: AnyPublisher<Void, Never> |
sink
方法完整的函数签名如下:
1 | func sink( |
可以同时提供两个闭包,receiveCompletion 用来接收 failure 或者 finished 事件,receiveValue 用来接收 output 值。
sink 可以充当一座桥梁,将响应函数式的 Publisher 链式代码,终结并桥接到基于闭包的指令式世界中来。
如果你是想要让数据继续在SwiftUI 的声明式的世界中来驱动 UI 的话,另一个 Subscriber 可能会更为简洁常用,
那就是 assign
assign
assign 接受一个 class 对象以及对象类型上的某个键路径 (key path)。每当 output 事件到来时,其中包含的值就将被
设置到对应的属性上去:
1 | class Foo { |
这样的 Subscriber 让我们可以彻底摆脱指令式的写法,直接将事件值“绑定” 到具体的属性上。
assign 方法的具体定义如下,它要求 keyPath 满足ReferenceWritableKeyPath
:
1 | func assign<Root>( |
也就是说,只有那些 class 类型的实例中的属性能被绑定。在 SwiftUI 中,代表 View 对应的模型的 ObservableObject
接口只能由 class 修饰的类型来实现,这也正是 assign 最常用的地方。
Subject
Subject 本身也是一个 Publisher:
1 | public protocol Subject : AnyObject, Publisher { |
如果说 sink 提供了由函数响应式向指令式编程转变的路径的话,Subject 则补全了这条通路的另一侧:
它让你可以将传统的指令式异步 API 里的事件和信号转换到响应式的世界中去。
Combine 内置提供了两种常用的 Subject 类型,分别是 PassthroughSubject 和 CurrentValueSubject
PassthroughSubject 并不会对接受到的值进行保留,当订阅开始后,它将监听并响应接下来的事件。
CurrentValueSubject 则会包装和持有一个值,并在
设置该值时发送事件并保留新的值。在订阅发生的瞬间,CurrentValueSubject 会把当前保存的值发送给订阅者。
Scheduler
Publisher 决定了发布怎样的 (what) 事件流,Scheduler 所要解决的就是两个问题:在什么地方 (where),以及在什么时候 (when) 来发布事件和执行代码。
- 关于 where
Combine 里提供了receive(on:options:)
来让下游在指定的线程中接收事件。
1 | URLSession.shared |
RunLoop 就是一个实现了 Scheduler 协议的类型,如果没有 receive(on: RunLoop.main) 的话,sink 的闭包将会在后台线程执行,这在更新 UI 时将带来问题。
- 关于when
比较常见的两种操作是 delay 和 debounce。delay 简单地将所有事件按照一定事件延后。debounce 则是设置了一个计时器,在事件第一次到来时,计时器启动。在计时器有效期间,每次接收到新值,则将计时器时间重置。当且仅当计时窗口中没有新
的值到来时,最后一次事件的值才会被当作新的事件发送出去。