如何构建异步渲染聊天框架

为何要异步渲染 UI

众所周知, 在 iOS 系统中, UI 只能在主线程中渲染,在 iPhone 13 之前, iOS 系统的最高屏幕刷新率为 60 FPS, 意味着每 16ms 就需要处理完一个渲染流程.

鉴于目前 iPhone 越来越强大的性能, 优化 App 性能和流畅度在当下也已经不是热门话题了.常规的方法大致有以下几类.

  1. 优化 TableView ,例如缓存高度等(其实目前用 self-sizing 布局的 UITableViewCell 的越来越多,这种优化方法聊胜于无)
  2. 防止离屏渲染(圆角问题等)
  3. 用 collectionView 或者 tableView 的 prefetch 等.
  4. 将繁复操作移步到异步线程处理.

诸如此类, 但是无法回避的是, UI 渲染始终是最耗性能的任务.理所当然的,自然有开发者开始思考,我们能否在异步线程中渲染 UI.

答案当然是可以.

异步渲染的秘密

一言以蔽之,异步渲染的秘密就是利用 TextKit 将 UI 的绘制变为富文本排版和渲染.

异步渲染带来的优点大致有以下几点.

  1. 减少了视图的图层数量(通过 draw 方法讲内容绘制为图片,所以整个视图变为一个 imageView).
  2. TextKit 可以不在主线程渲染和排版,将内容直接渲染到 Layer 层 可以轻松的到满帧 60 FPS.

当然,凡事有利必有弊.

弊端大概有:

  1. 处理不好TextKit 渲染有可能导致闪屏(因为即将 display 的时候还未在异步线程渲染完毕)
  2. 很难处理点击等事件
  3. 不太好处理线程之间的切换,处理不好很容易 Crash.

AsyncChatKit

融云其实已经有了一款基于 UIView 的 IMKit 框架,结合了融云的 IMLib 进行了深度封装.

除此之外,融云的场景化团队采用了 Texture 这个异步渲染框架 + Deepdiff 算法来作为我们的底层技术框架新做了一套基于异步渲染的 ChatKit ,叫做 AsyncChatKit.

简单介绍一下 AsyncChatKit 依赖的两个核心组件。Texture 和 Deepdiff。

Texture 以前叫做 AsyncDisplayKit, 是 Facebook 开源的一套,基于 TextKit 封装的异步渲染框架, 它比较理想的解决的异步渲染的很多问题,比如线程, 渲染机制, 点击事件等, 利用 CALayer 实现的 ASDisplayNode 可以很方便的像操作 UIView 一样绘制我们的 UI. 并且 Texture 利用了类似于 CSS 中 Flex 的布局方式. 在布局上也会比 Autolayout 方便和高效.

再结合上 Deepdiff 算法, 可以比较高效的 reloadData. 所以两者结合后实现的消息框架非常丝滑.

下图是AsyncChatKit 在加载富文本, GIF, 大图等内容的时候依然能保持 60FPS 的截图.

https://tva1.sinaimg.cn/large/008i3skNly1gwvs7sc6wlj30u01sx48e.jpg

重点不是框架的实现,而是框架的设计

当然,本文并不是为了介绍 Texture 和 Deepdiff 的使用与结合,因为相关文章和博客很多。

本文会讨论以下几个问题。

  1. 如何设计框架以降低用户使用框架的门槛
  2. 如何利用状态机和状态驱动来驱动 UI 的变更

有哪些门槛

  1. 由于 Texture 本身的实现机制已经与 UIKit 南辕北辙了。所以如果框架内的大量自定义视图需要让用户直接学习 Texture 的话无疑提升了框架的使用体验和使用门槛。
  2. MessageKit 的消息类型和特定 UI 的绑定如何设计实现?让用户的重点放在如何接收发送消息而不是去关心消息和数据如何绑定。

应该如何降低这种门槛呢?

下面会以聊天软件中常见的输入框举例,我们是怎么用状态机实现复杂的布局变换。

输入框与状态机

我们先来庖丁解牛一下微信的输入框。

如下图所示。

https://tva1.sinaimg.cn/large/008i3skNly1gwvsvm6kvqj30u00wvwgh.jpg

微信的输入框状态除了上图的键盘输入状态之外,还有下列3 种

  • 音频录制
https://tva1.sinaimg.cn/large/008i3skNly1gwvt1jznxqj30wa07b0sy.jpg
  • 表情输入
https://tva1.sinaimg.cn/large/008i3skNly1gwvt2b6b4mj30u00y1jvb.jpg
  • 扩展视图
https://tva1.sinaimg.cn/large/008i3skNly1gwvt363qquj30wd0ramy6.jpg

经过分析,我们不难发现。

  1. 状态总共有 4 种。
  2. 每种状态有以下特点。
    1. 状态切换会导致输入框左右的按钮列表和功能发生变化。
    2. 输入框有可能会被遮罩(例如音频录制状态,输入框会被录制按钮遮住)
    3. 每种状态意味着输入框附属的视图会发生变化
      1. 音频录制没有附属视图
      2. 表情状态有表情附属视图
      3. 扩展状态有扩展附属视图
      4. 键盘输入状态有键盘视图
  1. 状态之间的切换是由以下事件控制的。

  2. 点击音频输入按钮会切换为音频输入状态

  3. 点击表情按钮会切换为表情输入状态

  4. 点击扩展按钮会切换为扩展输入状态

  5. 点击键盘按钮或者输入框会切换为输入状态

综上所述

我们总结出了,一个输入框,是由 4 种状态,5 种事件来驱动的。

接下来,就是用代码来抽象出来。

public protocol InputBarEvent {
    var id: String { get }
    var image: UIImage? { get }
}

public protocol InputBarState {
    associatedtype Event: InputBarEvent
    associatedtype State: InputBarState
    var attachNode: ASDisplayNode { get }
    var attachNodeHeight: CGFloat { get }
    var leftEventList: [Event] { get }
    var rightEventList: [Event] { get }
    var showKeyboard: Bool { get }
    
    func transitionState(event: Event) -> Self
    func transitionState(keyboardState: SystemKeyboardEvent) -> Self
    func isEqual(_ other: Self) -> Bool
}

public enum SystemKeyboardEvent {
    case willShow(params: KeyboardParameters)
    case willHide(params: KeyboardParameters)
}

来解析以下代码的含义。

  1. 我们定义了一个 Event 的protocol,即 id 与 image, 其实是利用 InputBarEvent 生成一个按钮。
  2. attachNode: 即 状态的附属视图
  3. attachViewHeight: 即附属视图的高度。
  4. leftEventList: 即输入框左边的按钮列表
  5. rightEventList: 即输入框右边的按钮列表
  6. showKeyboard:即是否收起或展开系统键盘
  7. transitionState(event: Event) : 该方法用来收到事件以转换状态
  8. transitionState(keyboardState: SystemKeyboardEvent) : 该方法用来收到系统的键盘事件来转换状态

如何利用我们设计的状态机实现一个微信样式的输入框

talk is cheap, show me the code .

我们来看看是否能利用我们设定的protocol 来实现一个微信输入框。

//
//  InputBarStateImpl.swift
//  AsyncChatKit
//
//  Created by zang qilong on 2021/11/14.
//

import Foundation
import AsyncDisplayKit

public enum WechatButtonEvent {
    case clickEmoji
    case keyboardTrigger
    case clickAudio
    case clickPlus
}

extension WechatButtonEvent: InputBarEvent {
    public var id: String {
        switch self {
        case .clickEmoji:
            return "emoji"
        case .keyboardTrigger:
            return "keyboard"
        case .clickAudio:
            return "audio"
        case .clickPlus:
            return "plus"
        }
    }
    public var image: UIImage? {
        switch self {
        case .clickEmoji:
            return UIImage(named: "input_state_emoji")
        case .keyboardTrigger:
            return UIImage(named: "input_state_keyboard")
        case .clickAudio:
            return UIImage(named: "input_state_audio")
        case .clickPlus:
            return UIImage(named: "input_state_plus")
        }
    }
}

public enum WechatInputBarState: InputBarState, Equatable {
    public typealias Event = WechatButtonEvent
    public typealias State = WechatInputBarState
    
    case initial(params: KeyboardParameters?)
    case input(params: KeyboardParameters?)
    case audio
    case emoji
    case plus
    
    public var attachNode: ASDisplayNode {
        switch self {
        case .initial:
            let instance = UIView()
            instance.backgroundColor = UIColor(hex6: 0xf6f6f6)
            return ASDisplayNode {
                return instance
            }
        case .input:
            return ASDisplayNode()
        case .audio:
            let view = UIView()
            view.frame = CGRect(x: 0, y: 0, width: UIScreen.main.bounds.width, height: 300)
            view.backgroundColor = .red
            return ASDisplayNode {
                return view
            }
        case .emoji:
            let view = UIView()
            view.frame = CGRect(x: 0, y: 0, width: UIScreen.main.bounds.width, height: 300)
            view.backgroundColor = .red
            return ASDisplayNode {
                return view
            }
        case .plus:
            let view = UIView()
            view.frame = CGRect(x: 0, y: 0, width: UIScreen.main.bounds.width, height: 300)
            view.backgroundColor = .purple
            return ASDisplayNode {
                return view
            }
        }
    }
    
    public var attachNodeHeight: CGFloat {
        switch self {
        case .initial:
            return UIApplication.safeBottomInset
        case .input(let params):
            if let height = params?.height {
                return height
            }
            return UIApplication.safeBottomInset
        case .audio:
            return UIApplication.safeBottomInset
        case .emoji:
            return 300
        case .plus:
            return 300
        }
    }
    
    public var leftEventList: [WechatButtonEvent] {
        switch self {
        case .input, .initial, .plus:
            return [WechatButtonEvent.clickAudio]
        case .audio:
            return [WechatButtonEvent.keyboardTrigger]
        case .emoji:
            return [WechatButtonEvent.clickAudio]
        }
    }
    
    public var rightEventList: [WechatButtonEvent] {
        switch self {
        case .input, .initial:
            return [WechatButtonEvent.clickEmoji, WechatButtonEvent.clickPlus]
        case .audio:
            return [WechatButtonEvent.clickEmoji, WechatButtonEvent.clickPlus]
        case .emoji:
            return [WechatButtonEvent.keyboardTrigger, WechatButtonEvent.clickPlus]
        case .plus:
            return [WechatButtonEvent.clickEmoji, .keyboardTrigger]
        }
    }
    
    public var showKeyboard: Bool {
        switch self {
        case .initial:
            return false
        case .input:
            return true
        case .audio:
            return false
        case .emoji:
            return false
        case .plus:
            return false
        }
    }
    
    public func transitionState(event: WechatButtonEvent) -> WechatInputBarState {
        switch event {
        case .clickEmoji:
            return .emoji
        case .keyboardTrigger:
            return .input(params: nil)
        case .clickAudio:
            return .audio
        case .clickPlus:
            return .plus
        }
    }
    
    public func transitionState(keyboardState: SystemKeyboardEvent) -> WechatInputBarState {
        switch keyboardState {
        case .willShow(let params):
            return .input(params: params)
        case .willHide(let params):
            switch self {
            case .initial:
                return .initial(params: nil)
            case .input:
                return .initial(params: params)
            case .audio:
                return .audio
            case .emoji:
                return .emoji
            case .plus:
                return .plus
            }
        }
    }
    
    public func isEqual(_ other: WechatInputBarState) -> Bool {
        switch (self, other) {
        case (.initial(let param1), .initial(let param2)):
            return param1?.height == param2?.height && param1?.duration == param2?.duration && param1?.curve == param2?.curve
        case (.input(let param1), .input(let param2)):
            return param1?.height == param2?.height && param1?.duration == param2?.duration && param1?.curve == param2?.curve
        case (.audio, .audio):
            return true
        case (.emoji, .emoji):
            return true
        case (.plus, .plus):
            return true
        default:
            return false
        }
    }
}

大家可以看一下是否满足了实现一个微信输入框的要求。

UI = f(state)

需要强调的是,当我们实现一个比较复杂的 UI 视图的时候,我们本质上是在抽象出这个视图的各种状态和事件。

这也是 UI 的本质。

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

推荐阅读更多精彩内容