乐趣区

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

背景

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

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

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

富文本的原理

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

  • 创建过程

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

  1. 先创建 LeafRenderObjectElement 实例。

    1. ComponentElement 方法当中会调用 RichText 实例的 CreateRenderObject 方法,生成 RenderParagraph 实例。
  2. 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 分别独立移动到上面。

然而上面这种方案会带来两个难点:

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

通过 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) 

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

实现方案

关键部分代码实现如下:

  • 统一的占位 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),
           ],
         );
       }
    

效果

先上图看看效果

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

未来

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


本文作者:闲鱼技术 - 玄川

阅读原文

本文为云栖社区原创内容,未经允许不得转载。

退出移动版