前言

最近工作中处理的文本相关的内容较多,不论是刁钻的需求还是复杂的问题,最终都会引向一点“Flutter中的文本是如何绘制的?”。 这里我将以“调整下划线与文字的间距”为切入点并结合自定义Engine,记录一下我的个人分析和实践的结果,希望对各位有帮助,如有错误还望指出。

Text Widget

带下划线文本的显示

Flutter中,显示一行带有下划线的文本的代码如下:

Text('Flutter Demo Home Page', 
    style: TextStyle(
      decoration: TextDecoration.underline //下划线
    ),
)

其展示效果如下图:

在这里插入图片描述

为了调整下划线的间距,我们需要分析Text的实现原理。

Framework侧的实现原理浅析

为了便于大家对后文梳理过程有一个结构上的理解,先在此贴一下flutter架构图。

在这里插入图片描述

结构梳理

打开Text文件,可以看到其内部将文案转为一个TextSpan并作为参数传递给RichText

@override
Widget build(BuildContext context) {
  
  //...省略无关代码
  
  result = RichText(
      ///...各种配置参数
      text: TextSpan(
        style: effectiveTextStyle,
        text: data,
        children: textSpan != null ? <InlineSpan>[textSpan!] : null,
      ),
    );
  return result;
}

进一步查看RichiText源码可以发现其继承MultiChildRenderObjectWidget,内部通过createRenderObject()函数创建了RenderParagraph并将TextSpan传入其内,

class RichText extends MultiChildRenderObjectWidget {
    ///...省略代码
      @override
  RenderParagraph createRenderObject(BuildContext context) {
    assert(textDirection != null || debugCheckHasDirectionality(context));
    return RenderParagraph(text,
      textAlign: textAlign,
      textDirection: textDirection ?? Directionality.of(context),
      softWrap: softWrap,
      overflow: overflow,
      textScaler: textScaler,
      maxLines: maxLines,
      strutStyle: strutStyle,
      textWidthBasis: textWidthBasis,
      textHeightBehavior: textHeightBehavior,
      locale: locale ?? Localizations.maybeLocaleOf(context),
      registrar: selectionRegistrar,
      selectionColor: selectionColor,
    );
  }
}

此类初始化的同时会创建一个TextPinter对(此对象非常重要,连接着文字的布局和绘制)。

  RenderParagraph(InlineSpan text, {
    ///...各种配置参数
  }) : ///...省略无关代码
       _textPainter = TextPainter(
         text: text,
         textAlign: textAlign,
         textDirection: textDirection,
         textScaler: textScaler == TextScaler.noScaling ? TextScaler.linear(textScaleFactor) : textScaler,
         maxLines: maxLines,
         ellipsis: overflow == TextOverflow.ellipsis ? _kEllipsis : null,
         locale: locale,
         strutStyle: strutStyle,
         textWidthBasis: textWidthBasis,
         textHeightBehavior: textHeightBehavior,
       ) {
    addAll(children);
    this.registrar = registrar;
  }

通过对RichTextTextSpan以及RenderParagraph的分析可发现,TextSpan的父类是InlineSpan,其另一实现是PlaceHolderSpan(WidgetSpan便是它的子类),而RichText则是将两者聚合起来交由RenderParagraph处理相关的点击,layout,paint等操作,如下图:

在这里插入图片描述

绘制流程浅析

我们进一步观察RenderParagraph的两个主要函数performLayout()paint(),发现它们最终都会转到调用textPainter的相关函数:


void _layoutTextWithConstraints(BoxConstraints constraints) {
  _textPainter
    ..setPlaceholderDimensions(_placeholderDimensions)
    ..layout(minWidth: constraints.minWidth, maxWidth: _adjustMaxWidth(constraints.maxWidth));
}

@override
void performLayout() {
    //...省略代码
  _layoutTextWithConstraints(constraints);
  positionInlineChildren(_textPainter.inlinePlaceholderBoxes!);

  //...省略代码
}
@override
void paint(PaintingContext context, Offset offset) {
  //...省略代码

  _textPainter.paint(context.canvas, offset);

  //....
}

textPainter中,无论是布局还是绘制在对相关配置做了简单的调整和初始化后,便会进一步初始化并调用ui.Paragraph的相关函数。我们继续跟进ui.Paragraph这个类,发现它只是一层接口,具体实现是_NativeParagraph,而该类则代理的是engineParagraph的接口。

    abstract class Paragraph {
        ///...
    }

    base class _NativeParagraph extends NativeFieldWrapperClass1 
        implements Paragraph {
        
          //此类由engine创建,并关联对应接口,如 layout函数
          @override
          void layout(ParagraphConstraints constraints) {
              //framework转到engine侧
            _layout(constraints.width);
            assert(() {
              _needsLayout = false;
              return true;
            }());
          }
          //对应engine侧的接口
          @Native<Void Function(Pointer<Void>, Double)>(symbol: 'Paragraph::layout', isLeaf: true)
          external void _layout(double width);
        
        //...省略部分代码
    }

通过以上的梳理,我们可以得到一张大致的调用链:

在这里插入图片描述

至此framework层的使命便结束了,回顾整个流程,可以发现该层的实现很简单,多数是配置和初始化的操作,不涉及到具体的布局和绘制操作,接下来我们转到Engine

Engine

获取和编译源码

为了继续我们在engine的分析和调试,首先我们需要拥有engine源码和编译能力,由于这并不是本篇重点以及网络上已有大量前辈提供了相关文章,所以这里我仅对流程做简单介绍,并在文末贴出相关参考文章。

首先,你最好有个梯子xD,其次你需要下载配置谷歌的depot_tools,它用于负责管理flutter工程的依赖。之后在这个路径:你的flutter-sdk路径/bin/interval/engine.version,查看你当前flutter对应的engine版本号,如我的是:db49896cf25ceabc44096d5f088d86414e05a7aa

然后创建一个文件夹,并在此文件夹内运行fetch flutter拉取工程代码。拉取完成后,通过git切换分支到上面的那个版本号,再使用gclient sync 同步依赖,这样你就获取到了所有源码。

Setting-up-the-Engine-development-environment.

有了源码后,通过gnninja,你就可以进行编译了,例如我要编译android端的产物,就相继运行如下指令:

./flutter/tools/gn --android --android-cpu arm64 --unoptimized --no-stripped 

./flutter/tools/gn --unoptimized --mac-cpu arm64 ninja -C 

out/android_debug_unopt_arm64 & ninja -C out/host_debug_unopt_arm64

至此,我们的准备工作就完成了,接着我们前面的分析。

绘制流程分析

_NativeParagraph代理的是engine../txt/paragraph.h这个类的接口,而从该类的注释可发现,此类也是一个接口:


// Interface for text layout engines.  The current implementation is based on
// Skia's SkShaper/SkParagraph text layout module.
class Paragraph {
    //...省略代码
}

注释中也提到了,实现是基于Skia's SkShaper/SkParagraph的模块,通过这条线索,我们在../skparagaraph/src/ParagraphImpl.cpp路径上找到了相关实现类,我们在它的绘制函数中增加输出日志以已确定判断是否正确:

提示: engine是一个极其庞大的工程,切勿在里面盲目瞎转,多看注释或者开断点调试。

void ParagraphImpl::paint(ParagraphPainter* painter, SkScalar x, SkScalar y) {
    //输出一个标记日志
    FML_LOG(ERROR) << "custom engine ::paint painter";
    for (auto& line : fLines) {
        line.paint(painter, x, y);
    }
}

engine重新编译,并运行demo 获得如下输出日志:

在这里插入图片描述

由此可以证明,我们找到的位置是正确的。绘制函数内的具体绘制则被拆分到TextLine.cpp中,

void ParagraphImpl::paint(ParagraphPainter* painter, SkScalar x, SkScalar y) {
    for (auto& line : fLines) {
        line.paint(painter, x, y);
    }
}

不过这个不是我们此文的目标,回到我们的问题调整下划线和文字的间距来。我们在同目录下可以找到Decorations.cpp文件,并定位到paint()函数,会发现其内部对AllTextDecorations进行了遍历,并绘制对应的装饰

void Decorations::paint(ParagraphPainter* painter, const TextStyle& textStyle, const TextLine::ClipContext& context, SkScalar baseline) {
    if (textStyle.getDecorationType() == TextDecoration::kNoDecoration) {
        return;
    }

    // Get thickness and position
    calculateThickness(textStyle, context.run->font().refTypeface());

    for (auto decoration : AllTextDecorations) {
        if ((textStyle.getDecorationType() & decoration) == 0) {
            continue;
        }

        calculatePosition(decoration,
                          decoration == TextDecoration::kOverline
                          ? context.run->correctAscent() - context.run->ascent()
                          : context.run->correctAscent());

        calculatePaint(textStyle);

        auto width = context.clip.width();
        SkScalar x = context.clip.left();
        SkScalar y = context.clip.top() + fPosition;

        bool drawGaps = textStyle.getDecorationMode() == TextDecorationMode::kGaps &&
                        textStyle.getDecorationType() == TextDecoration::kUnderline;
        //根据装饰类型,进行绘制
        switch (textStyle.getDecorationStyle()) {
          case TextDecorationStyle::kWavy: {
              calculateWaves(textStyle, context.clip);
              fPath.offset(x, y);
              painter->drawPath(fPath, fDecorStyle);
              break;
          }
          case TextDecorationStyle::kDouble: {
              SkScalar bottom = y + kDoubleDecorationSpacing;
              if (drawGaps) {
                  SkScalar left = x - context.fTextShift;
                  painter->translate(context.fTextShift, 0);
                  calculateGaps(context, SkRect::MakeXYWH(left, y, width, fThickness), baseline, fThickness);
                  painter->drawPath(fPath, fDecorStyle);
                  calculateGaps(context, SkRect::MakeXYWH(left, bottom, width, fThickness), baseline, fThickness);
                  painter->drawPath(fPath, fDecorStyle);
              } else {
                  draw_line_as_rect(painter, x,      y, width, fDecorStyle);
                  draw_line_as_rect(painter, x, bottom, width, fDecorStyle);
              }
              break;
          }
          case TextDecorationStyle::kDashed:
          case TextDecorationStyle::kDotted:
              if (drawGaps) {
                  SkScalar left = x - context.fTextShift;
                  painter->translate(context.fTextShift, 0);
                  calculateGaps(context, SkRect::MakeXYWH(left, y, width, fThickness), baseline, 0);
                  painter->drawPath(fPath, fDecorStyle);
              } else {
                  painter->drawLine(x, y, x + width, y, fDecorStyle);
              }
              break;
          case TextDecorationStyle::kSolid:
           
              if (drawGaps) {
                  SkScalar left = x - context.fTextShift;
                  painter->translate(context.fTextShift, 0);
                  calculateGaps(context, SkRect::MakeXYWH(left, y, width, fThickness), baseline, fThickness);
                  painter->drawPath(fPath, fDecorStyle);
              } else {
                  //这里是绘制进行下划线的绘制
                  draw_line_as_rect(painter, x, y, width, fDecorStyle);
              }
              break;
          default:break;
        }
    }
}

而我们要找的则是case TextDecorationStyle::kSolid枚举下的draw_line_as_rect函数,其通过x、y和width在文字下方绘制出一条横线,这里我们将y值+10.0看一下效果:

draw_line_as_rect(painter, x, y + 10.0, width, fDecorStyle);

运行结果如下图:

在这里插入图片描述

再贴一下原图:

在这里插入图片描述

可以看到,相较于原图,下划线与文字的间隙发生了预期的变化。

至此,本文的目标已经完成,谢谢大家的阅读。

参考文章

Setting-up-the-Engine-development-environment

Flutter Engine 源码调试

Compiling-the-engine.

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部