美文网首页
Telegram-iOS 源码分析:第二部分(SSignalKi

Telegram-iOS 源码分析:第二部分(SSignalKi

作者: 灰原丶逗 | 来源:发表于2020-11-11 16:17 被阅读0次
    版权声明
    本文内容均为搬运,目的只为更方便的学习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闭包,只要订阅没有终止
    putErrorerror闭包发送错误并终止订阅
    putCompletion调用completed闭包终止订阅

    操作符(Operators)

    Signal定义了一系列的操作符来服务基础函数。这些基础函数根据它们的功能被划分为几类:CatchCombineDispatchLoopMappingMetaReduceSideEffectsSingleTake,和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协议定义了可以处理的东西。它通常与释放资源或取消任务相关。有四个类实现了这一协议,可以覆盖大多数使用情况,这四个类分别是:ActionDisposableMetaDisposableDisposableSet,和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
                }
        }
    }
    

    LocationPickerControllerpresent出来时,它将观察来自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中具有唯一性的用户名。用户名用于生成公共链接,以供其他人搜索到您。

    part-2-username.png
    实现应符合以下要求:
    • 控制器以当前用户名和当前主题开头。Telegram具有强大的主题系统,所有控制器都应具有更换主题的特性。
    • 输入的字符串应首先在本地验证,以检查其长度和字符。
    • 将有效字符串发送到后端以进行可用性检查。在快速键入的情况下,应限制请求的次数。
    • UI反馈应遵循用户的输入。屏幕上的信息应告诉新用户名的状态:正在检查,无效,不可用或可用。输入字符串有效且可用时,应启用右侧导航按钮。
    • 用户确定更新用户名,则右侧导航按钮应在更新期间显示转子。

    随时间变化的数据源共有三个:主题,当前帐户和编辑状态。主题和帐户是项目中的基本数据组件,因此有专用的信号:SharedAccountContext.presentationDataAccount.viewTracker.peerView。我将尝试在其他帖子中介绍它们。让我们集中讨论如何使用Signal逐步建模编辑状态。

    1. 结构体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 valuestate 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持有信号,当TextFieldNodetext发生变化时,更新statePromisestateValue的数据。当调用checkAddressNameDisposable.set()时,前一个在第三步中触发操作符delayMetaDisposable在内部取消任务。

    TextFieldNodeASDisplayNode的子类,并包装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的响应式编程解决方案。核心组件(如SignalPromise)与其他响应式框架的实现方式稍有不同。它已在各个模块中广泛使用,以将UI与数据更改连接起来。

    设计鼓励大量使用闭包。有许多相互嵌套的闭包,这些闭包使很远的行得到缩进。该项目还喜欢将许多操作公开为灵活性的闭包。Telegram工程师如何保持代码质量并轻松调试信号,这仍然是我的课题。

    相关文章

      网友评论

          本文标题:Telegram-iOS 源码分析:第二部分(SSignalKi

          本文链接:https://www.haomeiwen.com/subject/wxjebktx.html