前言
最近工作中处理的文本相关的内容较多,不论是刁钻的需求还是复杂的问题,最终都会引向一点“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 {///...省略代码@overrideRenderParagraph 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;}
通过对RichText
、TextSpan
以及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
,而该类则代理的是engine
层Paragraph
的接口。
abstract class Paragraph {///...}base class _NativeParagraph extends NativeFieldWrapperClass1 implements Paragraph {//此类由engine创建,并关联对应接口,如 layout函数@overridevoid 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.
有了源码后,通过gn
和ninja
,你就可以进行编译了,例如我要编译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 positioncalculateThickness(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.