如何低成本实现Flutter富文本,看着一篇就够了!

背景

闲鱼是国内最早使用和最大规模使用Flutter的团队,作为一个电商App商品详情页是非常重要场景,其中最主要的技术能力是文字混排。640?wx_fmt=jpeg

我们面对文本类的需求是复杂而且多变,然而Flutter历史的几个版本,Text只能显示简单样式文本,它只有包含一些控制文本样式显示的属性,而通过TextSpan连接实现的RichText也只能显示多种文本样式,这些远远达不到设计需要的能力。被产品和设计怂为啥别人别的平台能做,Flutter为何做不了,不管,必须支持!

640?wx_fmt=jpeg

因此,需要开发一个能力更强的文字混排组件就变得迫在眉睫!

富文本的原理

再讲文字混批组件设计实现前,先来讲讲系统RichText的富文本的原理。

创建过程

640?wx_fmt=png

创建RichText节点的时候其实会创建以下几个对象:

  1. 先创建LeafRenderObjectElement实例。

  2. ComponentElement方法当中会调用RichText实例的CreateRenderObject方法,生成RenderParagraph 实例。

  3. RenderParagraph 会创建TextPainter 负责其就计算宽高和绘制文本到Canvas 的代理类,同时TextPainter 持有TextSpan 文本结构。

RenderParagraph实例最后会将自身登记到渲染模块的Dirty Nodes当中去,渲染模块会遍历Dirty Nodes 将进入RenderParagraph 渲染环节。

渲染过程

RenderParagraph 方法当中封装的是将文本绘制到 canvas 上面的逻辑,主要是用了一个叫做 TextPainter 的模块,其调用过程遵循RenderObject 调用。

  1. PerfromLayout 过程通过调用TextPaint的Layout,在期过程中通过TextSpan 结构树,依次通过AddText 添加各个阶段的文本,最后通过Paragraph的Layout 计算文本高度。

  2. Paint 过程,先绘制clipRect,接着通过TextPaint的Paint函数调用,Paragraph的Paint绘制文本,最后绘制drawRect。

设计思路

通过RichText的文本绘制原理,我们不难发现TextSpan记录了各段文本信息,TextPaint通过记录的信息调用Native接口计算宽高,以及将文本绘制到canvas上面。传统的方案实现复杂的混排,会通过HTML去做一个WebView的富文本,使用WebView在性能上自然不及原生实现,出于性能的考虑,我们设想通过通过原生的方式去实现图文混排。一开始的方案是设计几种特殊的Span(例如:ImageSpan,EmojiSpan等),通过Span记录的信息,在TextPaint的Layout 重新根据各种类型重新计算布局,在Paint过程再分别绘制特殊的Widget,然而这种方案对上面几个涉及的类封装破坏的特别大,需要将RichText、RenderParagraph 源码Copy 出来重新修改。最后设想是后可以通过特殊的文字先占位置,(例如:空字符串),然后在这个文字的位置上面把特殊的Span分别独立移动到上面。640?wx_fmt=png

伴随这个方案有两个难点:

难点一:如何在文本中先占位,并且能制定任意想要的宽高。

通过Google 发现\u200B字符代表ZERO WIDTH SPACE(宽带为0的空白),结合对TextPainter测试,我们发现layout出来的Width总是0,fontSize只决定了高度,结合TextStyle里面的letterSpacing

/// The amount of space (in logical pixels) to add between each letter	
/// A negative value can be used to bring the letters closer.	
final double letterSpacing;

这样我们就能任意的控制这个特殊文字的宽高度。

难点二:如何将特殊的Span移动到位置上面。

通过上面的测试不难发现,特殊的Span其实还是独立Widget和RichText并不融合。所以我们需要知道当前widget相对RichText空间的相对位置,并且结合Stack将其融合。结合TextPaint里面的getOffsetForCaret方法。

/// Returns the offset at which to paint the caret.	
  ///	
  /// Valid only after [layout] has been called.	
  Offset getOffsetForCaret(TextPosition position, Rect caretPrototype)

可以天然的获取到当前占位符相对位置。

实现方案

640?wx_fmt=gif

关键部分代码实现如下:

统一的占位SpaceSpan

SpaceSpan({	
    this.contentWidth,	
    this.contentHeight,	
    this.widgetChild,	
    GestureRecognizer recognizer,	
  }) : super(	
            style: TextStyle(	
                color: Colors.transparent,	
                letterSpacing: contentWidth,	
                height: 1.0,	
                fontSize:	
                    contentHeight),	
            text: '\u200B',	
            recognizer: recognizer);

SpaceSpan 相对位置获取

for (TextSpan textSpan in widget.text.children) {	
      if (textSpan is SpaceSpan) {	
        final SpaceSpan targetSpan = textSpan;	
        Offset offsetForCaret = painter.getOffsetForCaret(	
          TextPosition(offset: textIndex),	
          Rect.fromLTRB(	
              0.0, targetSpan.contentHeight, targetSpan.contentWidth, 0.0),	
        );	
        ........	
      }	
      textIndex += textSpan.toPlainText().length;	
    }

RichtText和SpaceSpan融合

Stack(	
           children: <Widget>[	
           RichText(),	
           Positioned(left: position.dx, top: position.dy, child: child),	
          ],	
        );	
      }

效果

先上图看看效果

640?wx_fmt=jpeg

这种方案的优点是任意Widget可通过SpaceSpan和RichText进行组合,无论是图片、自定义标签、甚至是按钮都可以融合进来,同时对RichText本身封装性破坏较小。

未来

上面只是富文本显示的部分,依然存在着很多局限,还有较多需要优化的点,目前通过SpaceSpan 控件,必需要指定宽高,另外对于文本选择、自定义文字背景这些都是无法支持,其次对富文本编辑器的支持,可以使其编辑文字时,让图片、货币格式化等控件输入等。

就是现在,客户端/服务端java/架构/前端/质量工程师,小闲鱼通通期待你的加入,base杭州阿里巴巴西溪园区。欢迎天马行空的你加入我们,做有创想空间的社区产品、做深度顶级的开源项目,一起拓展技术边界成就极致!

*投喂简历给小闲鱼→guicai.gxy@alibaba-inc.com

640?wx_fmt=png

640?wx_fmt=png

更多系列文章、开源项目、关键洞察、深度解读

请认准闲鱼技术

  • 1
    点赞
  • 0
    评论
  • 3
    收藏
  • 一键三连
    一键三连
  • 扫一扫,分享海报

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、C币套餐、付费专栏及课程。

余额充值