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

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

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

文本排版引擎:StaticLayout 与 DynamicLayout

Android 的文本排版由 android.text.Layout 抽象类定义,两个核心子类是 StaticLayoutDynamicLayout。TextView 内部根据文本是否会变化,自动选择其中之一。

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

// TextView 内部简化逻辑
Layout layout;
if (text instanceof Spannable) {
    layout = new DynamicLayout(text, paint, width, alignment, spacingMult, spacingAdd, includepad);
} else {
    layout = new StaticLayout(text, paint, width, alignment, spacingMult, spacingAdd, includepad);
}

DynamicLayout 支持增量更新:当 Spannable 内容变化时,只重新计算受影响的区域,而不是整个布局。代价是文本必须是 SpannableEditable 实例。

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:lineSpacingExtraandroid: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 - ascentbottom - 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()
}

断行的核心逻辑在 LineBreaker.computeLineBreaks() 中:给定文本、宽度和 Paint,它返回一个 int[] 数组,每个元素表示一行的结束字符位置。算法大致流程:

  1. 从当前行起始位置开始,逐个字符推进
  2. 每次推进后测量已累积文本的宽度
  3. 宽度超过可用宽度时,回退到最近的可断行点(空格、CJK 字符边界、连字符位置等)
  4. 记录断行位置,开始下一行

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

BREAK_STRATEGY_HIGH_QUALITY 对英文排版提升明显:它会在单词边界断行,必要时使用连字符,效果接近专业排版软件。计算量确实更大,但对于几百行以内的文本,差异可以忽略。

BREAK_STRATEGY_BALANCED 更进一步,让断行位置在整段文本中更均匀分布。比如一段 3 行英文,不会出现前两行很满、第三行只有两个单词的情况。这个策略在 TextView 中需要 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 层完成,比想象中快得多。

深入 Android Spannable 富文本系统全链路:从 Spanned 接口设计到自定义 Span 的 Canvas 渲染引擎

深入剖析 Android Spannable 富文本从标记存储、Span 分层回调到 TextLine Canvas 渲染的完整链路,涵盖自定义 ReplacementSpan 与 ParagraphStyle 实战、异步测量陷阱及 Compose AnnotatedString 演进对比。

深入 Android RecyclerView 自定义 LayoutManager 全链路:从布局算法到动画协同的视口管理引擎

从首项居中放大需求出发,深入解析 RecyclerView 自定义 LayoutManager 的 Fill 布局算法、三级缓存漏斗、滚动状态机与航位推算,以及动画协同机制,并给出调试三板斧与性能红线。

深入浅出 Android TextView:揭秘文本测量与布局的艺术

在 Android 应用开发中,TextView 是最基础也是最常用的控件之一。我们每天都在用它来显示各种文本信息,从简单的按钮标签到复杂的富文本段落。但你是否曾好奇:TextView 是如何在有限的空间内,将一串字符精确地转换成屏幕上可见的、排列整齐的文字?这背后涉及一套复杂而精密的测量(Measure)与布局(Layout)机制。

深入 Android 端侧 AI 的独立进程推理架构:从进程隔离到 AIDL 通信的稳定性保障

将端侧 LLM 推理迁移到独立进程,通过内存隔离解决 OOM 问题,通过崩溃隔离保护主进程稳定性。本文详细记录了 AIDL 接口设计、跨进程生命周期绑定、Binder 通信陷阱及多模型管理等实战经验。