ReactNative文本截断问题解决与Android文本测绘原理分析
小编导读
本文主要针对ReactNative中由来已久的文本截断问题,进行了深入分析与解决。同时,对Android系统文本测绘的整体架构和流程进行了较为全面而简要的介绍。希望对受文本截断问题困扰和对文本测绘原理感兴趣的读者有所帮助。
文 / 编辑 李志强
From 快手主站跨端动态化团队
本文共8000+字,预计阅读时间30+分钟。首次阅读建议不用关注细节,只关注结论相关部分即可。
# 背景介绍
ReactNative文本截断问题背景
#
文本测绘原理
整体架构
关键类分析
测绘流程
本章小结
#
文本截断问题分析与解决
fontFamily为null导致的文本截断问题
设置系统字体缩放导致的文本截断问题
文本截断问题线上监控方案
#
总结
全文总结
背景介绍
目前React Native社区存在大量文本截断的反馈且至今没有得到很好的解决,而这些反馈由于和机型以及ROM版本存在强相关,且不同case之间具体场景不尽相同,相关issue并未得到有效的统一处理。本文将针对这一棘手问题在Native侧进行分析与解决。
文本测绘,是一项历史久远、繁杂而且系统的技术,它在各种渲染系统中也是最基本、最重要的组成部分。因此,在正式分析ReactNative中文本截断问题之前,我们有必要了解一下文本测绘的底层设计与原理。
对ReactNative文本截断问题情况有兴趣的读者可以查阅相关issues:
https://github.com/facebook/react-native/issues?q=is%3Aissue+is%3Aopen+text+cut。
文本测绘原理
文本测绘,听起来大家可能都很陌生,但是其实这是一项起源很久远的技术。比如,字型 (Font)这个术语最早就是来源于印刷行业的铸造铅字模块,当时的字体排版师傅必须用这些字块在印刷机上进行排版。了解文本测绘相关知识,将有助于解决文本渲染中的疑难问题,实现复杂的文本渲染需求。
想要讲清楚文本测绘原理不是一件容易的事情,目前公开的资料中,基本上都是针对其中某个环节或者某个部分进行介绍,而没有相对系统的学习资料。我们在这里尝试弥补这一空白,希望帮助读者朋友对文本测绘原理整体面貌有一个大概的掌握,进而能够在解决具体的文本相关的需求和问题时,能够起到一定的指引作用。
当然,我们也参考了网上很多不错的文章,在文末参考资料部分将会一一列出。整体架构
注:下图由于平台大小限制,省略了很多细节,需要高清大图的读者可以私信留言。
文本测绘的整体架构图如下所示,首次阅读可以不用关注细节,只需要大概知道有哪些类就可以了,特别是其中颜色加深的部分。我们接下来会就其中关键的类以及它们自己相互的关系进行具体的介绍。
关键类分析
1. Spanned
"Span",中文意为“跨度”,可以理解为一段连续的区域或者片段。在文本测量中,我们认为Span表示文本串中一段连续的区间。
Spanned接口是Span在Android系统中的相关操作的抽象,换句话说,Spanned接口定义了一些方法用来操作特定的Span对象。以getSpans为例,其定义如下:
public <T> T[] getSpans(int start, int end, Class<T> type);
它代表的操作为:获取区间[start, end]中类型为type的所有Span。那么,在Android系统中Span具体是用什么来表示的呢?其实,Android系统中并没有特定的一个类或者接口来表示Span,而是由CharacterStyle抽象类和ParagraphStyle接口共同表示这一抽象概念,它们分别从字符级和段落级两个不同的粒度来影响文本的样式和布局。UpdateAppearance:是一个影响字符级的文本展示的Span的顶级接口,并且对应的Span在被添加或者移除的时候会改变文本的展示。 UpdateLayout:是一个影响字符级文本格式的Span的顶级接口,并且对应的Span会影响文本的布局。由于布局更新也会明显地影响文本展示,因此,该接口也是一个UpdateAppearance接口。
UpdateLayout有一个顶级实现类MetricAffectingSpan,而MetricAffectingSpan同时也是一个抽象类,并提供了一个抽象方法updateMeasureState()用来更新文本的宽、高等样式。
CharacterStyle从字符级设置文本相关布局和样式,其相关的类层级结构如下图所示:
其中,进行字符替换的Span都直接继承了ReplacementSpan,例如ImageSpan;会影响到文本测量布局的Span都直接继承了MetricAffectingSpan,例如AbsoluteSizeSpan、ScaleXSpan等;而那些只会影响文本字符的显示,不会影响测量布局的Span都只继承了CharacterStyle而非MetricAffectingSpan,例如BackgroundColorSpan、URLSpan等。
无论是MetricAffectingSpan还是CharacterStyle,从其抽象方法参数类型可以看出,它们设置Span相关信息的方式,都是通过设置TextPaint来实现的,具体的逻辑和过程,在介绍TextLine的时候我们会详细介绍。
ParagraphStyle则是从段落级设置文本相关的布局和样式,其相关的类层级结构如下图所示:
ParagraphStyle也只是一个标记性接口,不包含任何属性和方法,其子类则主要是影响了行高、段落边距、对齐方式、制表符、项目编号样式等等文本段落样式相关的属性。
2. TextPaint & FontMetrics
TextPaint继承自Paint类,其绝大部分功能都由父类Paint实现。Paint是一个和C++底层接口交互的Java类,它保存着测绘图形、文本和图片必要的信息。其类结构如下图所示。
public static class FontMetrics {
public float top;
public float ascent;
public float descent;
public float bottom;
public float leading;
}
ascent:baseline之上到字符最高处的距离,符号为负;
descent:baseline之下到字符最低处的距离,符号为正;
top:给定字体大小的字体中字形最高处到baseline的值,即ascent绝对值的最大值,符号为负;
bottom:给定字体大小的字体中字形最低处到baseline的值,即descent的最大值,符号为正;
leading:两行文本之间的行距,即当前行字符descent到下一行的ascent之间的距离,符号非负。
FontMetricsInt是FontMetrics的整数版本,而BoringLayout.FontMetrics是其子类,并且增加了一个width字段,用来表示字符串的测量宽度,而这个宽度值最终将会影响TextView组件的宽高布局。
3. Layout
如果说Spanned相关对象是文本测绘过程中最底层的数据抽象,那么Layout则是该过程中最上层的逻辑抽象。Layout它定义了文本测绘过程中需要的所有顶层API,并在系统的Layout&Measure&Draw过程中由TextView组件触发调用,最终影响TextView组件的样式和布局。
从上面Layout的类结构图中可以看到,Layout作为一个文本测绘相关顶级抽象类,定义了基本的数据依赖和接口,而和每一行文本具体布局相关的接口均由子类来实现。
Layout包含了一个mSpannedText成员,用来表示所有处理的mText是否是Spanned的对象,而Layout默认只处理ParagraphStyle类型的Span对象。比如,在其draw()方法中会调用两个方法:drawBackground()和drawText(),其中
drawBackground()
drawText()
会对每一行文本进行三项处理。首先,如果存在LeadingMarginSpan或者LeadingMarginSpan2对象,则会调用LeadingMarginSpan#drawLeadingMargin()方法绘制对应Span前置段落边距。其次,根据AlignmentSpan计算文本对齐方式、调整缩进宽度等。最后,如果当前文本行是Spanned对象,则会调用TextLine#draw()方法进行绘制。普通文本则直接通过Canvas#drawText()绘制。
BoringLayout:用来处理只有一行,且文字方向为LTR(LeftToRight)的文本。
StaticLayout:用来处理不能编辑的文本。它会根据Spanned和指定的宽度计算文本行数,而文字的方向则由文本内容决定。
DynamicLayout:用来处理可编辑的文本,它除了具有StaticLayout的功能外,还支持在编辑的同时动态更新文本的布局。在实现上,DynamicLayout有一个ChangeWatcher对象mWatcher,这个对象观察者Spannable的变化。当Spannable更新的时候,Layout#getLines()也会随之变化。
4. TextLine
TextLine对象负责处理每一行带有特定样式的文本,是每一行文本测绘任务的具体执行者,在文本测绘中发挥着至关重要的作用,其类结构如下图所示。
public abstract void updateDrawState(TextPaint tp);
updateDrawState()方法返回void,而且参数也只有一个TextPaint对象,因此,每一个Span所对应的字符的测绘需要严重依赖TextPaint对象。这一点,从TextLine的类结构图中也不难看出。TextLine中定义了三个TextPaint对象:mPaint、mWorkPaint和mActivePaint。它们三个均在文本的测绘中使用,其中
mPaint:在set()初始化TextLine传入的TextPaint对象,保留着当前TextLine正在处理的文本的初始TextPaint状态;
mWorkPaint:在文本测量和绘制过程中,负责收集所有当前生效的CharacterStyle对象的处理逻辑(作为参数调用updateDrawState()方法),初始状态由mPaint决定;
mActivePaint:最终真正参与测量和绘制的TextPaint对象,默认由mPaint初始化其内部状态,如果mWorkPaint有更新的话,则由mWorkPaint更新mActivePaint的内部状态。
以上三个TextPaint对象主要在TextLine#handleRun()中使用,mPaint将会在TextLine#recycle()回收时释放,mWorkPaint和mActivePaint则不会释放,只在使用时由mPaint重新初始化内部状态。
private float handleRun(int start, int measureLimit,
int limit, boolean runIsRtl, Canvas c, float x,
int top, int y, int bottom, FontMetricsInt fmi,
boolean needWidth) {
// 1. 输入校验以及空行处理
...
// 2. 根据是否为Spanned对象,判断是否需要进行Span相关的逻辑处理
final boolean needsSpanMeasurement;
if (mSpanned == null) {
needsSpanMeasurement = false;
} else {
// 2.1 获取Spanned对象中的MetricAffectingSpan和CharacterStyle集合。
// SpanSet#init()中将会把Span对象、Span开始位置、
// Span结束位置以及相关的flag变量分别保存在spans、spanStarts、spanEnds、spanFlags数组中
...
needsSpanMeasurement = mMetricAffectingSpanSpanSet.numberOfSpans != 0
|| mCharacterStyleSpanSet.numberOfSpans != 0;
}
// 3. 如果不需要进行Span相关的逻辑操作,直接更新mWorkPaint,然后调用handleText()进行测绘
if (!needsSpanMeasurement) {
final TextPaint wp = mWorkPaint;
wp.set(mPaint);
...
return handleText(wp, ...);
}
// 4. 接下来进行Span对象的测绘。由于布局只需要考虑MetricAffectingSpan,
// 而渲染需要考虑字符样式,因此,这里在每一个文本串遍历过程中先遍历布局相关Span,
// 然后再遍历字符样式相关Span。
final float originalX = x;
for (int i = start, inext; i < measureLimit; i = inext) {
// 4.1 使用mPaint初始化work paint内部状态
final TextPaint wp = mWorkPaint;
wp.set(mPaint);
...
ReplacementSpan replacement = null;
// 4.2 遍历并应用布局相关Span,同时更新replacement
for (int j = 0; j < mMetricAffectingSpanSpanSet.numberOfSpans; j++) {
...
}
// 4.3 遍历完所有布局相关的span后,如果replacement不为null,
// 则使用当前work paint执行相关的字符替换逻辑,
// 并跳过当前文本串的后续处理(即CharacterStyle相关的逻辑)
if (replacement != null) {
x += handleReplacement(replacement, wp, ...);
continue;
}
// 以下为CharacterStyle相关的处理逻辑。
// 4.4 使用mPaint初始化active paint内部状态
final TextPaint activePaint = mActivePaint;
activePaint.set(mPaint);
...
// 4.5 遍历当前文本串,处理其中字符样式相关的span
for (int j = i, jnext; j < mlimit; j = jnext) {
...
// 4.5.1 应用CharacterStyle相关span之前,使用mPaint重新初始化work paint
wp.set(mPaint);
// 4.5.2 遍历每一个字符相关的span并应用到work paint
...
// 4.5.3 使用当前最新的work paint更新active paint内部状态,
// 最终将会在handleText中应用到具体的测绘中
...
// 4.5.4 使用当前active paint测绘文本
x += handleText(activePaint, ...);
}
...
// 4.6 计算并更新当前文本串的宽度
x += handleText(activePaint, ...);
}
// 5. 返回测量宽度。因为只有needWidth为true时x才是正确的结果,因此只有needWidth为true该返回值才有意义。
return x - originalX;
}
5. 相互关系
根据上文的介绍,我们可以大致总结一下文本测绘的关键类之间的关系。
首先,TextPaint是文本测绘能力实现者,其通过与底层C++相关接口交互,将文本中的字体、大小、粗细、颜色、缩放等各种相关信息传递到C++,实现文本字符的测绘。而Span则可以通过影响TextPaint相关设置分别在字符级和段落级影响文本的测绘,Spanned则为这其中的操作提供了一系列的抽象接口。
Layout作为最上层的接口,持有TextPaint作为通用的文本测绘信息,并持有Span相关对象作为更细粒度的信息控制文本测绘过程,最终完成整个文本的测绘。而TextLine作为每一行文本测绘任务的执行者,具体控制着Span与TextPaint之间交互的逻辑,并最终依赖TextPaint相关接口完成文本的测绘。
测绘流程
注:下图由于平台大小限制,省略了很多过程和细节,需要高清大图的读者可以私信留言。
本章小结
本章节相对系统而简要地介绍了Android系统文本测绘的原理,分别介绍了文本测绘的整体架构,以及其中涉及到的比较关键的对象如Spanned、Paint、Layout、TextLine等及其相互依赖关系,同时,重点分析了Span对象在TextLine#handleRun()中参与测绘过程的源码。最后,从TextView组件的角度,梳理了文本组件总体的测量流程,给出了以上各个关键对象在文本测量过程中所参与的具体环节。
由于文本测绘是一个非常复杂的过程,本章节所介绍的内容远远不足以覆盖全面,只希望读者可以在读完本章节后对文本测绘有一个大概的了解,在解决具体问题时能够有所帮助即可。
文本截断问题分析与解决
ReactNative文本截断问题,目前社区流传较为广泛的解决方案,均是从前端侧进行规避,包括添加空格、显示设置fontFamily、设置行高、设置breakStrategy为simple等等。社区的这些方案可以解决部分线下的文本截断case,但是,线上环境较为复杂,这些规避方案由于并没有从Native侧分析到文本截断的根本原因,因此并不能较为全面地解决线上用户侧的文本截断问题。
在分析ReactNative文本截断问题之前,我们先看一下ReactNative中和布局相关的Span,之后会结合两个实际案例进行分析。
ReactNative中Span全部实现了一个标记性接口ReactSpan,即上述类结构图中蓝色Span,其中ReactBackgroundColorSpan、CustomLetterSpacingSpan、ReactAbsoluteSizeSpan和CustomHeightSpan实现比较简单,只是将设置的相应参数应用到文本的测绘流程中。而CustomStyleSpan相对较为复杂,它根据业务设置的fontFamily、fontStyle、fontWeight等字体相关信息,创建相应的Typeface实例,然后应用到文本的测绘过程中,其中关键的主体逻辑如下:
// From CharacterStyle
@Override
public void updateDrawState(TextPaint ds) {
apply(ds, mStyle, mWeight, mFeatureSettings, mFontFamily, mAssetManager);
}
// From MetricAffectingSpan
@Override
public void updateMeasureState(TextPaint paint) {
apply(paint, mStyle, mWeight, mFeatureSettings, mFontFamily, mAssetManager);
}
private static void apply(Paint paint, int style,
int weight, @Nullable String fontFeatureSettings,
@Nullable String family, AssetManager assetManager) {
Typeface typeface = ReactTypefaceUtils.applyStyles(
paint.getTypeface(),
style, weight, family,
assetManager);
paint.setFontFeatureSettings(fontFeatureSettings);
paint.setTypeface(typeface);
paint.setSubpixelText(true);
}
在TextLine#handleRun()中应用CustomStyleSpan的时候,最终会调用到上述apply()方法。
apply()中首先根据当前TextPaint#getTypeface()返回值和业务设置的fontFamily等字体信息获取Typeface实例,然后将应用了这些字体信息之后得到的Typeface实例更新TextPaint中,从而影响文本的测量和绘制。
fontFamily为null导致的文本截断问题
由fontFamily为null(ReactNative中Text组件的缺省值)导致的文本截断问题在小米机型的MIUI12上复现概率较高,比如运行 https://snack.expo.dev/S6WUZyK3M 中的示例代码,文本字符“Hello React Native App”只显示“Hello React Native”。
设置系统字体缩放导致的文本截断问题
文本截断问题线上监控方案
针对单行文本,ReactNative测量得到一个Layout实例BoringLayout对象,并以其宽度作为布局宽度; 而TextView的onMeasure阶段传入的wantWidth小于新测量得到的boring.width,导致TextView认为设置的宽度不足以在一行内展示所有文本内容,进而使用StaticLayout对象进行多行渲染; 高度不变的情况下,将单行文本渲染成两行后,末尾部分字符无法显示。
1. 监控特征分析
TextView#mBoring
文本截断时,TextView#onMeasure()之后,mBoring不为null。 文本截断时,mText中不包含特殊字符 \t 和 \n 。 文本截断时,其width字段大于wantWidth。
该成员没有暴露getter接口且使用了“非公开API限制”,子类中访问需要使用元反射技术。
TextView#mLayout
TextView中Layout类型私有变量。Layout是一个抽象类,是BoringLayout、StaticLayout和DynamicLayout的父类。TextView#mLayout具有如下特点:
文本截断时,该变量实例为StaticLayout。 文本截断时,mLayout#getLineCount()大于1。 文本截断时,ReactNative的Yoga测量使用的Layout实例类型为BoringLayout。
2. 监控方案实现
TextView.mBoring.width > Yoga.mBoring.width TextView.mLayout.getLineCount() > Yoga.mLayout.getLineCount()
即,TextView中测量的文本字符宽度大于Yoga中测量的宽度,文本行数大于Yoga中测量得到的文本行数。
总结
想要熟练掌握文本测绘的相关知识,则需要我们在实践中不断总结与复盘,从而得到进一步的提高。
参考资料
Spans, a Powerful Concept. - Flavien Laurent
Android Span 原理解析-六虎
Bidirectional text - Wikipedia
Android之TextView文字绘制流程 - bvin - 博客园
Android字符串进阶之三:字体属性及测量(FontMetrics)_51CTO博客_android 字符串比较
Android字体系列 (一):Android字体基础 - 掘金
Android字体系列 (二):Typeface完全解析 - 掘金
TextView绘制流程二、BoringLayout深入理解 - 掘金
Draw Text in Deep - 掘金
React Native小米手机UI适配问题解决 - 简书
可变字体带来的文字设计变革 | Monotype.
字由-Typeface和Font 傻傻分不清?| 字说字话
”
欢迎加入
快手主站技术部客户端团队由业界资深的移动端技术专家组成,通过领先的移动技术深耕工程架构、研发工具、动态化、数据治理等多个垂直领域,积极探索创新技术,为亿万用户打造极致体验。团队自2011年成立以来全面赋能快手生态,已经建立起业内领先的大前端技术体系,支撑快手在国内外的亿万用户。
目前团队正在招贤纳士,欢迎对大前端技术感兴趣的同学投递简历~
我们期待你的加入!请发简历到:
chenguang03@kuaishou.com
或
lizhiqiang05@kuaishou.com
”