做者:字节跳动终端技术 —— 林学彬前端
鉴于咱们在业务开发中常常存在按钮场景,在 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,邮件主题 简历-姓名-求职意向-指望城市-电话。