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 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:

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.

UsernameSetupController

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.