Android font, 字体全攻略

一直没有详细地去了解android字体的相关内容, 实际开发的时候总是对设计稿上面字体和其他控件的间距, 字体内部的行距很疑惑, 直接设置好像每次都差几个像素, 简直逼死强迫症患者.
今天我们就一起来看看, 字体的秘密.

字体结构

要想对攻略字体, 我们先了解清楚字体里面都有些什么.
在分析字体的时候, 我们基本只需要关注垂直方向, 如下图

font结构

垂直方向有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打开字体信息面板

FontForge

先看看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是什么意思呢?

像素计算

要计算字体的高度, 需要记住以下几点:

  1. 设置text size的时候是设置1Em的值
  2. Roboto把1Em分成了2048份
  3. 在Roboto中, Ascent为1900, Descent为-500
  4. 在字体中, 基线(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的高度, 如下图

Android font结构

粉红色就是TextView的背景色, 可以看到在Ascent和Descent之外分别还有一点距离才到TextView的边缘, 也就是右侧使用橙色方块标出的fontPadding.

看到这个fontPadding, 不禁有几点疑问

  1. 这个fontPadding是什么东西? 有什么用?
  2. 这两个距离是由谁加上去的? 是字体设计者还是Android自己?
  3. 还有我们最关心的问题, 这两个距离的值怎么计算?

我们一个一个问题来看.

font padding

设计字体的时候设置的Ascent和Descent我认为只是一个参考值, 因为世界上的除了字母和数字外还有其他一些字体, 例如顶部有变音符的, 艺术字体这类需要占用额外空间的字体, 所以font padding就是这个额外空间, 来确保所有字体都能显示在区域内.

实际上, 上面提到的, ttf文件中的Win-Ascent和Win-Descent就是这个作用, 但是和Android中实际读取到的值并不一致.

那么这两个值怎么算? 我目前找到的办法是通过代码, 利用Paint#getFontMetrics获取这两个值.

FontMetrics

先简单介绍下这个类, 包含了5个变量

  1. top: 即上边界, 因为在Android中, y轴正方向是向下的, 而基准线是y=0, 所以这个值是一个负数.
  2. ascent: 字体文件中设置的Ascent值(即上文提到的在FontForge中查看到的HHead Ascent), 也是负数, 理由同上
  3. descent: 字体文件中设置的Descent值(即上文提到的在FontForge中查看到的HHead Descent), 正数
  4. bottom: 下边界, 正数
  5. 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

ascentdescent的值和我们从FontForge中查看ttf文件得到的值一样, 由于坐标系的不同, 符号相反.

但是topbottom我并没有找到规律, 希望知道的朋友指教一下.

不过不影响结论, 当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

那么为什么是由topbottom决定字体的高度的呢? 那么我们就要看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, 对比如下

Heavenly Font

右侧是把android:includeFontPadding设置为false后的情况, 部分字母显示不完整.
因此使用这个方法前先确定下字体的能够正常显示, 不过实际上大部分常规字体都不需要这个额外空间的, 大部分情况下还是能够放心使用的.

注意, 对于指定的字体文件不支持的文字, 例如使用英文字体文件输入中文, 样式会使用系统默认字体的样式, 但是空间计算的时候还是会按照指定的字体文件的参数来计算, 而不是默认字体的参数.

行距

行距就是相邻两行的基线之间的距离.

默认行距的实际值等于字体设置中的|Descent| + |Aescent|

例如对于Roboto Regular来说, textSize为2048px时, 行距为500 + |-1900| = 2400px

在Android的TextView中, 可以通过android:lineSpacingExtraandroid:lineSpacingMultiplier修改行距. 其中lineSpacingExtra默认值为0, lineSpacingMultiplier默认值为1, 有以下公式

行距=默认行距 * lineSpacingMultiplier + lineSpacingExtra

希望大家看完, 都能了解清楚字体在Android中, 占用高度的计算规则, 如有纰漏, 欢迎评论讨论 :D

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,847评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,208评论 1 292
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,587评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,942评论 0 205
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,332评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,587评论 1 218
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,853评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,568评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,273评论 1 242
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,542评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,033评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,373评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,031评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,073评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,830评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,628评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,537评论 2 269

推荐阅读更多精彩内容

  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 170,568评论 25 707
  • 用两张图告诉你,为什么你的 App 会卡顿? - Android - 掘金 Cover 有什么料? 从这篇文章中你...
    hw1212阅读 12,473评论 2 59
  • 1.iOS中的round、ceil、floor函数略解 round如果参数是小数,则求本身的四舍五入.ceil如果...
    K_Gopher阅读 1,147评论 1 0
  • 2017年1月7日,中国国家博物馆研究员晁岱双博士受莱芜美术馆的热情邀请,继青州送“福”二日行之后,继续把“...
    王永平阅读 385评论 0 0
  • 从上海学习回来后第一天上班。打开窗户惊喜地发现楼下的柿子树竟然早已结果。每天忙忙碌碌地工作,没有时间关心窗外的景色...
    cc42f49449c8阅读 186评论 0 0