Introduction
Telegram-iOS uses reactive programming in most modules. There are three frameworks to achieve reactive functions inside the project:
MTSignal: it might be their first attempt for a reactive paradigm in Objective-C. It’s mainly used in the module MtProtoKit, which implements MTProto, Telegram’s mobile protocol.
SSignalKit: it’s a descendant of MTSignal for more general scenarios with richer primitives and operations.
SwiftSignalKit: an equivalent port in Swift.
This post focuses on SwiftSignalKit to explain its design with use cases.
Design
Signal
Signal is a class that captures the concept of “change over time”. Its signature can be viewed as below:
// 伪代码
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
}
To set up a signal, it accepts a generator closure which defines the ways to generate data(), catch errors(), and update completion state. Once it’s set up, the function startcan register observer closures.
Subscriber
Subscriber has the logics to dispatch data to each observer closure with thread safety consideration.
// 伪代码
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()
}
A subscriber is terminated when an error occurred or it’s completed. The state can not be reversed.
putNext sends new data to the next closure as long as the subscriber is not terminated
putError sends an error to the error closure and marks the subscriber terminated
putCompletion invokes the completed closure and marks the subscriber terminated.
Operators
A rich set of operators are defined to provide functional primitives on Signal. These primitives are grouped into several categories according to their functions: Catch, Combine, Dispatch, Loop, Mapping, Meta, Reduce, SideEffects, Single, Take, and Timing. Let’s take several mapping operators as an example:
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>
The operator like map() takes a transformation closure and returns a function to change the data type of a Signal. There is a handy |>operator to help chain these operators as pipes:
//自定义操作符 |>
precedencegroup PipeRight {
associativity: left
higherThan: DefaultPrecedence
}
infix operator |> : PipeRight
public func |> <T, U>(value: T, function: ((T) -> U)) -> U {
return function(value)
}
The operator |>might be inspired by the proposed pipeline operator in the JavaScript world. By the trailing closure support from Swift, all operators can be pipelined with intuitive readability:
// 伪代码
let anotherSignal = valueSignal
|> filter { value -> Bool in
...
}
|> take(1)
|> map { value -> AnotherValue in
...
}
|> deliverOnMainQueue
Queue
The class Queue is a wrapper over GCD to manage the queue used to dispatch data in a Signal. There are three preset queues for general use cases: globalMainQueue, globalDefaultQueue, and globalBackgroundQueue. There is no mechanism to avoid overcommit to queues, which I think could be improved.
Disposable
The protocol Disposable defines something that can be disposed of. It’s usually associated with freeing resources or canceling tasks. Four classes implement this protocol and could cover most use cases: ActionDisposable, MetaDisposable, DisposableSet, and DisposableDict.
Promise
The classes Promise and ValuePromise are built for the scenario when multiple observers are interested in a data source. Promise supports using a Signal to update the data value, while ValuePromise is defined to accept the value changes directly.
Use Cases
Let’s check out some real use cases in the project, which demonstrate the usage pattern of SwiftSignalKit.
#1 Request Authorization
iOS enforces apps to request authorization from the user before accessing sensitive information on devices, such as contacts, camera, location, etc. While chatting with a friend, Telegram-iOS has a feature to send your location as a message. Let’s see how it gets the location authorization with 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
}
}
}
The workflow is a standard asynchronous task that can be modeled by SwiftSignalKit. The function authorizationStatus inside DeviceAccess.swift returns a Signal to check the current authorization status:
The current implementation is piped with another then operation, which I believe it’s a piece of copy-and-paste code, and it should be removed.
When aLocationPickerController is present, it observes on the signal from authorizationStatus and invokes DeviceAccess.authrizeAccess if the permission is not determined.
Signal.start returns an instance of Disposable. The best practice is to hold it in a field variable and dispose of it in 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 Change Username
Let’s check out a more complex example. Telegram allows each user to change the unique username in UsernameSetupController. The username is used to generate a public link for others to reach you.