版权声明
本文内容均为搬运,目的只为更方便的学习Telegram编码思维。
介绍
Ťelegram-iOS在大多数模块中使用响应式编程。有三个框架可以在项目内部实现响应功能:
- MTSignal:这可能是他们在Objective-C中首次尝试响应式范例。它主要用于MtProtoKit模块中,该模块实现了Telegram的移动端协议MTProto。
- SSignalKit:它是MTSignal的进阶,具有更丰富的基础使用和操作,可用于更基础的场景。
-
SwiftSignalKit:SSignalKit的Swift版本。
这篇文章重点介绍SwiftSignalKit,以用例说明其设计。
设计
信号(Signal)
Signal是捕获“随时间变化”概念的一个类。其特点如下所示:
// 伪代码
public final class Signal<T, E> {
public init(_ generator: @escaping(Subscriber<T, E>) -> Disposable)
public func start(next: ((T) -> Void)! = nil,
error: ((E) -> Void)! = nil,
completed: (() -> Void)! = nil) -> Disposable
}
为了创建一个Signal
,它接受一个generator
闭包,该闭包定义了生成数据(<T>
),捕获异常(<E>
)和更新完成状态的方式。一旦创建好,方法start
就可以注册观察者闭包。
订阅(Subscriber)
Subscriber具有考虑线程安全性的逻辑,将数据分发给每个观察者闭包。
// 伪代码
public final class Subscriber<T, E> {
private var next: ((T) -> Void)!
private var error: ((E) -> Void)!
private var completed: (() -> Void)!
private var terminated = false
public init(next: ((T) -> Void)! = nil,
error: ((E) -> Void)! = nil,
completed: (() -> Void)! = nil)
public func putNext(_ next: T)
public func putError(_ error: E)
public func putCompletion()
}
当出现错误或执行完成时,订阅将被终止。此状态不可逆。
putNext
将新数据发送到next
闭包,只要订阅没有终止
putError
向error
闭包发送错误并终止订阅
putCompletion
调用completed
闭包终止订阅
操作符(Operators)
Signal定义了一系列的操作符来服务基础函数。这些基础函数根据它们的功能被划分为几类:Catch,Combine,Dispatch,Loop,Mapping,Meta,Reduce,SideEffects,Single,Take,和Timing。
以一些映射操作符为例:
public func map<T, E, R>(_ f: @escaping(T) -> R) -> (Signal<T, E>) -> Signal<R, E>
public func filter<T, E>(_ f: @escaping(T) -> Bool) -> (Signal<T, E>) -> Signal<T, E>
public func flatMap<T, E, R>(_ f: @escaping (T) -> R) -> (Signal<T?, E>) -> Signal<R?, E>
public func mapError<T, E, R>(_ f: @escaping(E) -> R) -> (Signal<T, E>) -> Signal<T, R>
像操作符map()
一样,进行转换闭包并返回一个函数以更改Signal的数据类型。
有一个方便的|>
操作员可以将这些操作符像管道一样链接起来:
//自定义操作符 |>
precedencegroup PipeRight {
associativity: left
higherThan: DefaultPrecedence
}
infix operator |> : PipeRight
public func |> <T, U>(value: T, function: ((T) -> U)) -> U {
return function(value)
}
该操作符|>
也许是受到JavaScript中建议的管道操作启发。通过Swift的结尾闭包支持,可以直观地读取所有操作符的流水线:
// 伪代码
let anotherSignal = valueSignal
|> filter { value -> Bool in
...
}
|> take(1)
|> map { value -> AnotherValue in
...
}
|> deliverOnMainQueue
队列(Queue)
Queue类是在GCD之上的封装,用于管理用于在Signal中调度数据的队列。一般情况下,共有三个预设队列:globalMainQueue,
globalDefaultQueue,
和globalBackgroundQueue
。我认为没有任何机制可以避免过度分配到队列。
Disposable
Disposable协议定义了可以处理的东西。它通常与释放资源或取消任务相关。有四个类实现了这一协议,可以覆盖大多数使用情况,这四个类分别是:ActionDisposable
,MetaDisposable
,DisposableSet
,和DisposableDict
。
Promise
Promise类和ValuePromise类是为多个观察者依赖同一个数据源的情况而构建的。Promise
支持使用Signal来更新数据值,而ValuePromise
定义为可以直接接受值更改。
用例
让我们查看项目中的一些实际用例,这些用例演示了SwiftSignalKit的使用模式。
#1请求授权
iOS强制应用程序在访问设备上的敏感信息(例如联系人,相机,位置等)之前,先向用户请求授权。在与朋友聊天时,Telegram-iOS具有将您的位置作为消息发送的功能。让我们看看它如何通过Signal获得位置授权。
工作流是可以由SwiftSignalKit建模的标准异步任务。DeviceAccess.swift的内部函数authorizationStatus返回一个Signal以检查当前授权状态:
public enum AccessType {
case notDetermined
case allowed
case denied
case restricted
case unreachable
}
public static func authorizationStatus(subject: DeviceAccessSubject) -> Signal<AccessType, NoError> {
switch subject {
case .location:
return Signal { subscriber in
let status = CLLocationManager.authorizationStatus()
switch status {
case .authorizedAlways, .authorizedWhenInUse:
subscriber.putNext(.allowed)
case .denied, .restricted:
subscriber.putNext(.denied)
case .notDetermined:
subscriber.putNext(.notDetermined)
@unknown default:
fatalError()
}
subscriber.putCompletion()
return EmptyDisposable
}
}
}
当LocationPickerController被present
出来时,它将观察来自authorizationStatus
的信号,并在未确定许可的情况下调用DeviceAccess.authrizeAccess
。
Signal.start
返回一个实例Disposable
。最好的做法是将其保存在字段变量中,然后在deinit
方法中释放。
override public func loadDisplayNode() {
...
self.permissionDisposable =
(DeviceAccess.authorizationStatus(subject: .location(.send))
|> deliverOnMainQueue)
.start(next: { [weak self] next in
guard let strongSelf = self else {
return
}
switch next {
case .notDetermined:
DeviceAccess.authorizeAccess(
to: .location(.send),
present: { c, a in
// present an alert if user denied it
strongSelf.present(c, in: .window(.root), with: a)
},
openSettings: {
// guide user to open system settings
strongSelf.context.sharedContext.applicationBindings.openSettings()
})
case .denied:
strongSelf.controllerNode.updateState { state in
var state = state
// change the controller state to ask user to select a location
state.forceSelection = true
return state
}
default:
break
}
})
}
deinit {
self.permissionDisposable?.dispose()
}
#2更改用户名
让我们来看一个更复杂的例子。Telegram允许每个用户更改UsernameSetupController中具有唯一性的用户名。用户名用于生成公共链接,以供其他人搜索到您。
实现应符合以下要求:
- 控制器以当前用户名和当前主题开头。Telegram具有强大的主题系统,所有控制器都应具有更换主题的特性。
- 输入的字符串应首先在本地验证,以检查其长度和字符。
- 将有效字符串发送到后端以进行可用性检查。在快速键入的情况下,应限制请求的次数。
- UI反馈应遵循用户的输入。屏幕上的信息应告诉新用户名的状态:正在检查,无效,不可用或可用。输入字符串有效且可用时,应启用右侧导航按钮。
- 用户确定更新用户名,则右侧导航按钮应在更新期间显示转子。
随时间变化的数据源共有三个:主题,当前帐户和编辑状态。主题和帐户是项目中的基本数据组件,因此有专用的信号:SharedAccountContext.presentationData和Account.viewTracker.peerView。我将尝试在其他帖子中介绍它们。让我们集中讨论如何使用Signal逐步建模编辑状态。
- 结构体UsernameSetupControllerState定义了三个元素:正在输入的文本,验证状态和更新标志。并且提供了一些辅助方法来更新它并获取新实例。
struct UsernameSetupControllerState: Equatable {
let editingPublicLinkText: String?
let addressNameValidationStatus: AddressNameValidationStatus?
let updatingAddressName: Bool
...
func withUpdatedEditingPublicLinkText(_ editingPublicLinkText: String?)
-> UsernameSetupControllerState {
return UsernameSetupControllerState(
editingPublicLinkText: editingPublicLinkText,
addressNameValidationStatus: self.addressNameValidationStatus,
updatingAddressName: self.updatingAddressName)
}
func withUpdatedAddressNameValidationStatus(
_ addressNameValidationStatus: AddressNameValidationStatus?)
-> UsernameSetupControllerState {
return UsernameSetupControllerState(
editingPublicLinkText: self.editingPublicLinkText,
addressNameValidationStatus: addressNameValidationStatus,
updatingAddressName: self.updatingAddressName)
}
}
enum AddressNameValidationStatus : Equatable {
case checking
case invalidFormat(TelegramCore.AddressNameFormatError)
case availability(TelegramCore.AddressNameAvailability)
}
2.状态更改通过ValuePromise
里的statePromise传播,它还提供了一种简洁的功能来省略重复的数据更新。还有一个stateValue保持最新状态,因为ValuePromise
里的数据是不能访问的外面。这是项目内部常见的模式,即promise value
与state value
相伴。
let statePromise = ValuePromise(UsernameSetupControllerState(), ignoreRepeated: true)
let stateValue = Atomic(value: UsernameSetupControllerState())
3.验证过程可以在管道信号(piped Signal
)中实现。操作符delay
将请求保留0.3秒的延迟。对于快速键入的场景,步骤4中的设置将取消先前未发送的请求。
public enum AddressNameValidationStatus: Equatable {
case checking
case invalidFormat(AddressNameFormatError)
case availability(AddressNameAvailability)
}
public func validateAddressNameInteractive(name: String)
-> Signal<AddressNameValidationStatus, NoError> {
if let error = checkAddressNameFormat(name) { // local check
return .single(.invalidFormat(error))
} else {
return .single(.checking) // start to request backend
|> then(addressNameAvailability(name: name) // the request
|> delay(0.3, queue: Queue.concurrentDefaultQueue()) // in a delayed manner
|> map { .availability($0) } // convert the result
)
}
}
4. 使用MetaDisposable持有信号,当TextFieldNode
的text
发生变化时,更新statePromise
和stateValue
的数据。当调用checkAddressNameDisposable.set()
时,前一个在第三步中触发操作符delay
的MetaDisposable
在内部取消任务。
TextFieldNode是
ASDisplayNode
的子类,并包装UITextField以进行文本输入。Telegram-iOS利用AsyncDisplayKit的异步呈现机制来使其复杂的消息UI平滑和响应。
let checkAddressNameDisposable = MetaDisposable()
...
if text.isEmpty {
checkAddressNameDisposable.set(nil)
statePromise.set(stateValue.modify {
$0.withUpdatedEditingPublicLinkText(text)
.withUpdatedAddressNameValidationStatus(nil)
})
} else {
checkAddressNameDisposable.set(
(validateAddressNameInteractive(name: text) |> deliverOnMainQueue)
.start(next: { (result: AddressNameValidationStatus) in
statePromise.set(stateValue.modify {
$0.withUpdatedAddressNameValidationStatus(result)
})
}))
}
5.combineLatest如果更改了三个信号,则操作员将三个信号组合起来以更新控制器UI。
let signal = combineLatest(
presentationData,
statePromise.get() |> deliverOnMainQueue,
peerView) {
// update navigation button
// update controller UI
}
结论
SSignalKit
是Telegram-iOS的响应式编程解决方案。核心组件(如Signal
和Promise
)与其他响应式框架的实现方式稍有不同。它已在各个模块中广泛使用,以将UI与数据更改连接起来。
设计鼓励大量使用闭包。有许多相互嵌套的闭包,这些闭包使很远的行得到缩进。该项目还喜欢将许多操作公开为灵活性的闭包。Telegram工程师如何保持代码质量并轻松调试信号,这仍然是我的课题。