8

Flutter 疑难杂症系列:实现中文文本的垂直居中

 2 years ago
source link: https://my.oschina.net/u/4180867/blog/5310861
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

作者:字节跳动终端技术——林学彬

一、背景

​鉴于我们在业务开发中经常存在按钮场景,在 UI 表现上我们要求其中的描述文案能尽可能的垂直居中。但是在开发的过程中,我们经常遇到如下图所展示的文本垂直不居中的问题,需要额外的设置 Padding 属性。但是随着字号、手机屏幕密度等因素的变化,Padding 的值也需要随着进行调整,从而需要我们研发人员投入一定的精力去适配。

二、字体关键信息

2.1 字体关键信息

如果我们的 Flutter 应用不指定自定义字体的话,那么将会 Fallback 至系统默认的字体。那么系统默认是什么字体呢?以 Android 为例,在设备的 /system/etc/fonts.xml 文件中记录了相关的匹配规则,相对应的字体存储在 /system/fonts 中。我们平时应用中的中文文本根据以下规则,默认情况下会匹配为 NotoSansCJK-Regular (思源黑体) 字体。

<family lang="zh-Hans">
    <font weight="400" style="normal" index="2">NotoSansCJK-Regular.ttc</font>
    <font weight="400" style="normal" index="2" fallbackFor="serif">NotoSerifCJK-Regular.ttc</font>
</family>

注:我们可以创建一个 Android 模拟器,之后通过 adb 命令获取上述信息

之后我们利用 font-line 工具,获取字体的相关信息。

pip3 install font-line # install
font-line report ttf_path # get ttf font info

其中获取到的 NotoSansCJK-Regular 的关键信息如下:

[head] Units per Em:  1000
[head] yMax:      1808
[head] yMin:     -1048
[OS/2] CapHeight:   733
[OS/2] xHeight:    543
[OS/2] TypoAscender:  880
[OS/2] TypoDescender: -120
[OS/2] WinAscent:   1160
[OS/2] WinDescent:   320
[hhea] Ascent:     1160
[hhea] Descent:    -320

[hhea] LineGap:    0
[OS/2] TypoLineGap:  0

上述日志中有很多的条目,通过查阅 https://glyphsapp.com/learn/vertical-metrics 我们可以知道,Android 设备上采用 hhea ( horizontal typesetting header ) 所表示的信息,所以可以提取关键信息为:

[head] Units per Em:  1000
[head] yMax:      1808
[head] yMin:     -1048
[hhea] Ascent:     1160
[hhea] Descent:    -320
[hhea] LineGap:    0

是不是还是比较迷茫?没事,通过阅读下图就可以比较清晰的了解了。

上图中,最关键的为 3 条线,分别是 baselineAscent 及 Descentbaseline 可以理解为我们水平线,一般情况下 Ascent 及 Descent 分别表示字形绘制区域的上下限。在 NotoSansCJK-Regular 的信息中,我们看到了 yMax 和 yMin 分别对应图中的 Top 及 Bottom,分别表示在本字体所包含的所有字形中,在 y 轴的上限及下限。此外,我们还看到了 LineGap 参数,该参数对应图中的 Leading,用于控制行间距的大小。

此外,我们还未提及一个重要的参数 Units per Em 有些时候我们简称 Em, 该参数用于归一化字体的相关信息。

比如,在 Flutter 中 我们将字体的 fontSize 设置了 10,此外设备的 density 为 3,那么字体到底多高呢 ?

通过 fontEditor 我们可以得到如下图形:

从上图可知,“中”字的上顶点坐标为 (459, 837), 下顶点坐标为 (459, -76),因而 “中”字的高度为 (837 + 76) = 913, 从上述 NotoSans 字体信息可知, Em值 为 1000,所以每个单位的“中”字高度为 0.913,ascent 及 descent 为 上述所描述的 1160 及 -320。

这里再次解释下,如果我们在屏幕密度为 3 的设备上,使用 NotoSans 字体,如果设置 “中” 的 fontSize 为 10,那么

  • “中”字形高度为:10 * 3 * 0.913 = 27.39 ~= 27
  • 文本边框高度为:10 * 3 * (1160 + 320) / 1000= 44

即 当 fontSize 设置为 30 像素时,“中” 字高度为 27 像素,文本框高度为 44 像素。

2.2 为什么不能垂直居中

由上节可知,LineGap 为 0 也即 Leading 为 0,那么在 Flutter 中文本在在垂直方向上的布局仅仅和 ascent 及 descent 有关即:

height = (accent - descent) / em * fontSize

通过由2.1节的“中”子图可知:

  • “中”字字形的中心在 (837 + -76) / 2 = 380 处
  • “中”字的 ascent 及 descent 的中心为 (1160 + -320) / 2 = 420 处

如果fontSize 为 10 ,在 density 为 3 的设备上,10 * 3 * (420 - 380) / 1000= 1.2 ~= 1,中心点已经出现了 1 像素的偏差,随着字号越大,偏差就会越大,因而如果直接使用 NotoSans 的信息进行垂直方向的布局是不可能实现文本的垂直居中的。

那么除了使用 Padding 方式外,还有什么其他方法吗?或者我们换个角度,因为 Flutter 很多设计原理和 Android 极其类似,所有我们先参考下 Android 目前的实现方式。

三、Android 原生如何实现文本垂直居中

目前在 Android 中除了使用 Padding,我们目前可行是的两个方案:

  • 设置 TextView 的 includeFontPadding 为 false
  • 自定义 View 调用 Paint.getTextBounds() 方法获取 String 的 bounds

3.1 includeFontPadding 实现文本居中

在 Android 中,TextView 默认情况下是采用 yMax 及 yMin 作为文本框的上边缘及边缘,若将 TextView 的 includeFontPadding 设置为 false 之后,才使用 Ascent 及 Descent 的上下边缘。

我们可以在 android/text/BoringLayout.java 的 init 方法里,找到该逻辑。

void init(CharSequence source, TextPaint paint, Alignment align,
        BoringLayout.Metrics metrics, boolean includePad, boolean trustWidth) {
    // ...
    // 既 若 includePad 为 true  则以 bottom 及 top 为准
    //    若 includePad 为 false 则以 ascent 及 descent 为准
    if (includePad) {
        spacing = metrics.bottom - metrics.top;
        mDesc = metrics.bottom;
    } else {
        spacing = metrics.descent - metrics.ascent;
        mDesc = metrics.descent;
    }
    // ...
 }

为了进一步验证,我们将系统的 NotoSansCJK-Regular 导出,并放入 Android 工程中,之后将 TextView 的 android:fontFamily 属性设置为该字体,然后意想不到的事发生了。

上图分别表示将 TextView 的 includeFontPadding 属性设置为 false 之后,其中的文本匹配系统默认 NotoSansCJK-Regular 字体 (左图)和使用通过 android:fontFamily 指定的 NotoSansCJK-Regular 字体(右图)的区别。如果采用通一个字体的情况下,两者理论上应该完全一致,但是现在的结果并不相同。

通过断点调试我们在 android/graphics/Paint.java 找到了 getFontMetricsInt 方法,可以获取中包含字体信息的 Metrics:

public int getFontMetricsInt(FontMetricsInt fmi) {
    return nGetFontMetricsInt(mNativePaint, fmi);
}

实验一、在默认情况下,我们获取了如下信息:

FontMetricsInt: top=-111 ascent=-97 descent=26 bottom=29 leading=0 width=0

实验二、在设置 android:fontFamliy 为 NotoSans 之后,我们得到如下结果:

FontMetricsInt: top=-190 ascent=-122 descent=30 bottom=111 leading=0 width=0

实验三、在设置 android:fontFamliy 为 Roboto 之后,我们得到如下结果:

FontMetricsInt: top=-111 ascent=-97 descent=26 bottom=29 leading=0 width=0

注1:上述数据是在 Pixel 模拟器中,字体设置为 40dp, dpi 为 420注2:Roboto 为数字英文所匹配的字体

从上述三个实验我们可知,TextView 在默认情况下采用了 Roboto 信息作为其布局信息,而中文最终匹配了 NotoSans 字体,这种情况下恰巧使得文本居中了,因而这不是我们所追求的方案。\

3.2 Paint.getTextBounds() 实现文本居中

@Override
protected void onDraw(Canvas canvas) {
    super.onDraw(canvas);
    Paint paint = new Paint();
    paint.setColor(0xFF03DAC5);
    Rect r = new Rect();
    // 设置字体大小
    paint.setTextSize(dip2px(getContext(), fontSize));
    // 获取字体bounds
    paint.getTextBounds(str, 0, str.length(), r);
    float offsetTop = -r.top;
    float offsetLeft = -r.left;
    r.offset(-r.left, -r.top);
    paint.setAntiAlias(true);
    canvas.drawRect(r, paint);
    paint.setColor(0xFF000000);
    canvas.drawText(str, offsetLeft, offsetTop, paint);
}

上述 代码是我们操作的逻辑,这里需要稍微说明下获取的 Rect 的值。其中屏幕坐标是以左上角为原点,向下为 Y 轴的正方向。字体绘制以 baseline 为基准,相对整个 Rect 来说,baseline 为其自身的 Y 轴的原点,那么 baseline 之上的 top 就是负的,bottom 在 baseline 之下就是正的。

上述自定义 View 的核心便是 getTextBounds 函数,只要我们能解读里面的信息,就能破解该方案。好在 Android 是开源的,我们在 frameworks/base/core/jni/android/graphics/Paint.cpp 中找到了如下实现:

static void getStringBounds(JNIEnv* env, jobject, jlong paintHandle, jstring text, jint start,
        jint end, jint bidiFlags, jobject bounds) {
    // 省略若干代码 ...
    doTextBounds(env, textArray + start, end - start, bounds, *paint, typeface, bidiFlags);
    env->ReleaseStringChars(text, textArray);
}

static void doTextBounds(JNIEnv* env, const jchar* text, int count, jobject bounds,
        const Paint& paint, const Typeface* typeface, jint bidiFlags) {

    // 省略若干代码 ...
    minikin::Layout layout = MinikinUtils::doLayout(&paint,
            static_cast<minikin::Bidi>(bidiFlags), typeface,
            text, count,  // text buffer
            0, count,  // draw range
            0, count,  // context range
            nullptr);
    minikin::MinikinRect rect;
    layout.getBounds(&rect);
    // 省略若干代码 ...
}

接下来我们看下 frameworks/base/libs/hwui/hwui/MinikinUtils.cpp:

minikin::Layout MinikinUtils::doLayout(const Paint* paint, minikin::Bidi bidiFlags,
                                    const Typeface* typeface, const uint16_t* buf,
                                    size_t bufSize, size_t start, size_t count,
                                    size_t contextStart, size_t contextCount,
                                    minikin::MeasuredText* mt) {
    minikin::MinikinPaint minikinPaint = prepareMinikinPaint(paint, typeface);
    // 省略若干代码 ... 
    return minikin::Layout(textBuf.substr(contextRange), range - contextStart, bidiFlags,
}

综上,其实核心是通过调用了 minikin 的 Layout 接口获取了 Bounds,而 Flutter 相关的逻辑和 Android 具有极大的相似性,所以该方案是可以适用于 Flutter 的。

四、在 Flutter 中实现文本居中

4.1 相关原理及修改说明

由 3.2 小节可知,如果要在 flutter 中按照 Android 的 getTextBounds 的思路实现文本居中,核心是要调用 minikin:Layout 的方法。我们在 flutter 的现有布局逻辑中找到如下调用链路:

ParagraphTxt::Layout()
    -> Layout::doLayout()
        -> Layout::doLayoutRunCached()
            -> Layout::doLayoutWord()
                ->LayoutCacheKey::doLayout()
                    -> Layout::doLayoutRun()
                        -> MinikinFont::GetBounds()
                            -> FontSkia::GetBounds()
                                -> SkFont::getWidths()
                                    -> SkFont::getWidthsBounds()

其中 SkFont::getWidthsBounds 如下:

void SkFont::getWidthsBounds(const SkGlyphID glyphIDs[],
                             int count,
                             SkScalar widths[],
                             SkRect bounds[],
                             const SkPaint* paint) const {
    SkStrikeSpec strikeSpec = SkStrikeSpec::MakeCanonicalized(*this, paint);
    SkBulkGlyphMetrics metrics{strikeSpec};
    // 获取相应的字形
    SkSpan<const SkGlyph*> glyphs = metrics.glyphs(SkMakeSpan(glyphIDs, count));
    SkScalar scale = strikeSpec.strikeToSourceRatio();

    if (bounds) {
        SkMatrix scaleMat = SkMatrix::Scale(scale, scale);
        SkRect* cursor = bounds;
        for (auto glyph : glyphs) {
            // 注意 glyph->rect() 里面的值都是 int 类型
            scaleMat.mapRectScaleTranslate(cursor++, glyph->rect());
        }
    }

    if (widths) {
        SkScalar* cursor = widths;
        for (auto glyph : glyphs) {
            *cursor++ = glyph->advanceX() * scale;
        }
    }
}

因而按照 getTextBounds 的思路,并不会增加额外的布局消耗,我们只要将上述链路中存储的数据通过

Layout::getBounds(MinikinRect* bounds) 函数调用获取并可以。

在实现的过程中遇到以下几个注意的点:

  • Flutter 测绘的时候,使用的 Size 真是 Dart 层所设置的 fontSize,相比 Android 的 fontSize x density,所以会造成精度的丢失,造成 1 ~ density 像素的偏差 —— 因而需要做相应的放大处理
  • 在 ParagraphTxt::Layout 中,对 height 计算为 round(max_accent + max_descent),会存在精度丢失
  • 在 ParagraphTxt::Layout 中,对 y_offset 也即绘制的时候 baseline 的 y 轴位置,也存在精度丢失的问题
  • Paragraph 在 Dart 层获取 height 接口,调用了 _applyFloatingPointHack 即 value.ceilToDouble(), 如 0.0001 -> 1.0 在底层精度适配过程中需要额外主要

我们也向官方提了相应的 PR 实现了 forceVerticalCenter 功能,详情见:https://github.com/flutter/engine/pull/27278

4.2 结果验证

和官方 PR 的区别是内部版本我们而外提供了 drawMinHeight 参数,因为要实现这部分功能修改量比较大所在暂不准备向官方提 PR。

在 Text 中,我们添加了两个参数:

  • drawMinHeight:绘制最小的高度
  • forceVerticalCenter:保持现有其他相关逻辑不变的情况下,强制将文本在该行中垂直居中

图 4-1 Android 端 FontSize 从 8 至 26 的正常模式(左)和 drawMinHeight (右) 的对比图

图 4-2 Android 端 FontSize 从 8 至 26 的正常模式(左)和 forceVerticalCenter (右) 的对比

五、总结

本文通过对字体的关键信息的解读,使得读者对字体在垂直方向上的布局有一个大概的印象。再以“中”字为例分析了 NotoSans 的信息,指出了不能居中的根源问题。然后探索了 Android 原生的两个方案,分析了其中的原理。最后基于 Android 的 getTextBounds 方案的原理,在 Flutter 上实现了 forceVerticalCenter 功能。

Flutter目前还在快速成长中,或多或少存在一些体验的疑难问题,字节跳动Flutter Infra团队正在致力于解决这些疑难杂症,本文主要解决了Flutter的文本居中对齐的问题,后续会有Flutter疑难杂症治理系列文章输出,敬请关注。

<!---->

关于字节终端技术团队

字节跳动终端技术团队(Client Infrastructure)是大前端基础技术的全球化研发团队(分别在北京、上海、杭州、深圳、广州、新加坡和美国山景城设有研发团队),负责整个字节跳动的大前端基础设施建设,提升公司全产品线的性能、稳定性和工程效率;支持的产品包括但不限于抖音、今日头条、西瓜视频、飞书、懂车帝等,在移动端、Web、Desktop等各终端都有深入研究。

就是现在!客户端/前端/服务端/端智能算法/测试开发 面向全球范围招聘!一起来用技术改变世界,感兴趣可以联系邮箱 [[email protected]],邮件主题 简历-姓名-求职意向-期望城市-电话

火山引擎应用开发套件MARS是字节跳动终端技术团队过去九年在抖音、今日头条、西瓜视频、飞书、懂车帝等 App 的研发实践成果,面向移动研发、前端开发、QA、 运维、产品经理、项目经理以及运营角色,提供一站式整体研发解决方案,助力企业研发模式升级,降低企业研发综合成本。可点击链接进入官网查看更多产品信息。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK