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

版权声明
本文内容均为搬运,目的只为更方便的学习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工程师如何保持代码质量并轻松调试信号,这仍然是我的课题。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,222评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,455评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,720评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,568评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,696评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,879评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,028评论 3 409
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,773评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,220评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,550评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,697评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,360评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,002评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,782评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,010评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,433评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,587评论 2 350

推荐阅读更多精彩内容