做者:字節跳動終端技術 —— 林學彬前端
鑑於咱們在業務開發中常常存在按鈕場景,在 UI 表現上咱們要求其中的描述文案能儘量的垂直居中。可是在開發的過程當中,咱們常常遇到以下圖所展現的文本垂直不居中的問題,須要額外的設置 Padding 屬性。可是隨着字號、手機屏幕密度等因素的變化,Padding 的值也須要隨着進行調整,從而須要咱們研發人員投入必定的精力去適配。java
若是咱們的 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
上圖中,最關鍵的爲 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,那麼
即 當 fontSize 設置爲 30 像素時,「中」 字高度爲 27 像素,文本框高度爲 44 像素。
由上節可知,LineGap 爲 0 也即 Leading 爲 0,那麼在 Flutter 中文本在在垂直方向上的佈局僅僅和 ascent 及 descent 有關即:
height = (accent - descent) / em * fontSize
經過由2.1節的「中」子圖可知:
若是fontSize 爲 10 ,在 density 爲 3 的設備上,10 * 3 * (420 - 380) / 1000= 1.2 ~= 1,中心點已經出現了 1 像素的誤差,隨着字號越大,誤差就會越大,於是若是直接使用 NotoSans 的信息進行垂直方向的佈局是不可能實現文本的垂直居中的。
那麼除了使用 Padding 方式外,還有什麼其餘方法嗎?或者咱們換個角度,由於 Flutter 不少設計原理和 Android 極其相似,全部咱們先參考下 Android 目前的實現方式。
目前在 Android 中除了使用 Padding,咱們目前可行是的兩個方案:
includeFontPadding
爲 false
在 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);
}
複製代碼
實驗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 字體,這種狀況下恰巧使得文本居中了,於是這不是咱們所追求的方案。
@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 的。
由 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)
函數調用獲取並能夠。
在實現的過程當中遇到如下幾個注意的點:
咱們也向官方提了相應的 PR 實現了 forceVerticalCenter
功能,詳情見:github.com/flutter/eng…
和官方 PR 的區別是內部版本咱們而外提供了 drawMinHeight 參數,由於要實現這部分功能修改量比較大所在暫不許備向官方提 PR。
在 Text 中,咱們添加了兩個參數:
圖 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,郵件主題 簡歷-姓名-求職意向-指望城市-電話。