本系列文章主要介绍Flutter富文本编辑的设计和实现,从协议层、渲染层、自定义扩展以及体验优化等方面,详细介绍如何实现一个功能完善、可扩展、高性能的Flutter富文本编辑器,以及闲鱼在实践过程中遇到的问题和我们的一些解法。
协议篇文章,我们介绍了Flutter富文本编辑器协议层的设计。以Slate为例,介绍了协议层设计的几个重要的概念:嵌套Model、Opeartion、Normalizing;站在Slate的肩膀上,让我们有了一个强壮、设计完善的富文本协议层,接下来就让我们看看渲染层是如何实现的;
让我们回顾一下Mural整体的架构设计分层:
渲染层主要工作是将协议Model转换成Widget渲染到屏幕上,以及处理选区、光标的计算和绘制,处理用户的手势交互、键盘交互等一系列工作;
首先让我们来看下Flutter的TextField是如何渲染的:
如上图所示,Textfield继承自StatefulWidget,会build嵌套的Widget tree,其中有几个比较关键的Widget:
TextSelectionGestureDetector处理手势交互相关的逻辑,比如单击移动光标、长按选择文字展示Toolbar等等;
另一个比较重要的Widget——EditableText;EditableText在build的时候,通过buildTextSpan方法,根据TextEditingValue的普通文本以及composing部分,创建一个Textspan对象给_Editable;最终RenderEditable通过TextPainter将文本绘制到canvas上;
如上图所示,Mural在渲染层的设计上,与原生TextField前面一部分基本是一致的,不同之处从MuralEditable
开始,对应到TextField的EditableText
;
上面在协议层我们说了,Slate在协议在设计上是与Dom一致的,到Flutter渲染层,就会将Dom树转换成Widget tree,最终渲染到屏幕上;
MuralEditable不再是简单的创建一个TextSpan,而是按照Dom树结构,每一个Element映射成一个Widget;每个Element对应的Widget,创建的RenderObject实现了抽象类:RenderEditorInlineBox
;
接下来我们再来看看Element对应的Widget,是怎么处理它的子节点的:
我们以最简单的EditableTextLine
为例,包含Leading和Body两部分,Leading负责渲染段落修饰相关的内容,比如有序段落的序号、引用段落前面的装饰竖线等;Body则负责渲染具体的富文本内容,实现了抽象类:RenderEditorTextBox
,最终依然将所有的叶子节点转换成InlineSpan,通过TextPainer将文本绘制到屏幕上;
EditorUtils
的buildChildren
方法实现如下:
光标和选区是富文本编辑器渲染层另外一个需要处理的难点;
与原生TextField相比,Mural在处理光标和选区处理更加复杂;TextField所有输入文本都绘制在一个TextPainter,前面我们说过,Mural每个Element都是一个独立的段落,对应一个RenderObject;在Mural中,我们需要计算用户手势操作不同段落的光标位置以及段落之间的选区计算;
要实现Mural的光标和选区渲染,需要解决如下问题:
1. 多Element点击获取TextPosition;
2. TextPosition to MuralPoint;
3. 光标位置计算;
如上图所示,当用户点击绿色光点位置之后,首先我们可以根据点击事件确认被点击是哪一个Element所渲染的RenderObject;
首先我们通过globalToLocal
方法将手势回调的globalPosition
转换为相对于Mural的localPosition
;接下来遍历MuralRenderEditable的child,寻找包含localPosition
的child;
如上面介绍的,Element渲染的RenderObject实现了RenderEditorInlineBox
抽象类,也就可以通过getPositionForOffset
方法获取到相对于当前TextPainter的TextPosition;
接下来就要解决第二个问题,如何将TextPosition转换为协议对于光标、选区位置的描述;
以上图为例,点击之后,TextPosition的Offset为12,而Slate协议是如何描述这样一个光标位置呢?如上图所示,变成了Path
为[0,2]
,offset
为2
的Point
。
接下来就是光标位置计算,通过TextPainter的getOffsetForCaret
方法,获取选中Element对应RenderObject的光标位置,然后转换成相对于Mural全局的Offset;
整体过程梳理如下:
在实现自定义表情的过程中,我们发现在展示状态,复杂的WidgetSpan渲染是不存在问题的,但是在编辑状态支持WidgetSpan遇到了一系列问题;
简单一点的做法就是,在编辑状态将表情变成中括号包裹的文字,变成一个不可编辑的inline&void类型的Element;
但我们目标是实现一个所见即所得的富文本编辑器,为了在编辑状态支持WidgetSpan,需要解决如下几个问题:
1. Element到WidgetSpan渲染;
2. TextValue与Native同步问题;
3. 光标、选区TextBox计算问题;
我们定义了MuralCustomElement
这样一个自定义Element的抽象类,如果要实现自定义表情Element的渲染,需要继承自它:
其中自定义表情长度计算与Emoji不同的一点,我们认为自定义表情始终长度为一;
因为是Inline&Void类型,所以isInline
和isVoid
都返回true
;
Flutter文本输入组件的基本原理,就是在Native侧创建一个TextField组件,通过TextInputConnection实现双端事件交互以及TextValue同步等逻辑;
当用户操作键盘进行文字的输入删除、键盘收起、移动光标等操作,会同步到Flutter侧;同样的,在Flutter进行插入、复制、手势导致Selection变化等操作,通过调用TextInputConnection
的setEditingState
同步给Native侧的组件;
当我们输入一个表情的时候,从Flutter角度看,我们输入了一个特殊的长度为1的字符,这个时候我们就需要将这个TextValue的变化同步给Native;
我们参考PlaceholderSpan的实现,使用字符\uFFFC
同步给Native;
如果我们不做任何处理会发现,当包含WidgetSpan的时候,光标的位置总会计算Offset为零;深入了解代码发现问题所在:
我们需要处理WidgetSpan的codeUnitAtVisitor
以及getSpanForPositionVisitor
方法:
自定义表情作为WidgetSpan的例子,其实是相对简单的;对于WidgetSpan嵌套WidgetSpan,嵌套的WidgetSpan可以被选择、光标移动的场景,要怎么实现呢?大家可以想一想。
当用户键盘输入的时候,Engine侧会通过message channel发送TextInputClient.updateEditingState
事件,将最新的TextEditingValue
同步到Flutter侧;
对于TextField来说,更新的过程比较简单,整体更新TextValue即可;但对于Mural来说,每一次TextValue的更新,都进行一次TextValue到Slate Model的转换,频繁执行导致编辑状态下的卡顿,性能大大下降;我们采用了diff的方式,判断用户输入、删除内容,进而调用Commond更新Model,刷新界面渲染;
我们需要对于换行符做特殊的处理,正如之前提到过的,Element是不包含换行符的,每一次换行都会新增一个新的Element节点;
另外一个需要处理的问题就是移动光标的处理,如:iOS的长按移动光标、Android的横扫键盘移动光标以及第三方输入法移动光标的键盘操作;这里的处理方案,iOS主要是处理TextInputClient.updateFloatingCursor
事件,根据Offset计算光标位置,Android以及第三方输入法的操作,主要是在TextInputClient.updateEditingState
同步处理。
扩展能力是我们设计之初就非常重视的能力,为接入方提供简单、强大的自定义扩展能力,支持复杂、不断变化的业务诉求;接下来我们就以自定义主题和撤销功能的实现,来看一看Mural在扩展能力方面的设计。
如上面视频演示的,当输入两个#
中间包含字符,则变成一个主题的样式,点击可以跳转到对应的主题落地页;可以对主题进行编辑,如果删掉其中一个#
,则变成普通的文本。
要实现这样一个自定义主题,我们需要实现以下几个步骤:自定义Element、自定义Normalizing;
首先是定义Element:
接下来就轮到强大的自定义Normalizing出场了,通过自定义规则,处理主题Node节点校验:
只需要这样简单两步,就实现了主题能力的支持;业务还可以根据自己的需求定制更加复杂的场景,比如有序段落等等。
如上面图所示,我们实现了一个简单的Plugin层的扩展——撤销功能;在前面讲到协议层设计的时候,我们讨论过Slate的精简的Opeartion设计,每一次交互的Commond,最终都会拆解成一个或者多个Opeartion执行;我们可以通过以下三步实现plugin的扩展:
1. 重写Operation的apply方法,通过过滤、合并等操作,记录Opeartion执行的历史;
2. 实现Opeartion的reverse方法;
3. 根据Opeartion执行历史,调用Opeartion的reverse方法,执行reverse操作;
通过两篇文章,我们介绍了富文本编辑器协议层、渲染层设计和实现,完成了一个功能完善的Flutter富文本编辑器;接下来我们会介绍Flutter富文本编辑器体验优化方面闲鱼的一些实践和挑战。