设计稿给了一个 14×12dp 的排名角标,背景圆角矩形,里面一个数字居中。用 TextView + gravity="center" 实现,结果数字总是偏上。调 padding、调 includeFontPadding="false"、换 lineSpacingExtra,怎么都差那么一两个像素。

Font Metrics ≠ 你看到的字

Android 的 TextViewfont metrics 定位文字。Font metrics 描述的是字体的”设计空间”,而不是某个具体字符的实际像素范围:

┌─────────────────────────────┐
│          leading             │  ← 行间距预留
├─────────────────────────────┤
│          ascent              │  ← 包含音调符号空间 (Ä, É)
│                             │
│      ┌───────────┐          │
│      │  visible  │          │  ← 实际墨水区域 (glyph bounds)
│      │   glyph   │          │
│      └───────────┘          │
│                             │
├─────────────────────────────┤
│          descent             │  ← 包含下挂字母空间 (g, y, p)
└─────────────────────────────┘

TextView 的”居中”是把 ascent 到 descent 这段区间居中在容器里。但对于数字 “1”~”9”:

  • 实际墨水区域只占 ascent 的一部分(数字不需要音调符号的空间)
  • descent 几乎为零(数字没有下挂部分)

结果就是:font metrics 的几何中心和字形的视觉中心不重合,文字看起来偏上。

容器越大,这个偏移占比越小,越不明显。但当容器只有 12dp 高时,1~2px 的偏移肉眼可见。

用 getTextBounds() 验证

Paint.getTextBounds() 可以拿到字符实际墨水区域的 Rect,和 FontMetrics 对比一下就能看出差距:

val paint = Paint().apply { textSize = 30f }

val metrics = paint.fontMetrics
// metrics.ascent = -28.0  (baseline 以上 28px)
// metrics.descent = 7.0   (baseline 以下 7px)
// 总高度 = 35px,中心在 baseline 上方 10.5px

val bounds = Rect()
paint.getTextBounds("3", 0, 1, bounds)
// bounds.top = -21  (baseline 以上 21px)
// bounds.bottom = 0 (刚好到 baseline)
// 总高度 = 21px,中心在 baseline 上方 10.5px

font metrics 认为文字占 35px,实际 “3” 只占 21px。两者的”中心”差了 (35 - 21) / 2 = 7px。在 12dp(约 36px @3x)的容器里,7px 的偏移非常明显。

解法:基于 glyph bounds 做测量和绘制

放弃 TextView,继承 View,自己用 getTextBounds() 做居中:

class GlyphCenteredTextView(context: Context, attrs: AttributeSet?) : View(context, attrs) {

    private val textBounds = Rect()
    private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG)
    private var textString = ""

    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        if (textString.isNotEmpty()) {
            textPaint.getTextBounds(textString, 0, textString.length, textBounds)
        } else {
            textBounds.setEmpty()
        }
        val desiredWidth = paddingLeft + textBounds.width() + paddingRight
        val desiredHeight = paddingTop + textBounds.height() + paddingBottom
        setMeasuredDimension(
            resolveSize(desiredWidth, widthMeasureSpec),
            resolveSize(desiredHeight, heightMeasureSpec),
        )
    }

    override fun onDraw(canvas: Canvas) {
        if (textString.isEmpty()) return

        val contentLeft = paddingLeft
        val contentRight = width - paddingRight
        val contentTop = paddingTop
        val contentBottom = height - paddingBottom

        val x = contentLeft +
            (contentRight - contentLeft - textBounds.width()) / 2F -
            textBounds.left
        val baseline = contentTop +
            (contentBottom - contentTop - textBounds.height()) / 2F -
            textBounds.top

        canvas.drawText(textString, x, baseline, textPaint)
    }
}

几个关键点:

为什么要减 textBounds.left

getTextBounds() 返回的 left 不一定是 0。斜体字或某些字形的左侧会有 bearing(留白或溢出)。减去 left 才能让字形的视觉左边缘对齐到我们计算的 x 位置。

为什么 -textBounds.top 就是 baseline 偏移?

getTextBounds() 的坐标系以 baseline 为原点,baseline 以上为负。所以 top 是负数(比如 -21),-top 就是”从字形顶部到 baseline 的距离”。我们先算出字形顶部应该在哪(居中后的位置),再加上这个距离,就得到了 baseline 的 y 坐标。

为什么 onMeasure 用 glyph bounds 而不是 font metrics?

如果用 font metrics 测量高度(ascent + descent = 35px),然后在 onDraw 里用 glyph bounds 居中(实际只有 21px),wrap_content 时容器会比内容大很多。对于角标这种紧凑场景,测量和绘制必须基于同一套数据。

什么时候该用这个方案

这不是一个通用方案。它适合的场景有明确的特征:

  • 容器很小(≤ 16dp 高),像素级偏移肉眼可见
  • 只显示数字或单个字符,不需要处理多行、省略号、Span
  • 需要文字在背景图形中精确视觉居中(角标、徽章、头像上的数字)

正常的文本展示仍然应该用 TextView。font metrics 的”偏移”在常规尺寸下是正确的排版行为——它保证了多行文本的行间距一致,保证了不同字符(大写、小写、带音调)在同一行内对齐。只有当你把文字塞进一个极小的容器、且只关心单个字符的视觉中心时,font metrics 才会成为问题。