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

版权声明
本文内容均为搬运,目的只为更方便的学习Telegram编码思维。

如需查阅原作者文章,附赠原文章机票

介绍

Ťelegram-iOS在大多数模块中使用响应式编程。有三个框架可以在项目内部实现响应功能:

  • MTSignal:这可能是他们在Objective-C中首次尝试响应式范例。它主要用于MtProtoKit模块中,该模块实现了Telegram的移动端协议MTProto。
  • SSignalKit:它是MTSignal的进阶,具有更丰富的基础使用和操作,可用于更基础的场景。
  • SwiftSignalKit:SSignalKit的Swift版本。
    这篇文章重点介绍SwiftSignalKit,以用例说明其设计。

设计

信号(Signal)

Signal是捕获“随时间变化”概念的一个类。其特点如下所示:

// 伪代码
public final class Signal {
    public init(_ generator: @escaping(Subscriber) -> Disposable)
    
    public func start(next: ((T) -> Void)! = nil, 
                      error: ((E) -> Void)! = nil, 
                      completed: (() -> Void)! = nil) -> Disposable
}

为了创建一个Signal,它接受一个generator闭包,该闭包定义了生成数据(),捕获异常()和更新完成状态的方式。一旦创建好,方法start就可以注册观察者闭包。

订阅(Subscriber)

Subscriber具有考虑线程安全性的逻辑,将数据分发给每个观察者闭包。

// 伪代码
public final class Subscriber {
    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定义了一系列的操作符来服务基础函数。这些基础函数根据它们的功能被划分为几类:Catch,Combine,Dispatch,Loop,Mapping,Meta,Reduce,SideEffects,Single,Take,和Timing。
以一些映射操作符为例:

public func map(_ f: @escaping(T) -> R) -> (Signal) -> Signal
public func filter(_ f: @escaping(T) -> Bool) -> (Signal) -> Signal
public func flatMap(_ f: @escaping (T) -> R) -> (Signal) -> Signal
public func mapError(_ f: @escaping(E) -> R) -> (Signal) -> Signal

像操作符map()一样,进行转换闭包并返回一个函数以更改Signal的数据类型。

有一个方便的|>操作员可以将这些操作符像管道一样链接起来:

//自定义操作符   |>
precedencegroup PipeRight {
    associativity: left
    higherThan: DefaultPrecedence
}

infix operator |> : PipeRight

public func |> (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 {
    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被present出来时,它将观察来自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.presentationData和Account.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 {
    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在内部取消任务。

TextFieldNode是ASDisplayNode的子类,并包装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 源码分析:第二部分(SSignalKit))