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

位元組終端技術發表於2021-08-05

作者:位元組跳動終端技術 —— 林學彬

一、背景

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

img

二、字型關鍵資訊

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
複製程式碼

上述日誌中有很多的條目,通過查閱 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
複製程式碼

是不是還是比較迷茫?沒事,通過閱讀下圖就可以比較清晰的瞭解了。

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 目前的實現方式。

三、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);
}
複製程式碼

實驗一、在預設情況下,我們獲取瞭如下資訊

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);

}
複製程式碼

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 的。

四、在 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 (右) 的對比圖

五、總結

本文通過對字型的關鍵資訊的解讀,使得讀者對字型在垂直方向上的佈局有一個大概的印象。再以“中”字為例分析了 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,郵件主題 簡歷-姓名-求職意向-期望城市-電話

相關文章