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

为何要异步渲染 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 的本质。

你可能感兴趣的:(如何构建异步渲染聊天框架)