Telegram-iOS 源码分析:第五部分(AsyncDisplayKit)

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

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

Telegram-iOS使用AsyncDisplayKit构建大多数UI。AsyncDisplayKit是项目的一个子模块,其中很多功能被移除,一些功能已在Swift中重新实现。本篇文章探讨项目中的组件结构和UI编程模式。

1.概述

AsyncDisplayKit是一个异步UI框架,最初是从Facebook诞生的。它已被Pinterest所采用,并于2017年重命名为Texture。其核心概念是node作为的抽象UIView使用,与React Virtual DOM的想法有点相似。节点是线程安全的,这有助于将复杂的UI操作从主线程中移出,例如图像解码,文本大小调整等。Nodes是轻量级的,这允许你在tables和collections里面不重复使用Cell

part5-asdk.png

如图所示,Telegram-iOS保留约35%的 AsyncDisplayKit代码(蓝色框表示),并删除其他代码。

基本上来说,Telegram-iOS保留了最少的核心Node类,然后重写了几百个Node的子类。这些代码散布在子模块(如DisplayTelegramUIItemListUI)以及支持主要Telegram UI功能的其他子模块中。

2.核心Node

part5-asdisplaynode.png

有一些节点类是构建应用程序用户界面的基本模块。让我们了解一下它们,如图所示。

带箭头的边缘表示右边的Node是左边Node的子类。没有边缘的相同级别的Nodes意味着它们与最左边的Node是相同的父类。

文本

TextNodeImmediateTextNodeASTextNode负责文本渲染。

public class TextNode: ASDisplayNode {
    public internal(set) var cachedLayout: TextNodeLayout?
    
    public static func asyncLayout(_ maybeNode: TextNode?) -> (TextNodeLayoutArguments) -> (TextNodeLayout, () -> TextNode)
}

public class ImmediateTextNode: TextNode {
    public var attributedText: NSAttributedString?
    public var textAlignment: NSTextAlignment = .natural
    public var truncationType: CTLineTruncationType = .end
    public var maximumNumberOfLines: Int = 1
    public var lineSpacing: CGFloat = 0.0
    public var insets: UIEdgeInsets = UIEdgeInsets()
    public var textShadowColor: UIColor?
    public var textStroke: (UIColor, CGFloat)?
    public var cutout: TextNodeCutout?
    public var truncationMode: NSLineBreakMode
    public var linkHighlightColor: UIColor?
    public var trailingLineWidth: CGFloat?
    public var highlightAttributeAction: (([NSAttributedString.Key: Any]) -> NSAttributedString.Key?)?
    public var tapAttributeAction: (([NSAttributedString.Key: Any], Int) -> Void)?
    public var longTapAttributeAction: (([NSAttributedString.Key: Any], Int) -> Void)?
    ...
}

public class ASTextNode: ImmediateTextNode {
    override public var attributedText: NSAttributedString? {
        didSet {
            self.setNeedsLayout()
        }
    }
    ...
}

TextNode利用CoreText渲染NSAttributedString。它有一个计算基于行的文本布局的方法calculateLayout,并重写了父类方法draw以渲染文本。公开的方法asyncLayout用来异步调用布局计算并缓存结果。asyncLayout方法是被设计来供上一级调用的。否则,它将不会呈现任何内容,因为缓存的布局为nil。实现支持RTLAccessibility是值得称赞的。

ImmediateTextNode通过添加更多属性来控制文本布局样式使得TextNode更加丰富。它还支持高亮显示和点击时间。

ASTextNode只需在设置attributedText属性时就是更新布局。这和官方AsyncDisplayKit里的不是同一个,尽管名字一样。

EditableTextNode扩展ASEditableTextNode以支持RTL输入检测。

Image

open class ASImageNode: ASDisplayNode {
    public var image: UIImage?
}

public class ImageNode: ASDisplayNode {
    public func setSignal(_ signal: Signal<UIImage?, NoError>)
}

open class TransformImageNode: ASDisplayNode {
    public var imageUpdated: ((UIImage?) -> Void)?
    public var contentAnimations: TransformImageNodeContentAnimations = []
    
    public func setSignal(_ signal: Signal<(TransformImageArguments) -> DrawingContext?, NoError>, attemptSynchronously: Bool = false, dispatchOnDisplayLink: Bool = true)
    public func setOverlayColor(_ color: UIColor?, animated: Bool)
}

ASImageNode渲染UIImage并使用图像的Size作为Node的Size。同样,它和官方AsyncDisplayKit里的不是同一个Node。

ImageNode接受一个Signal来异步设置图像内容。仅由AvatarNode使用,尽管它的名称看起来很普通。

TransformImageNode是异步图片使用最广泛的类。它支持在更改图片时使用alpha动画,并支持颜色叠加。

Button

open class ASButtonNode: ASControlNode {
   public let titleNode: ImmediateTextNode
   public let highlightedTitleNode: ImmediateTextNode
   public let disabledTitleNode: ImmediateTextNode
   public let imageNode: ASImageNode
   public let disabledImageNode: ASImageNode
   public let backgroundImageNode: ASImageNode
   public let highlightedBackgroundImageNode: ASImageNode
}

open class HighlightTrackingButtonNode: ASButtonNode {
   public var highligthedChanged: (Bool) -> Void = { _ in }
}

open class HighlightableButtonNode: HighlightTrackingButtonNode {
   ...
}

ASButtonNode 为具有图片和标题的button,具有三个状态:normal, highlighted, disabled。

HighlightableButtonNode 在点击按钮时添加高亮动画。

Status

ActivityIndicator 模仿UIActivityIndicatorView样式并提供灵活的选项以自定义细节,例如颜色,直径和线宽

Media

Telegram-iOS实现了一组丰富的组件来支持不同的媒体类型。本文只是大致了解,它值得在本系列中撰写一篇专门的文章,包括FFMpeg集成,第三方视频网站的应用内视频播放,贴纸动画等。

MediaPlayNodeMediaPlayer的子类,用于在AVSampleBufferDisplayLayer上渲染视频帧。

WebEmbedPlayerNode通过嵌入播放网页内的视频WKWebView。它支持来自Youtube,Vimeo,Twitch等的视频。

AnimatedStickerNode播放用于播放来自AnimatedStickerNodeSource的动画。

Bar

SearchBarNodeNavigationBarTabBarNode,和ToolbarNode模仿了在UIKit中对应的功能。它还消除了各系统版本之间行为不一致的影响,UIKit内部对于开发人员是不可见的,这始终是个不太好的问题。

StatusBar 在系统状态栏区域显示呼叫中的文本通知。

List

ListView是为可滑动列表设计的最复杂的node类之一。正如我们从WWDC 2014中学到的那样,它利用隐藏的UIScrollView并借用其pan手势来获得滚动行为。除了管理列表中元素(无论大小)的可见性之外,它还提供其他简洁的功能,例如方便的项目标题,可自定义的滚动指示器,记录项目,滚动项目,捕捉边界等。

GridNode是另一个用于网格布局的滚动UI组件。它在项目中使用到的场景例如贴纸选择,墙纸设置等功能。

3. Controllers

part5-uiviewcontroller.png

ViewController使UIViewController像nodes层次结构的容器一样工作。与官方node控制器类ASViewController不同,它没有可见深度智能预载之类的功能。

| @objc open class ViewController: UIViewController, ContainableController { |
|  | // the root content node |
|  | private var _displayNode: ASDisplayNode? |
|  | public final var displayNode: ASDisplayNode { |
|  | get { |
|  | if let value = self._displayNode { |
|  | return value |
|  | } |
|  | else { |
|  | self.loadDisplayNode() |
|  | ... |
|  | return self._displayNode! |
|  | } |
|  | } |
|  | ... |
|  | } |
|  | open func loadDisplayNode() |
|  | open func displayNodeDidLoad() |
|  |  |
|  | // shared components |
|  | public let statusBar: StatusBar |
|  | public let navigationBar: NavigationBar? |
|  | private(set) var toolbar: Toolbar? |
|  | private var scrollToTopView: ScrollToTopView? |
|  | // customizations of navigationBar |
|  | public var navigationOffset: CGFloat |
|  | open var navigationHeight: CGFloat |
|  | open var navigationInsetHeight: CGFloat |
|  | open var cleanNavigationHeight: CGFloat |
|  | open var visualNavigationInsetHeight: CGFloat |
|  | public var additionalNavigationBarHeight: CGFloat |
|  | } |

[view raw](https://gist.github.com/openaphid/23530e81ca4d4fa8336327e092a6ebf0/raw/950b1f1af5d90f02dedec1523f271d145d1b9301/ViewController.swift) [](https://gist.github.com/openaphid/23530e81ca4d4fa8336327e092a6ebf0#file-viewcontroller-swift)

每个ViewController通过一个root node来管理node层次结构,该root node存储在displayNode该类的属性中。loadDisplayNodedisplayNodeDidLoad方法实现我们在UIViewController中熟悉的懒加载行为。

作为基类,它为子类准备了几个共享的node组件:状态栏,导航栏,工具栏和返回到顶部功能。还有一些方便的属性可以自定义其导航栏,这对于普通的UIViewController而言仍然是一个麻烦的问题。

ViewController很少单独使用,项目中有100多个控制器子类用于不同的用户界面。两种UIKit中最常用的容器控制器UINavigationControllerUITabBarController,分别用NavigationControllerTabBarController重新实现。

open class NavigationController: UINavigationController, ContainableController, UIGestureRecognizerDelegate {
    private var _viewControllers: [ViewController] = []
    // NavigationControllerNode
    private var _displayNode: ASDisplayNode?
    private var theme: NavigationControllerTheme
    
    // manage layout and transition animation
    private func updateContainers(layout rawLayout: ContainerViewLayout, transition: ContainedViewLayoutTransition)
    
    // push with a completion handler
    public func pushViewController(_ controller: ViewController, animated: Bool = true, completion: @escaping () -> Void)
}

// NavigationLayout.swift
enum RootNavigationLayout {
    case split([ViewController], [ViewController])
    case flat([ViewController])
}

// NavigationContainer.swift
final class NavigationContainer: ASDisplayNode, UIGestureRecognizerDelegate
    override func didLoad() {
        // the interactive pop gesture
        let panRecognizer = InteractiveTransitionGestureRecognizer(target: self, action: #selector(self.panGesture(_:)), allowedDirections: ...)
    }
}

NavigationController扩展UINavigationController以借用其公共API,使得它可以像普通的UIViewController一样被使用。它在内部重写了以下所有内容:

  • 直接管理子控制器。由于它只是一个简单的数组,因此可以自由调整以进行堆栈操作。
  • 过渡动画。您可以在ContainedViewLayoutTransition中找到所有动画详细信息。
  • 交互式pop手势。InteractiveTransitionGestureRecognizer可以在整个屏幕范围响应pop手势。
  • 像iPad这样的大屏设备分屏布局。它支持两种类型的布局:flatsplit。最好有一个容器控制器同时支持iPhone和iPad,而不需要花很多精力在容器控制器上UISplitViewController
  • 主题。通过theme属性可以很容易地自定义外观。

TabBarController只能在根屏幕中使用,它是ViewController的子类,而不是UITabBarController,因此不需要保留API。该规则同样适用于ActionSheetControllerAlertControllerContextMenuController。这些实现完美地覆盖了系统视图控制器内部细节,在我看来,用户体验几乎相同。

ItemListController相当于UITableViewController管理一个ListView。它还支持自定义overlay node,search view和items排序。

4.布局

AsyncDisplayKit中的Flexbox布局系统已由混合布局机制取代:

// NavigationBar.swift
//   layout in the main thread
open class NavigationBar: ASDisplayNode {
    override open func layout() {
        super.layout()
        
        if let validLayout = self.validLayout, self.requestedLayout {
            self.requestedLayout = false
            self.updateLayout(size: validLayout.0, defaultHeight: validLayout.1, additionalHeight: validLayout.2, leftInset: validLayout.3, rightInset: validLayout.4, appearsHidden: validLayout.5, transition: .immediate)
        }
    }
    func updateLayout(size: CGSize, defaultHeight: CGFloat, additionalHeight: CGFloat, leftInset: CGFloat, rightInset: CGFloat, appearsHidden: Bool, transition: ContainedViewLayoutTransition)
}

// TabBarController.swift
//   layout in the main thread
open class TabBarController: ViewController {
    override open func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) {
        super.containerLayoutUpdated(layout, transition: transition)
        self.tabBarControllerNode.containerLayoutUpdated(layout, toolbar: self.currentController?.toolbar, transition: transition)
        ...
    }
}


// ListView.swift
//     asynchronously load visible items by the scrolling event
open class ListView: ASDisplayNode, ... {
    public func scrollViewDidScroll(_ scrollView: UIScrollView) {
        self.updateScrollViewDidScroll(scrollView, synchronous: false)
    }
    private func updateScrollViewDidScroll(_ scrollView: UIScrollView, synchronous: Bool) {
        ...
        self.enqueueUpdateVisibleItems(synchronous: synchronous)
    }
    private func enqueueUpdateVisibleItems(synchronous: Bool) {
        ...
        strongSelf.updateVisibleItemsTransaction(synchronous: synchronous, completion:...)
    }
    private func updateVisibleItemsTransaction(synchronous: Bool, completion: @escaping () -> Void)
}
  • 所有布局都是手动完成的。显然,开发者不喜欢自动布局的概念。
  • 布局计算在简单UI的主线程中运行。布局代码可以放在node的layout方法中,也可以放在视图控制器的containerLayoutUpdated方法中。
  • ListView 为其item nodes构建灵活的布局机制,该机制支持同步和异步计算。

5.结论

Telegram集成AsyncDisplayKit的方式令人印象深刻。它在node之上重建整个UIKit组件生态,以提高效率和自由控制。尽管聊天气泡用户界面很复杂,但是聊天消息列表在旧设备上感觉很流畅。处理系统升级适配的代码很少,每年WWDC之后,大多数开发者总是要花一些“快乐的时光”。

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

推荐阅读更多精彩内容