深入 Android TextView 文本测量与布局全链路:从 StaticLayout 创建到 LineBreaker 断行的文本排版引擎解析

做自定义 View 的文本渲染时,我踩过一个坑:用 Canvas.drawText() 画多行文本,手动算换行位置,结果中英文混排时行尾参差不齐,加上 \n 换行符还要额外处理。后来才意识到,Android 提供了完整的文本排版引擎——StaticLayout,它就是 TextView 内部的排版核心。

要用好 StaticLayout,得先理解排版引擎的工作机制、文字度量坐标系和断行算法的决策逻辑。

文本排版引擎:StaticLayout 与 DynamicLayout

Android 的文本排版由 android.text.Layout 抽象类定义,核心子类有 StaticLayout、DynamicLayout,以及专门优化单行无格式文本的 BoringLayout。TextView 内部根据文本类型和是否可编辑/可选中,自动选择其中之一。

StaticLayout 的「Static」不是指文本内容不可变,而是布局创建后不能修改。文本变了就得重新 new 一个。它的优势在于快:创建时一次性完成所有测量和断行,后续 getLineStart()、getLineEnd()、getLineBottom() 都是 O(1) 查表。

TextView 的选择逻辑(来自 AOSP TextView#useDynamicLayout(),简化描述)大致是:文本是 Spannable(且没有预计算文本 PrecomputedText)时,或者文本被设置为可选中(setTextIsSelectable(true)),才使用 DynamicLayout;否则走 StaticLayout(其中单行、无特殊字符的「boring」文本还会被 BoringLayout.isBoring() 命中,用更轻量的 BoringLayout 处理):

// TextView 内部简化逻辑(对应 makeSingleLayout)
Layout layout;
if (isTextSelectable() || (isSpannable && precomputedText == null)) {
    layout = DynamicLayout.Builder.obtain(text, paint, width)....build();
} else if (boring != null /* 单行、无需换行等 */) {
    layout = new BoringLayout(text, paint, width, ...);
} else {
    layout = StaticLayout.Builder.obtain(text, 0, text.length(), paint, width)....build();
}

注意这里判断的核心不是简单的 text instanceof Spannable,而是 TextView 内部维护的 mSpannable 状态(在 setText() 时根据 BufferType 和文本类型确定),并且需要没有关联 PrecomputedText。DynamicLayout 支持增量更新:当 Spannable 内容变化时,只重新计算受影响的区域,而不是整个布局,代价是文本必须是 Spannable 实例(可编辑的 Editable 也是 Spannable 的子接口)。

Layout 内部维护了一个关键结构:行表(line table)。每行记录起始字符偏移、行宽、行高。创建时用 LineBreaker 计算断行位置,然后逐行测量宽度和高度,最终确定总高度。

在自定义 View 中做多行文本渲染,直接用 StaticLayout 是最省心的方案:

val layout = StaticLayout.Builder.obtain(text, 0, text.length, paint, width)
    .setAlignment(Layout.Alignment.ALIGN_NORMAL)
    .setLineSpacing(4f, 1.2f)  // extra spacing, multiplier
    .setIncludePad(true)
    .build()

// 绘制时直接遍历行
for (i in 0 until layout.lineCount) {
    val lineStart = layout.getLineStart(i)
    val lineEnd = layout.getLineEnd(i)
    canvas.drawText(text, lineStart, lineEnd, x, layout.getLineBaseline(i), paint)
}

setLineSpacing 的两个参数对应 android:lineSpacingExtra 和 android:lineSpacingMultiplier,最终行高 = 原始行高 × multiplier + extra。

FontMetrics:文字度量的坐标系

要理解布局计算,必须先搞清楚 FontMetrics——它是 Paint 对字体的度量描述,定义了一套基于 基线(baseline) 的坐标系。

val fm = paint.fontMetrics
// fm.top     → 基线以上最大距离(负值,如 -250.7)
// fm.ascent  → 基线以上推荐距离(负值,如 -214.8)
// fm.descent → 基线以下推荐距离(正值,如 59.6)
// fm.bottom  → 基线以下最大距离(正值,如 69.4)
// fm.leading → 行间距(通常为 bottom - ascent 之外的额外空间)

这些值的关系如下图所示(用文字描述):

  ─────── top(负值,最上方)
  ─────── ascent(负值,推荐升部上界)
  ═══════ baseline(0,基线)
  ─────── descent(正值,推荐降部下界)
  ─────── bottom(正值,最下方)

很多人以为 textSize 等于 descent - ascent,实际上不是。textSize 是字体的设计尺寸,实际字符可能超出这个范围——带变音符号的字母可能越过 ascent 延伸到 top 区域。getTextBounds() 返回的高度比 textSize 大就是这个原因。

Layout.getLineTop(i) 和 Layout.getLineBottom(i) 的差值就是第 i 行的实际高度。Layout.getLineBaseline(i) 返回该行基线在布局中的绝对位置,这是绘制时 drawText 的 y 坐标。

setIncludePad(true) 会让第一行顶部和最后一行底部各增加 top - ascent 和 bottom - descent 的额外空间,目的是防止顶部字符被裁剪。如果自定义 View 中文本顶部留白过大,关掉它就行:

StaticLayout.Builder.obtain(...)
    .setIncludePad(false)  // 去掉顶部/底部内边距
    .build()

LineBreaker:断行算法解析

文本排版里最复杂的环节是断行——决定一行在哪里换行。Android 从 Q(API 29)开始引入了 LineBreaker 类,支持三种策略:

策略常量行为
简单断行BREAK_STRATEGY_SIMPLE逐字符推进,超出宽度时回退到上一个可断行位置
高质量断行BREAK_STRATEGY_HIGH_QUALITY基于 Unicode 断行算法 + 语言规则,自动处理连字符
均衡断行BREAK_STRATEGY_BALANCED在高质量断行基础上,尽量让每行宽度接近,避免末行过短

StaticLayout.Builder 默认使用 BREAK_STRATEGY_SIMPLE,这也是 TextView 的默认行为。中英文混排时英文单词被拦腰截断,就是这个原因。切换到高质量断行即可解决:

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
    StaticLayout.Builder.obtain(...)
        .setBreakStrategy(Layout.BREAK_STRATEGY_HIGH_QUALITY)
        .build()
}

断行背后的公开 API 是 android.graphics.text.LineBreaker(API 29 引入,属于 android.graphics.text 包,不同于 android.text.Layout 中同名的 BREAK_STRATEGY_* 常量定义)。它的核心方法是 computeLineBreaks(MeasuredText, LineBreaker.ParagraphConstraints, int),输入是预先测量好的 MeasuredText 和段落约束,返回一个 LineBreaker.Result 对象(包含每行的结束位置、宽度等信息),而不是简单的 int[]。StaticLayout/DynamicLayout 内部会创建 LineBreaker 实例并逐段落调用它来完成断行,但这属于内部实现细节,不属于应用开发者需要直接调用的公开接口。

三种断行策略的实际差异(公开行为,不涉及内部实现):

  1. BREAK_STRATEGY_SIMPLE:只在必要时断行,不做额外优化,速度最快
  2. BREAK_STRATEGY_HIGH_QUALITY:自动连字,并对整个段落做断行优化,计算成本更高
  3. BREAK_STRATEGY_BALANCED:在高质量断行基础上,进一步均衡每行宽度

CJK(中日韩)文字的特殊性在于:每个字符本身就是一个可断行点。所以中文排版很少出现单词被截断的情况。但英文没有空格时,SIMPLE 策略可能会在单词中间断行。

BREAK_STRATEGY_HIGH_QUALITY 对英文排版提升明显:它会尽量在单词边界断行,开启连字(setHyphenationFrequency)时还会自动插入连字符,效果接近专业排版软件。计算量确实更大,但对于几百行以内的文本,差异可以忽略。

BREAK_STRATEGY_BALANCED 更进一步,让断行位置在整段文本中更均匀分布。比如一段 3 行英文,不会出现前两行很满、第三行只有两个单词的情况。这三个断行常量都定义在 Layout 中,自 API 23(Android 6.0)起就可通过 StaticLayout.Builder.setBreakStrategy() 和 TextView.setBreakStrategy() 使用,并不需要 API 31。

行高计算:从 FontMetrics 到 Layout

Layout 计算行高时,不是直接用 descent - ascent,而是用 bottom - top。这里有个细节:

// Layout 内部行高计算(简化)
int lineHeight = (int) Math.ceil(fm.bottom - fm.top);

如果 setIncludePad(true),第一行会额外加上 fm.top - fm.ascent,最后一行额外加上 fm.bottom - fm.descent。

Android P(API 28)引入了 setFallbackLineSpacing(),用于处理 fallback 字体 的行高问题。当一行文本包含需要回退到不同字体渲染的字符时(比如拉丁字母行里混入了一个 Emoji),不同字体的 FontMetrics 可能差异很大。setFallbackLineSpacing(true) 确保行高受最大 fallback 字体度量值的影响,防止行高不一致导致的文本跳动。

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
    StaticLayout.Builder.obtain(...)
        .setFallbackLineSpacing(true)
        .build()
}

自定义 View 中的实践

一个典型的自定义文本渲染 View 的 onDraw 实现:

override fun onDraw(canvas: Canvas) {
    super.onDraw(canvas)
    val layout = buildLayout()
    for (i in 0 until layout.lineCount) {
        canvas.drawText(
            layout.text, layout.getLineStart(i), layout.getLineEnd(i),
            0f, layout.getLineBaseline(i), textPaint
        )
    }
}

private fun buildLayout(): StaticLayout {
    return StaticLayout.Builder.obtain(text, 0, text.length, textPaint, width.toFloat())
        .setAlignment(Layout.Alignment.ALIGN_NORMAL)
        .setIncludePad(false)
        .setBreakStrategy(Layout.BREAK_STRATEGY_HIGH_QUALITY)
        .setFallbackLineSpacing(true)
        .build()
}

上面这段代码有一个性能问题:onDraw 里每次都调用 buildLayout() 创建新的 StaticLayout。如果文本较长,断行计算会成为每帧的负担。正确做法是缓存 Layout 对象,只在文本或宽度变化时重建。

另一个容易踩的坑是 Layout.getLineWidth(i):它返回的是该行纯文本的宽度,不包括对齐产生的偏移。居中或右对齐时,需要改用 getLineLeft(i) 或 getLineRight(i) 获取实际绘制位置。

Layout.getLineLeft(i) 和 getLineRight(i) 的差值等于该行最大宽度,受 getLineMax(i) 影响。做「文本选中高亮」一类需求时,用这两个值来确定高亮背景的边界,比手动计算靠谱得多。

最后

StaticLayout 替代手动排版,FontMetrics 的基线坐标系,断行策略的选择——这三者构成了 Android 文本排版的基础。搞清楚了,TextView 的测量、自定义文本渲染、甚至富文本编辑器里的排版问题,都能从同一个模型出发去理解。

BreakStrategy 的差异在中文场景下不那么明显,但做国际化应用时,HIGH_QUALITY 几乎是必选项。性能方面,除非你的列表里每个 item 都在实时渲染大量文本,否则不用纠结 StaticLayout 的创建开销——它的计算在 Java 层完成,比想象中快得多。