格式化文本消息项目一些思考

811 阅读8分钟

前言

格式化文本,如下
image.png

项目一期支持格式化(行内列表/粗体/斜体)消息,后续版本迭代计划新增图片及代码块等富文本类型。 如何设计一套通用灵活的协议,并在实现层保障版本迭代新增消息类型场景的兼容性与稳定性,是项目的攻坚难点之一

竞品调研

项目前期调研了Slack/钉钉/企微等竞品的实现方式。

  • Slack:使用JSON存储传递数据,结构类似XML渲染树
  • 钉钉:编辑器使用Webview,违背项目诉求
  • 企微:不支持

Slack富文本功能与项目诉求十分契合,我们需要分析借鉴下Slack富文本消息数据结构。
那么问题来了,如何获取Slack富文本消息数据?
如果消息是通过http发送的,且内容未加密,那好办,移动端Charles代理抓包下或者使用Web版本查看网络请求即可。
image.png image.png

协议设计

项目协议基于Slack协议设计,基本上没有太大差异,结构设计开始前,先抛几个问题

  • 如何支持多级列表?
  • 如何兼容新版本/脏数据? 基础结构,类似文本样式存储/文本Mention元素区分等,比较简单,结合JSON(有兴趣的同学可以自己抓下)看应该是一目了然的
    抽象下,Json格式,非叶子结点承载section,叶子结点承载content,数据呈扁平树形结构,深度为三层
    -- root
    ---- section
    ------- content
    ---- (nested)section
    ------- content
    Slack对嵌套内容块的处理思路十分新颖,一般可能会使用多级树形结构去实现,这种方式在数据层会清晰些,但在解析渲染侧相对麻烦,我们必须去遍历树,并记录层级
    Slack巧妙使用了层级的概念,在内容块新增了indent及offset支持嵌套数据
  • indent: 层级,支持渲染缩进
  • offset: 偏移量,仅在有序列表使用,支持渲染符号(1./a.)

我们从压缩数据量角度出发,基于Slack协议进行了部分改造

  • content style使用Int代替多个bool变量,二进制位代表一个样式
  • section type使用Int代替String
  • 独立定义有序列表与无序列表(有序列表offset字段并未在无序列表中使用)

前文提到,协议还需兼容未来版本新数据及极限场景脏数据,对此我们在实现层,对协议数据使用宽松解析策略
所以,什么叫***宽松解析...?

  • 支持未知type section(新版本新增code section)
  • 支持未知type content(新版本新增img content)
  • 支持已知字段不合法数据(text合法为字符串类型,脏数据为整型)
  • 支持未知字段(text类型节点包含unknown字段)
  • 支持不合法嵌套section(section嵌套section嵌套section)
  • 支持section&content混排(section{section, content})

解析仅对以下三点进行强校验

  • 合法JSON数据
  • 所有element需存在合法type字段
  • section节点数据需存在合法elements字段

项目后期针对合法/新/脏数据进行了单元测试及Live场景数据模拟验证

架构简介

架构相对比较清晰 image.png
RawDataJson层,基于Jackson.StdDeserializer自定义解析
AbstractData层,我们定义了RenderLine,用于辅助转换(DataModel<==>RenderLine<==>Span)
UiData层,Android使用Span表示,结合TextView进行渲染
详见下文

JSON抽象与解析

抽象

这东西因协议需宽松解析,有不少麻烦,开发过程协议抽象也经历了几个版本,先上个终版类图吧 image.png 思考一个问题

  • 严格解析和宽松解析的各有什么优缺点?

一开始是严格解析协议,这种方式可以将泛型具象化,在解析成本和代码可读性都有好处,举个例子

  • 非文本section.elements协定所有元素只能是一种类型(要么全部是section,要么全部是element)
  • 文本section.elements协定所有元素只能是element类型 在代码层面可以定义为
  • 非文本section.elements: List<FormatSection>
  • 文本section.elements: List<FormatContent>

一一对应,可读性很高,解析时我们基于type去做类型映射就可以了,未知的type统一映射到UnknownSection/UnknownContent
但有个致命缺陷,严格解析要求数据合法,一旦有脏数据,会导致解析过程失败,所有样式丢失,且不支持section/content混排,在一定程度上限制了后续富文本类型的扩展

综上,我们采用了宽松解析,优点上文提及了,一定前提内,什么垃圾数据都可以解析,而且可以保留所有合法数据的样式,缺点就是代码过几天就看不懂了哈哈哈哈哈哈(所以必须得写多点注释)

BTW,数据抽象时,从类图也可以看出,通过多态继承较为清晰地定义了类职责和通用字段,有些同学可能会考虑使用万能类代替抽象,这种方式强烈不建议使用,有很多缺陷,具体可参考这篇文章。 如何“好好利用多态”写出又臭又长又难以维护的代码?

解析

项目使用了Jackson解析,可继承StdDeserializer并通过注解注入,实现自定义解析
实际上,绝大部分宽松解析策略是在自定义解析实现的,e.g.

  • 未知type数据节点,尝试读取elements字段,若存在,判定为UnknownSection
  • 未知type数据节点,尝试读取text字段,若存在,判定为UnknownContent

DATA-SPAN互转

DATA指JSON抽象数据模型,SPAN指SpannableString

AndroidSpan支持常用文本样式(粗斜体/下划线等)及前缀符号(可自定义)的渲染,且Span继承于CharSequence可基于TextView渲染,毫无疑问,我们可以使用Span进行渲染,问题来了,如何互转?
我们定义了RenderLine用于辅助转换,RenderLine表示单行文本,并携带所有样式信息

image.png

SPAN=>DATA

image.png 思路一: 以\n为分隔符,获取多行数据,单行基于Span自带的start及end信息,获取所有Span,对于同范围内的Span进行合并,最后通过Span信息映射到DATA.content/DATA.section
思路二: 贪心算法,双指针迭代,右指针迭代到指定条件(e.g. 读取到不同Span类型信息),构建DATA.content,更新左指针,迭代到\n符号,判定为单行结束,构建DATA.section

理论上这两种方式都可以实现,思路一会有大量的merge动作,e.g.

    1. 粗斜体
      上示一行样式文本,在AndroidSpan上,会有三个同范围的Span,我们需要两次合并才能构建最终数据 且思路一有一种case十分复杂,e.g.
  • 粗斜粗斜
    上示样式文本,可以用ItalicSpan(0,5) BoldSpan(0,2) BoldSpan(3,5)表示,这种要先拆再合并,理论上可以转换,但,你能想到一个算法来实现吗?

综上,我们采用了思路二进行实现,思路二逻辑更清晰,一次循环即可构建最终数据,计算效率更高
当然在开发过程遇到很多EdgeCase挑战,算法代码第二天就因为一个BUG推翻重写了,但思路没变
比如,mention是特殊的文本样式,破坏了“迭代到不同Span类型信息构建数据”的条件,需兼容

  • 连续多个mention元素兼容,Span信息未更改,但需单独构建DATA.content
  • mention后跟随纯文本,Span信息未更改,但需单独构建DATA.content

DATA=>SPAN

抽象数据理想的解析方式,用一行代码ObjectA.toObjectB就能转换,基于多态实现
FormatElement开放了两个核心抽象方法由子类实现,业务层只需调用toRenderLine即可获取用于渲染的RenderLine集合

  • getRenderLine,上文有提到,RenderLine用于辅助数据解析转换
  • getRenderContext,递归解析时,传递父节点的相关信息

因抽象数据是多叉树结构,我们采用了递归回溯算法进行构建,简要流程如下

image.png
回溯过程,处理脏数据,确保返回数据在本次协议协定范围内
e.g. section&content混排,相邻的content会合并成一个RenderLine 在数据转换过程,确保数据在协定范围内,有许多好处

  • UI测试用例是有边界的,不用考虑新/脏数据会导致低版本出现UI错乱问题
  • 转换成Span的逻辑更清晰,不用考虑新/脏数据兼容逻辑 RenderLine转Span,基于RenderLine持有的content做一层转换即可,因项目及前期代码设计问题,RenderLine转Span尚未做抽象处理,代码有较多if-else逻辑,有待重构


注:一般情况下,不建议在工程中使用递归,避免StackOverFlow,因协议目前协定树深度不会三层,使用递归并无风险

稳定性保障

所有测试同步覆盖,三端消息发送与接收,高低版本消息接收与发送

单测

  • 基于宽松解析策略构建合法数据与脏数据测试用例,用于验证JSON解析(DATA-SPAN互转尚未构建,用例可以复用)

Live场景校验

  • Web端mock生成合法数据与脏数据,测试UI渲染
  • 其他交给测试同学了

日志

  • 一次解析转换映射一个唯一ID,一次解析转换的所有日志均有同一ID标识
  • 关键数据转换过程及出入参数添加日志,用于辅助未来线上问题排查

性能保障

数据解析及转换过程添加了时间戳埋点,极限场景测试
小米10,5000个字符,解析转换时间<3ms

展望

  • 协议抽象,解析及数据转换目前以包隔离形式抽离在protocal及framework模块,期望后续可跟随IM模块抽离
  • RenderLine<=>Span尚未做抽象处理,代码有较多if-else逻辑,有待重构
  • 解析及数据转换过程可建立全链路数据报表,用于监控脏数据,解析转换失败率,解析转换时间等