一直没有详细地去了解android字体的相关内容, 实际开发的时候总是对设计稿上面字体和其他控件的间距, 字体内部的行距很疑惑, 直接设置好像每次都差点几个像素, 简直逼死强迫症患者.
今天我们就一起来看看, 字体的秘密.
字体结构
要想对攻略字体, 我们先了解清楚字体里面都有些什么.
在分析字体的时候, 我们基本只需要关注垂直方向, 如下图
垂直方向有5条关键横线.
绿色的横线是最关键的基线(base line), 字体的位置都是相对于基线的, 所以从坐标的角度看, 基线就是y=0的坐标轴.
顶部(ascent)和底部(descent)的红色横线分别为字体的上下"边界".
注意, 虽然说是"边界", 但是实际渲染字体的时候是可能会超过边界的, 我个人理解这两条线算是字体设计者在设计层面给程序提供的一个参考值. 这两条线在设计字体的时候是可以由设计者设置的.
基线到ascent的区域称为升部(ascender), 即图中右侧紫色的区域.
基线到descent的区域称为降部(descender), 即图中右侧蓝色的区域.
黄色的虚线是主线(mean line), 决定无升部的小写字母的高度, 例如e, z, c等. 这个高度又叫x字高(x-height), 也就是图中右侧褐色的区域.
玫红色的虚线(叫啥我也不知道)决定了大写字母的高度. 这个高度又叫大写高度(cap height), 也就是图中右侧绿色的区域.
Em, UPM
除了上述基本结构, 我们还需要搞清楚Em的概念, 有些地方也叫UPM. 简单地说, 字体设计者和程序之间需要有一个抽象的单位来描述字体的高度, 在金属活字印刷时代就有Em来表示一个金属块的高度了, 所以也就沿用了以前Em的说法, 来表示字体的基本单位.
关键知识点: 在Android中, 设置text size的时候, 就是设置1Em的大小.
Em是由字体设计者在设计的时候自行决定将1Em划分成多少份, 然后其他字体中的距离都是用相对Em的大小来描述的.
上面提到的很多值都是在字体设计的时候设置的, 显然这些设置是保存在字体文件当中的, 而在Android中, 最常用的字体文件格式就是.ttf
(True Type Font), 所以我们有必要稍微了解一下这种文件.
TTF(True Type Font)文件
TTF简单地说就是一个标准, 用来统一字体的描述方式.
我们的目的不是为了设计字体, 只是希望搞清楚, 字体当中的设置是怎样影响字体在Android TextView中的显示的, 尤其想搞清楚如何根据字体文件计算垂直方向上字体占用的空间.
注意, 接下来很多关于Ascent和Descent的结论都是通过代码实测得到, 能力有限, 并没有弄清楚其中的原理, 希望知道的朋友可以评论补充 :P
分析字体文件设置, 我们需要一个工具来查看这些.ttf
文件, 我这里用的是FontForge, 用这个软件打开Android的默认字体Roboto Regular, 看看其中的字体信息.
打开文件后, 选择Element ー> Font Info打开字体信息面板
先看看General选项
Em信息
上图可以看出, Roboto中, 把1Em分成了2048份,
实际上, 大部分
ttf
字体都是把1Em分成了2048份. 可能也有部分字体会分成4096份.
这里还会看到Ascent和Descent的值, 不过经过实测, 这两个并不是真正在Android中用到的Ascent和Descent.(我也很崩溃...这部分的资料很少, 并没有深究这其中究竟有什么不同)
真正在Android中的Ascent和Descent值需要看OS/2选项
字体信息面板实测结论就是, 红框中的这两个值才是Android中的Ascent和Descent.
图中顶部的Win Ascent和Win Descent是表示所有字中最高和最低的边界, 但是这两个值并不能对应上Android中的值, 原因不明...
在这图中也能看到x-height和cap height的值.
那么这个1900和-500是什么意思呢?
像素计算
要计算字体的高度, 需要记住以下几点:
- 设置text size的时候是设置1Em的值
- Roboto把1Em分成了2048份
- 在Roboto中, Ascent为1900, Descent为-500
- 在字体中, 基线(base line)是y=0的坐标轴
根据1, 2两点, 可以知道, 1份的值是(textSize / 2048) px
, 假设text size是2048px, 那么1份就是1px.
而1900表示Ascent在基线上方, 距离是1900份. -500表示Descent在基线的下方, 距离是500份.
所以理论上, 如果在字体的text size是2048px, 那么对于这份Roboto Regular字体来说
ascender = 2048px / 2048 * 1900 = 1900px
// 同理
cap height = 1456px
x-height = 1082px
descender = 500px
总高度 = ascender + descender = 1900px + 500px = 2400px
随便打开一个软件, 使用Roboto Regular字体在文本框中输入一段文字, 很容易就能验证这个结论是正确的, 下图是使用Sketch验证的截图
Sketch 2048px基线为0, 左侧可以看到各条线距离基线的距离, 右侧可以看到文本框总高度为2400px, 和计算值一致.
那么在Android的TextView
中显示是不是也是这样呢?
Android TextView中的字体结构
在Android中实测得到的各个区域的值也是一致的, 但是字体的高度却不等于TextView
的高度, 如下图
粉红色就是TextView
的背景色, 可以看到在Ascent和Descent之外分别还有一点距离才到TextView
的边缘, 也就是右侧使用橙色方块标出的fontPadding.
看到这个fontPadding, 不禁有几点疑问
- 这个fontPadding是什么东西? 有什么用?
- 这两个距离是由谁加上去的? 是字体设计者还是Android自己?
- 还有我们最关心的问题, 这两个距离的值怎么计算?
我们一个一个问题来看.
font padding
设计字体的时候设置的Ascent和Descent我认为只是一个参考值, 因为世界上的除了字母和数字外还有其他一些字体, 例如顶部有变音符的, 艺术字体这类需要占用额外空间的字体, 所以font padding就是这个额外空间, 来确保所有字体都能显示在区域内.
实际上, 上面提到的,
ttf
文件中的Win-Ascent和Win-Descent就是这个作用, 但是和Android中实际读取到得知并不一致.
那么这两个值怎么算? 我目前找到的办法是通过代码, 利用Paint#getFontMetrics获取这两个值.
FontMetrics
先简单介绍下这个类, 包含了5个变量
-
top
: 即上边界, 因为在Android中, y轴正方向是向下的, 而基准线是y=0, 所以这个值是一个负数. -
ascent
: 字体文件中设置的Ascent值(即上文提到的在FontForge中查看到的HHead Ascent), 也是负数, 理由同上 -
descent
: 字体文件中设置的Descent值(即上文提到的在FontForge中查看到的HHead Descent), 正数 -
bottom
: 下边界, 正数 -
leading
: 两行之间, 上一行的bottom和下一行的top的间距, 然而这个值总是0, 可以忽略.
更具体的说明可以看看这个回答 Meaning of top, ascent, baseline, descent, bottom, and leading in Android's FontMetrics
而顶部的font padding就是|top - ascent|
, 底部的font padding就是bottom - ascent
我们来实测以下, 通过以下方法读取字体的相关值
public static void printFontMetrics(Context context, @FontRes int fontRes, int emSize) {
Paint paint = new Paint();
// 设置字体, 使用兼容库来通过font资源id获取Typeface实例
paint.setTypeface(ResourcesCompat.getFont(context, fontRes));
// 把字体大小设置成em size方便查看
paint.setTextSize(emSize);
FontMetrics metrics = paint.getFontMetrics();
Log.d("metrics",
"top = " + metrics.top +
", ascent = " + metrics.ascent +
", descent = " + metrics.descent +
", bottom = " + metrics.bottom +
", leading = " + metrics.leading);
}
对于Rotobo Regular, 调用
// 从上面可以知道Rotobo Regular的em size是2048
printFontMetrics(context, R.font.roboto_regular, 2048);
输出为
D/metrics: top = -2163.0, ascent = -1900.0, descent = 500.0, bottom = 555.0, leading = 0.0
ascent
和descent
的值和我们从FontForge中查看ttf
文件得到的值一样, 由于坐标系的不同, 符号相反.
但是
top
和bottom
我并没有找到规律, 希望知道的朋友指教一下.
不过不影响结论, 当textSize=2048的时候, 上面的Android font结构图中的fontPadding, 顶部的值是2163 - 1900 = 263
, 底部的值是550 - 500 = 55
, 可以自行截图验证, 得到以上值之后, 我们就可以通过计算得到字体的上下font padding了
// Rotobo Regular字体
topFontPadding = textSzie * (2163 - 1900) / 2048
bottomFontPadding = textSize * (550 - 500) / 2048
同时还能知道字体的实际高度
// Rotobo Regular字体
height = textSize * (2163 + 550) / 2048 = textSize * 1.3247
那么为什么是由top
和bottom
决定字体的高度的呢? 那么我们就要看TextView
的实现了, 而对于普通的文本, 绘制是由android.text.BoringLayout
负责的.
BoringLayout
决定文本高度的关键代码在于init
方法, 其实很简单, 不看下面的代码也没关系
void init(CharSequence source,
TextPaint paint, int outerwidth,
Alignment align,
float spacingmult, float spacingadd,
BoringLayout.Metrics metrics, boolean includepad,
boolean trustWidth) {
int spacing;
// 忽略非重点代码
// metrics虽然不是FontMetrics, 但含义一致
// spacing就是字体单行所占高度
// mDesc就是字体的下边界
if (includepad) {
spacing = metrics.bottom - metrics.top;
mDesc = metrics.bottom;
} else {
spacing = metrics.descent - metrics.ascent;
mDesc = metrics.descent;
}
mBottom = spacing;
// 忽略非重点代码
// 记录上下font padding
if (includepad) {
mTopPadding = metrics.top - metrics.ascent;
mBottomPadding = metrics.bottom - metrics.descent;
}
}
逻辑很简单, 关键在includepad
, 这个值其实就是android:includeFontPadding
的值, 这个值默认是true
的, 所以默认情况下
Android中的字体高度是
|bottom| + |top|
, 而普通软件(例如word, Sketch或者其他设计软件)中, 字体高度使用的是|descent| + |ascent|
, 所以Android中的字体在垂直方向上总是比设计稿的多占一点空间.
分析到这里, 解决方案也很明显了
对于普通的字体, 要完美复刻设计稿的字体高度, 应该把
android:includeFontPadding
设置为false
当然你也可以手动计算这个font padding, 然后做偏移.
不过这个值默认为true
是有原因的, 因为这个距离是为了保证
字体中所有"符号"都能显示完全, 因此对于特殊的字体, 如果把这个值设为false
, 有可能导致部分字母显示不全, 例如Heavenly Font, 对比如下
左侧是把
android:includeFontPadding
设置为false
后的情况, 部分字母显示不完整.因此使用这个方法前先确定下字体的能够正常显示, 不过实际上大部分常规字体都不需要这个额外空间的, 大部分情况下还是能够放心使用的.
注意, 对于指定的字体文件不支持的文字, 例如使用英文字体文件输入中文, 样式会使用系统默认字体的样式, 但是空间计算的时候还是会按照指定的字体文件的参数来计算, 而不是默认字体的参数.
希望大家看完, 都能了解清楚字体在Android中, 占用高度的计算规则, 如有纰漏, 欢迎评论讨论 :D
下一篇文章我们来看看行距的问题~
网友评论