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:
1
2
3
4
5
6
7
8
// pseudocode
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(<T>
), catch errors(<E>
), and update completion state. Once it’s set up, the function start
can register observer closures.
Subscriber
Subscriber
has the logics to dispatch data to each observer closure with thread safety consideration.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// pseudocode
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 thenext
closure as long as the subscriber is not terminatedputError
sends an error to theerror
closure and marks the subscriber terminatedputCompletion
invokes thecompleted
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:
1
2
3
4
5
6
7
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:
1
2
3
4
5
6
7
8
9
10
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:
1
2
3
4
5
6
7
8
9
10
// pseudocode
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.
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:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
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 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 a LocationPickerController
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
.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
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.
The implementation should meet the requirements:
- The controller starts with the current username and the current theme. Telegram has a powerful theme system, all controllers should be themeable.
- The input string should be validated locally first to check its length and characters.
- A valid string should be sent to the backend for the availability check. The number of requests should be limited in case of fast typing.
- UI Feedback should follow the user’s input. The message on the screen should tell the status of the new username: it’s in checking, invalid, unavailable, or available. The right navigation button should be enabled when the input string is valid and available.
- Once the user wants to update the username, the right navigation button should show an activity indicator during updating.
There are three data sources that could change over time: the theme, the current account, and the editing state. The theme and account are fundamental data components in the project, so there are dedicated Signals: SharedAccountContext.presentationData
and Account.viewTracker.peerView
. I’ll try to cover them in other posts. Let’s focus on how the editing state is modeled with Signal step by step.
#1. The struct UsernameSetupControllerState
defines the data with three elements: the editing input text, the validation status, and the updating flag. Several helper functions are provided to update it and get a new instance.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
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. The state changes are propagated by statePromise
in ValuePromise
, which also provides a neat feature to omit repeated data updates. There is also a stateValue
to hold the latest state because the data in a ValuePromise
is not visible
outside. It’s a common pattern inside the project for a value promise companied with a state value. Exposing read access to the internal value might be an appropriate improvement to ValuePromise
IMO.
1
2
3
let statePromise = ValuePromise(UsernameSetupControllerState(), ignoreRepeated: true)
let stateValue = Atomic(value: UsernameSetupControllerState())
#3. The validation process can be implemented in a piped Signal. The operator delay
holds the request for a 0.3 seconds delay. For fast typing, the previous unsent request would be canceled by the setup in Step 4.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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. A MetaDisposable
holds the Signal, and updates the data in statePromise
and stateValue
when text
is changed in TextFieldNode
. When invoking checkAddressNameDisposable.set()
, the previous one is disposed of which triggers the canceling task inside the operator delay
in the 3rd step.
TextFieldNode
is a subclass of ASDisplayNode
and wraps a UITextField for text input. Telegram-iOS leverages the asynchronous rendering mechanism from AsyncDisplayKit
to make its complex message UI smooth and responsive.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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. The operator combineLatest
combines the three Signals to update the controller UI if any of them is changed.
1
2
3
4
5
6
7
let signal = combineLatest(
presentationData,
statePromise.get() |> deliverOnMainQueue,
peerView) {
// update navigation button
// update controller UI
}
Conclusion
SSignalKit
is Telegram-iOS’s solution to reactive programming. The core components, like Signal
and Promise
, are implemented in slightly different approaches from other reactive frameworks. It’s used pervasively across the modules to connect UI with data changes.
The design encourages heavy usage of closures. There are many closures nested with each other, which indents some lines
far away. The project also likes exposing many actions as closures
for flexibility. It’s still a myth to me on how Telegram engineers maintain the code quality and debug the Signals easily.