前言
格式化文本,如下
项目一期支持格式化(行内列表/粗体/斜体)消息,后续版本迭代计划新增图片及代码块等富文本类型。 如何设计一套通用灵活的协议,并在实现层保障版本迭代新增消息类型场景的兼容性与稳定性,是项目的攻坚难点之一
竞品调研
项目前期调研了Slack/钉钉/企微等竞品的实现方式。
- Slack:使用JSON存储传递数据,结构类似XML渲染树
- 钉钉:编辑器使用Webview,违背项目诉求
- 企微:不支持
Slack富文本功能与项目诉求十分契合,我们需要分析借鉴下Slack富文本消息数据结构。
那么问题来了,如何获取Slack富文本消息数据?
如果消息是通过http发送的,且内容未加密,那好办,移动端Charles代理抓包下或者使用Web版本查看网络请求即可。
协议设计
项目协议基于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场景数据模拟验证
架构简介
架构相对比较清晰
RawDataJson层,基于Jackson.StdDeserializer自定义解析
AbstractData层,我们定义了RenderLine,用于辅助转换(DataModel<==>RenderLine<==>Span)
UiData层,Android使用Span表示,结合TextView进行渲染
详见下文
JSON抽象与解析
抽象
这东西因协议需宽松解析,有不少麻烦,开发过程协议抽象也经历了几个版本,先上个终版类图吧
思考一个问题
- 严格解析和宽松解析的各有什么优缺点?
一开始是严格解析协议,这种方式可以将泛型具象化,在解析成本和代码可读性都有好处,举个例子
- 非文本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表示单行文本,并携带所有样式信息
SPAN=>DATA
思路一: 以\n为分隔符,获取多行数据,单行基于Span自带的start及end信息,获取所有Span,对于同范围内的Span进行合并,最后通过Span信息映射到DATA.content/DATA.section
思路二: 贪心算法,双指针迭代,右指针迭代到指定条件(e.g. 读取到不同Span类型信息),构建DATA.content,更新左指针,迭代到\n符号,判定为单行结束,构建DATA.section
理论上这两种方式都可以实现,思路一会有大量的merge动作,e.g.
-
- 粗斜体
上示一行样式文本,在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,递归解析时,传递父节点的相关信息
因抽象数据是多叉树结构,我们采用了递归回溯算法进行构建,简要流程如下
回溯过程,处理脏数据,确保返回数据在本次协议协定范围内
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逻辑,有待重构
- 解析及数据转换过程可建立全链路数据报表,用于监控脏数据,解析转换失败率,解析转换时间等