Flutter 疑難雜症系列:實現中文文本的垂直居中

做者:字節跳動終端技術 —— 林學彬前端

1、背景

鑑於咱們在業務開發中常常存在按鈕場景,在 UI 表現上咱們要求其中的描述文案能儘量的垂直居中。可是在開發的過程當中,咱們常常遇到以下圖所展現的文本垂直不居中的問題,須要額外的設置 Padding 屬性。可是隨着字號、手機屏幕密度等因素的變化,Padding 的值也須要隨着進行調整,從而須要咱們研發人員投入必定的精力去適配。java

img

2、字體關鍵信息

2.1 字體關鍵信息

若是咱們的 Flutter 應用不指定自定義字體的話,那麼將會 Fallback 至系統默認的字體。那麼系統默認是什麼字體呢?android

以 Android 爲例,在設備的 /system/etc/fonts.xml 文件中記錄了相關的匹配規則,相對應的字體存儲在 /system/fonts 中。c++

咱們平時應用中的中文文本根據如下規則,默認狀況下會匹配爲 NotoSansCJK-Regular (思源黑體) 字體。git

<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 命令獲取上述信息github

以後咱們利用 font-line 工具,獲取字體的相關信息。算法

pip3 install font-line # install
font-line report ttf_path # get ttf font info
複製代碼

其中獲取到的 NotoSansCJK-Regular 的關鍵信息以下:canvas

[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 ) 所表示的信息,因此能夠提取關鍵信息爲markdown

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

是否是仍是比較迷茫?沒事,經過閱讀下圖就能夠比較清晰的瞭解了。app

img

上圖中,最關鍵的爲 3 條線,分別是 baselineAscentDescentbaseline 能夠理解爲咱們水平線,通常狀況下 AscentDescent 分別表示字形繪製區域的上下限。在 NotoSansCJK-Regular 的信息中,咱們看到了 yMaxyMin 分別對應圖中的 TopBottom,分別表示在本字體所包含的全部字形中,在 y 軸的上限及下限。此外,咱們還看到了 LineGap 參數,該參數對應圖中的 Leading,用於控制行間距的大小。

此外,咱們還未說起一個重要的參數 Units per Em 有些時候咱們簡稱 Em, 該參數用於歸一化字體的相關信息。

好比,在 Flutter 中 咱們將字體的 fontSize 設置了 10,此外設備的 density 爲 3,那麼字體到底多高呢 ?

經過 fontEditor (github.com/ecomfe/font…) 咱們能夠獲得以下圖形:

img

從上圖可知,「中」字的上頂點座標爲 (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 目前的實現方式。

3、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 屬性設置爲該字體,而後意想不到的事發生了。

img

上圖分別表示將 TextView 的 includeFontPadding 屬性設置爲 false 以後,其中的文本匹配系統默認 NotoSansCJK-Regular 字體 (左圖)和使用經過 android:fontFamily 指定的 NotoSansCJK-Regular 字體(右圖)的區別。若是採用通一個字體的狀況下,二者理論上應該徹底一致,可是如今的結果並不相同。

經過斷點調試咱們在 android/graphics/Paint.java 找到了 getFontMetricsInt 方法,能夠獲取中包含字體信息的 Metrics:

public int getFontMetricsInt(FontMetricsInt fmi) {
    return nGetFontMetricsInt(mNativePaint, fmi);
}
複製代碼

實驗1、在默認狀況下,咱們獲取了以下信息

FontMetricsInt: top=-111 ascent=-97 descent=26 bottom=29 leading=0 width=0
複製代碼

實驗2、在設置 android:fontFamliy 爲 NotoSans 以後,咱們獲得以下結果:

FontMetricsInt: top=-190 ascent=-122 descent=30 bottom=111 leading=0 width=0
複製代碼

實驗3、在設置 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);

}
複製代碼

img

上述 代碼是咱們操做的邏輯,這裏須要稍微說明下獲取的 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 的。

4、在 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:保持現有其餘相關邏輯不變的狀況下,強制將文本在該行中垂直居中

img

圖 4-1 Android 端 FontSize 從 8 至 26 的正常模式(左)和 drawMinHeight (右) 的對比圖

img

圖 4-2 Android 端 FontSize 從 8 至 26 的正常模式(左)和 forceVerticalCenter (右) 的對比圖

5、總結

本文經過對字體的關鍵信息的解讀,使得讀者對字體在垂直方向上的佈局有一個大概的印象。再以「中」字爲例分析了 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,郵件主題 簡歷-姓名-求職意向-指望城市-電話

相關文章
相關標籤/搜索