作者:位元組跳動終端技術 —— 林學彬
一、背景
鑑於我們在業務開發中經常存在按鈕場景,在 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
複製程式碼
上述日誌中有很多的條目,通過查閱 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
複製程式碼
是不是還是比較迷茫?沒事,通過閱讀下圖就可以比較清晰的瞭解了。
上圖中,最關鍵的為 3 條線,分別是 baseline
、Ascent
及 Descent
。baseline
可以理解為我們水平線,一般情況下 Ascent
及 Descent
分別表示字形繪製區域的上下限。在 NotoSansCJK-Regular
的資訊中,我們看到了 yMax
和 yMin
分別對應圖中的 Top
及 Bottom
,分別表示在本字型所包含的所有字形中,在 y 軸的上限及下限。此外,我們還看到了 LineGap
引數,該引數對應圖中的 Leading
,用於控制行間距的大小。
此外,我們還未提及一個重要的引數 Units per Em
有些時候我們簡稱 Em
, 該引數用於歸一化字型的相關資訊。
比如,在 Flutter 中 我們將字型的 fontSize 設定了 10,此外裝置的 density 為 3,那麼字型到底多高呢 ?
通過 fontEditor
(github.com/ecomfe/font…) 我們可以得到如下圖形:
從上圖可知,“中”字的上頂點座標為 (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
功能,詳情見:github.com/flutter/eng…
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疑難雜症治理系列文章輸出,敬請關注。
參考資料
[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,郵件主題 簡歷-姓名-求職意向-期望城市-電話。