最新公告
  • 欢迎您光临起源地模板网,本站秉承服务宗旨 履行“站长”责任,销售只是起点 服务永无止境!立即加入钻石VIP
  • Flutter 疑难杂症系列:实现中文文本的垂直居中

    正文概述 掘金(字节终端技术)   2021-08-06   1365

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

    一、背景

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

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

    二、字体关键信息

    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>
    

    之后我们利用 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
    

    上述日志中有很多的条目,通过查阅 glyphsapp.com/learn/verti… 我们可以知道,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
    

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

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

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

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

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

    通过 fontEditor (github.com/ecomfe/font…) 我们可以得到如下图形:

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

    从上图可知,“中”字的上顶点坐标为 (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 的 includeFontPaddingfalse
    • 自定义 View 调用 Paint.getTextBounds() 方法获取 String 的 bounds

    3.1 includeFontPadding 实现文本居中

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

    我们可以在 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 属性设置为该字体,然后意想不到的事发生了。

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

    上图分别表示将 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
    

    从上述三个实验我们可知,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);
    
    }
    

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

    上述 代码是我们操作的逻辑,这里需要稍微说明下获取的 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 功能,详情见:github.com/flutter/eng…

    4.2 结果验证

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

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

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

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

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

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

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

    五、总结

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

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

    参考资料

    [1] Android font, 字体全攻略

    [2] Meaning of top, ascent, baseline, descent, bottom, and leading in Android's FontMetrics

    [3] 思源黑体

    [4] 字体排印学

    [5] Android 源码

    [6] glyphsapp.com/learn/verti…

    关于字节终端技术团队

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

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


    起源地下载网 » Flutter 疑难杂症系列:实现中文文本的垂直居中

    常见问题FAQ

    免费下载或者VIP会员专享资源能否直接商用?
    本站所有资源版权均属于原作者所有,这里所提供资源均只能用于参考学习用,请勿直接商用。若由于商用引起版权纠纷,一切责任均由使用者承担。更多说明请参考 VIP介绍。
    提示下载完但解压或打开不了?
    最常见的情况是下载不完整: 可对比下载完压缩包的与网盘上的容量,若小于网盘提示的容量则是这个原因。这是浏览器下载的bug,建议用百度网盘软件或迅雷下载。若排除这种情况,可在对应资源底部留言,或 联络我们.。
    找不到素材资源介绍文章里的示例图片?
    对于PPT,KEY,Mockups,APP,网页模版等类型的素材,文章内用于介绍的图片通常并不包含在对应可供下载素材包内。这些相关商业图片需另外购买,且本站不负责(也没有办法)找到出处。 同样地一些字体文件也是这种情况,但部分素材会在素材包内有一份字体下载链接清单。
    模板不会安装或需要功能定制以及二次开发?
    请QQ联系我们

    发表评论

    还没有评论,快来抢沙发吧!

    如需帝国cms功能定制以及二次开发请联系我们

    联系作者

    请选择支付方式

    ×
    迅虎支付宝
    迅虎微信
    支付宝当面付
    余额支付
    ×
    微信扫码支付 0 元