Telegram-iOS 第 2 部分的源代码演练:SSignalKit

2021-12-08 15:09:28 浏览数 (1)

  • 原文地址:Source Code Walkthrough of Telegram-iOS Part 2: SSignalKit
  • 原文作者:Bo
  • 译文出自:掘金翻译计划

Telegram-iOS 在大多数模块中使用反应性编程。在项目内实现反应功能有三个框架......

Telegram-iOS 在大多数模块中使用反应性编程。在项目内实现反应功能有三个框架:

  • MTSignal: 这可能是他们第一次尝试在目标-C中建立反应性范式。它主要用于模块 MtProtoKit, 它实现了 MTProto, 电报的移动协议。
  • SSignalKit:它是 MTSignal 的后裔, 用于更通用的场景, 具有更丰富的原始和操作。
  • SwiftSignalKit: 在Swift的等效端口。

这篇文章侧重于SwiftSignalKit解释其设计与使用案例。

设计

信号 是一个捕捉"随着时间而变化"概念的类。其签名可视为以下内容::

代码语言:javascript复制
// 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
}

复制代码

要设置信号,它接受一个发电机关闭,该关闭定义了生成数据()、捕获错误(和更新完成状态)的方法。设置后,该功能可以注册观察者关闭。start

订阅者

订阅者有逻辑将数据发送到每个观察者关闭与线程安全考虑。

代码语言:javascript复制
// 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()
}

复制代码

当发生错误或完成订阅者时,订阅者将终止。状态无法逆转

  • putNext 只要用户未终止,就向关闭发送新数据next
  • putError 向关闭发送错误并标记已终止的订阅者error
  • putCompletion 调用关闭并标记已终止的订阅者。completed

运营商

定义了一组丰富的操作员,以在信号上提供功能原始。这些原始人被分为几个类别,根据其功能:Catch, Combine, Dispatch, Loop, Mapping, Meta, Reduce, SideEffects, Single, Take, and Timing. 让我们以几个映射操作员为例:

代码语言:javascript复制
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()|>

代码语言:javascript复制
precedencegroup PipeRight {
    associativity: left
    higherThan: DefaultPrecedence
}

infix operator |> : PipeRight

public func |> <T, U>(value: T, function: ((T) -> U)) -> U {
    return function(value)
}

复制代码

运营商可能受到JavaScript世界中提议的 管道运营商 的启发。通过 Swift 的尾随关闭支持,所有操作员都可以通过直观的可读性进行管道传输:|>

代码语言:javascript复制
// pseudocode
let anotherSignal = valueSignal
    |> filter { value -> Bool in
      ...
    }
    |> take(1)
    |> map { value -> AnotherValue in
      ...
    }
    |> deliverOnMainQueue

复制代码

队列

Queue 类是 GCD 上的包装,用于管理用于在信号中发送数据的队列。一般使用案例有三个 globalMainQueue, globalDefaultQueue, and globalBackgroundQueue. 没有机制可以避免overcommit 排队,我认为可以改进。

一次性

协议 Disposable d定义了可以处置的某些东西。它通常与释放资源或取消任务相关联。四类实施此协议,可以涵盖大多数使用案例: ActionDisposable, MetaDisposable, DisposableSet, and DisposableDict.

承诺

当多个观察者对数据源感兴趣时,为该方案构建了 PromiseValuePromise 类。 支持使用信号更新数据值,同时定义为直接接受值更改。 ValuePromise

让我们看看项目中的一些实际使用案例,演示了 SwiftSignalKit 的使用模式。

iOS 强制应用在访问设备上的敏感信息如: contacts, camera, location, 等. 之前请求用户授权。在与朋友聊天时,电报 iOS 具有将您的位置作为消息发送的功能。让我们看看它如何获得位置授权与信号。

工作流程是一个标准的异步任务,可以由 SwiftSignalKit 建模。authorizationStatus 访问. DeviceAccess.swift 中的功能授权状态返回信号以检查当前授权状态:

代码语言:javascript复制
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 它会从授权统计中观察信号,并在未确定权限时调用该信号。 DeviceAccess.authrizeAccess

Signal.start 返回一个 Disposable实例 。 最佳做法是将其保存在字段变量中并将其处理在。 deinit.

代码语言:javascript复制
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 更改用户名

让我们看看一个更复杂的例子。电报允许每个用户更改UsernameSetupController中唯一的用户名。用户名用于生成公共链接,供他人访问您。

实施应满足要求:

  • 控制器从当前用户名和当前主题开始。电报有一个强大的 theme system,所有的控制器应该是可主题的。
  • 输入字符串应首先在本地验证,以检查其长度和字符。
  • 有效的字符串应发送到后端以进行可用性检查。如果快速键入,请求的数量应受到限制。
  • UI 反馈应遵循用户的意见。屏幕上的消息应告知新用户名的状态:它正在检查、无效、不可用或可用。当输入字符串有效且可用时,应启用正确的导航按钮。
  • 一旦用户想要更新用户名,正确的导航按钮应在更新过程中显示活动指示器。

有三个数据源可能会随着时间的推移而变化:主题、经常账户和编辑状态。主题和帐户是项目的基本数据组件,因此有专用信号: SharedAccountContext.presentationData and Account.viewTracker.peerView. 我会试着在其他帖子中覆盖他们。让我们专注于如何一步一步地用信号建模编辑状态。

#1. 结构 UsernameSetupControllerState 使用三个元素定义数据:编辑输入文本、验证状态和更新标志。提供了多个辅助功能来更新它并获取新实例。

代码语言:javascript复制
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. 状态更改由 statePromiseValuePromise,这也提供了一个整洁的功能,以省略重复的数据更新。还有一个stateValue来保存最新的状态,因为外部ValuePromisenot visible这是项目内部与国家价值相匹配的价值承诺的常见模式。公开阅读访问内部价值可能是对海事组织的适当改进。ValuePromise IMO.

代码语言:javascript复制
let statePromise = ValuePromise(UsernameSetupControllerState(), ignoreRepeated: true)

let stateValue = Atomic(value: UsernameSetupControllerState())

复制代码

#3. 验证过程可以在管道信号中实现。操作员持有延迟 0.3 秒的请求。对于快速键入,先前的未请求将因第 4 步中的设置而取消。delay

代码语言:javascript复制
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位可保留信号, 并更新数据内和何时更改。 statePromisestateValuetext 改变了 TextFieldNode.调用时,将处理前一个, checkAddressNameDisposable.set(), 在第三步触发操作员内部的取消任务。delay

TextFieldNode 文本输入的子类,并包装 UIText 字点。 ASDisplayNode Telegram-iOS 利用 AsyncDisplayKit 的异步渲染机制,使其复杂的消息 UI 流畅且响应迅速。

代码语言:javascript复制
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。

代码语言:javascript复制
let signal = combineLatest(
                 presentationData, 
                 statePromise.get() |> deliverOnMainQueue, 
                 peerView) {
  // update navigation button
  // update controller UI
}

复制代码

结论

SSignalKit 是 Telegram-iOS 对反应性编程的解决方案。 核心组件, 如 SignalPromise, 以与其他反应性框架略有不同的方式实施。它在模块中普遍使用,用于将 UI 与数据更改连接起来。

该设计鼓励大量使用封闭。有许多封闭的相互嵌套,这indents some lines 远。该项目还喜欢 exposing many actions as closures 。对于电报工程师如何保持代码质量和轻松调试信号, 这对我来说仍然是一个神话。

0 人点赞