1. 从一次文本渲染异常说起:为什么需要深入StaticLayout
做Android开发的朋友大概率都遇到过这类问题:一段长文本在TextView里显示时,换行位置总是不对,明明设置了maxLines和ellipsize,省略号出现的位置却飘忽不定;或者自定义View里用Canvas画多行文字,行间距怎么调都和设计稿对不上。更让人头疼的是,同一段文字在不同字号、不同语言环境下,布局结果完全不可预测。
这些问题的根源,往往不在TextView本身,而在于它背后真正负责文本排版的那个核心类——StaticLayout。TextView只是一个壳,它把文本测量的活儿委托给了Layout体系,而StaticLayout是其中处理静态文本的主力。你调用textView.setText()之后,系统内部会创建或复用一个StaticLayout,由它来计算每一行放哪些字、在哪里断行、每行的基线在哪、总高度是多少。TextView拿到这些数据后,才在onDraw里逐行绘制。
所以,如果你想真正掌控文本渲染,光会调TextView的API是不够的。你得理解StaticLayout的工作机制:它怎么测量、怎么断行、怎么处理省略号、怎么和Paint配合。这篇文章就是围绕这些展开的。我会从实际开发中遇到的文本渲染问题切入,把StaticLayout的构造参数、测量流程、断行算法、与TextView的协作关系逐一拆开讲,并给出可直接复用的代码示例和避坑经验。适合已经写过自定义View、对Canvas绘制有基本了解,但在文本排版上总觉得“差一口气”的Android开发者。
2. StaticLayout在Android文本体系中的位置
2.1 Layout家族的三兄弟:BoringLayout、StaticLayout、DynamicLayout
Android的文本布局体系里,Layout是一个抽象基类,它定义了文本测量的通用接口,比如getLineCount()、getLineTop()、getLineBaseline()、getLineWidth()等。真正干活的是它的三个子类,各自对应不同的使用场景。
BoringLayout是最轻量的一个。它的名字直译过来就是“无聊的布局”,意思是它只处理最简单的情况:单行文本,或者虽然多行但没有复杂断行需求的文本。它的测量速度最快,因为它不需要做复杂的断行计算,直接按字符宽度累加就行。TextView在判断文本是单行且没有特殊样式时,会优先用BoringLayout。
StaticLayout是本文的主角。它处理的是“静态”文本——所谓静态,是指文本内容在布局创建后不会再变。它支持多行、支持Span样式、支持自定义断行策略、支持省略号。TextView在大多数多行场景下用的就是它。它的构造过程会完整地走一遍断行算法,把每一行的起始位置、结束位置、宽度、基线都算出来,缓存起来供后续绘制使用。
DynamicLayout则是在StaticLayout基础上增加了对可变文本的支持。当文本内容会动态变化(比如Editable对象),并且变化后需要增量更新布局时,就用它。它内部会监听文本变化,只重新计算受影响的行,而不是全部重排。EditText用的就是DynamicLayout。
三者的关系可以这样理解:BoringLayout是“不用算”,StaticLayout是“算一次”,DynamicLayout是“变了再算”。选择哪个,取决于你的文本是否可变、是否复杂。
2.2 TextView如何委托StaticLayout完成测量
TextView的测量流程大致是这样的:在onMeasure里,它会根据当前宽度约束和文本内容,决定用哪种Layout。判断逻辑在makeNewLayout方法里,大致是:如果文本是单行、没有Span、没有省略号需求,就用BoringLayout;否则用StaticLayout或DynamicLayout。
创建StaticLayout时,TextView会传入几个关键参数:文本内容、Paint对象、布局宽度、对齐方式、行间距、是否包含字体padding等。StaticLayout构造完成后,TextView就能通过getLineCount()拿到行数,通过getLineTop(line)拿到每行的顶部Y坐标,通过getLineBaseline(line)拿到基线位置,通过getLineWidth(line)拿到行宽。这些数据直接决定了TextView的onDraw里怎么逐行画文字。
这里有个容易被忽略的点:TextView的getLineHeight()返回的其实是getLineTop(1) - getLineTop(0),也就是第一行和第二行顶部的差值,而不是字体本身的行高。如果你在自定义View里想模仿TextView的行间距,不能直接用Paint的FontMetrics,得用StaticLayout算出来的行高。
2.3 为什么自定义View里画多行文字必须用StaticLayout
很多人在自定义View里画多行文字时,第一反应是用canvas.drawText配合手动换行。做法大概是:用paint.measureText逐字测量,累加到超过宽度就换行,记录每行的起始索引,然后逐行drawText。这个方案在简单场景下能跑,但一旦遇到中文、英文混排,或者有标点符号避头尾需求,就会出问题。
原因在于,Android的断行规则远比“宽度够了就换”复杂。它要处理:英文单词不能从中间断开(除非用连字符)、中文可以在任意字之间断行、标点符号不能出现在行首(避头)、开括号不能出现在行尾(避尾)、不同语言的断行规则还不一样。这些规则都封装在StaticLayout内部的断行算法里,你自己实现一遍几乎不可能做到完全正确。
所以,正确的做法是:在自定义View里也创建StaticLayout,用它算好布局,然后在onDraw里遍历每一行,用canvas.drawText配合getLineStart、getLineEnd、getLineBaseline来绘制。这样既能保证断行正确,又能复用系统的排版能力。代价是StaticLayout的创建有一定开销,所以如果文本不变,应该缓存起来,不要每次onDraw都重新创建。
3. StaticLayout构造参数逐个拆解
3.1 构造方法签名与参数含义
StaticLayout的构造方法有好几个重载,最常用的一个签名大致如下(不同API版本略有差异):
StaticLayout(CharSequence source, TextPaint paint, int width, Layout.Alignment align, float spacingmult, float spacingadd, boolean includepad, TextUtils.TruncateAt ellipsize, int ellipsizedWidth)逐个看这些参数:
- source:要布局的文本。可以是String、SpannableString等。如果是Spannable,里面的Span样式会影响测量结果,比如粗体字更宽、字号更大的字更高。
- paint:TextPaint对象,携带字体、字号、颜色等信息。注意它必须是TextPaint,因为内部会用到TextPaint特有的属性。
- width:布局的可用宽度,单位像素。文本会在这个宽度内断行。
- align:对齐方式,有ALIGN_NORMAL、ALIGN_CENTER、ALIGN_OPPOSITE三种。注意它影响的是每行文本在布局宽度内的水平位置,不是整个文本块的位置。
- spacingmult:行间距倍数。1.0表示默认行距,1.5表示1.5倍行距。
- spacingadd:行间距额外增量,单位像素。在倍数基础上再加这么多。
- includepad:是否在行高里包含字体padding。字体本身在基线上下有额外空间,这个参数决定要不要算进去。
- ellipsize:省略号策略,有END、START、MIDDLE、MARQUEE几种。传null表示不省略。
- ellipsizedWidth:省略号生效的宽度。通常和width一样,但可以不同,比如你想在更窄的范围内显示省略号。
这些参数里,最容易踩坑的是spacingmult和spacingadd的组合效果,以及includepad对行高的影响。下面分别说。
3.2 spacingmult与spacingadd的真实计算逻辑
很多人以为spacingmult=1.5就是行高变成1.5倍,实际不是。StaticLayout计算行高的公式大致是:
行高 = 字体行高 * spacingmult + spacingadd其中字体行高是Paint的FontMetrics里descent - ascent + leading(如果includepad为true,还要加上top和bottom的padding)。所以spacingmult是乘在字体行高上的,spacingadd是在乘完之后再加的固定值。
举个例子:假设字体行高是40px,spacingmult=1.5,spacingadd=10,那么实际行高是40 * 1.5 + 10 = 70px。如果你想要“1.5倍行距”的效果,通常应该设spacingmult=1.5、spacingadd=0。如果你想要“在默认行距基础上加10px”,应该设spacingmult=1.0、spacingadd=10。
TextView的setLineSpacing(add, mult)方法就是直接映射到这两个参数的。注意参数顺序是add在前、mult在后,和构造方法里的顺序相反,容易搞混。
还有一个细节:spacingadd可以是负数,用来压缩行距。但压缩太多会导致文字重叠,一般不建议小于-字体行高 * 0.2。
3.3 includepad对首行和末行高度的影响
includepad这个参数名字很不起眼,但它对布局高度的影响很大。字体在基线之上有ascent,之下有descent,但字体本身还定义了top和bottom,通常top比ascent更靠上,bottom比descent更靠下。这部分多出来的空间就是“pad”。
当includepad=true时,第一行的顶部会从top开始算,最后一行的底部会到bottom,所以整个布局会更高。当includepad=false时,第一行顶部从ascent开始,最后一行底部到descent,布局更紧凑。
TextView默认用的是includepad=true,所以你在自定义View里如果设了false,会发现文本块比TextView里矮一截。反过来,如果你想让自定义View的文本和TextView完全对齐,就得也用true。
实测经验:对于单行文本,includepad=true会让布局高度比false多出大概(top - ascent) + (bottom - descent),这个值在不同字体下不一样,通常占字体大小的10%到20%。如果你在做精确的垂直居中,这个差异必须考虑进去。
4. 断行算法与省略号的底层机制
4.1 断行是怎么一步步算出来的
StaticLayout的断行算法在StaticLayout.Builder或者构造方法里触发,核心逻辑在generate方法里。它的大致流程是:
- 从文本起始位置开始,用Paint测量每个字符的宽度,累加得到当前行的宽度。
- 当累加宽度超过布局宽度时,触发断行判断。
- 断行判断会往回找最近的合法断点。合法断点包括:空格、连字符、中文汉字之间、标点符号之后等。
- 找到断点后,把这一行的起止位置、宽度、基线记录下来。
- 从断点之后继续下一行的测量,直到文本结束。
这个过程中,最复杂的是“找合法断点”。Android用的是android.text.TextUtils和内部的MeasuredText类来处理。它会考虑Unicode的断行属性,比如某些字符是“不允许断行”的(如不换行空格),某些是“允许断行”的(如普通空格),某些是“必须断行”的(如换行符)。
对于中文,由于汉字之间没有空格,断行规则是“任意两个汉字之间都可以断”,但标点符号有避头尾规则。比如逗号、句号不能出现在行首,如果断点正好在标点前,算法会把标点拉到上一行,导致上一行稍微超出宽度。这个“稍微超出”是允许的,因为标点避头尾的优先级高于严格宽度限制。
4.2 省略号出现的位置为什么难以预测
省略号(ellipsize)的处理是StaticLayout里另一个容易让人困惑的地方。当你设置了ellipsize=END,StaticLayout在断行到超过maxLines时,会在最后一行末尾画省略号。但“末尾”具体在哪,取决于几个因素:
- 最后一行原本能放多少字
- 省略号本身占多少宽度
- 省略号前面的字符是否允许被截断
算法大致是:先按正常断行算出所有行,如果行数超过maxLines,就取第maxLines行,然后从该行末尾往回删字符,直到剩下的宽度能放下省略号。删的过程中,如果遇到不能截断的位置(比如英文单词中间),会继续往前删到合法断点。
这就解释了为什么有时候省略号前面会多出几个字符,有时候又少几个——因为合法断点的位置不同。如果你发现省略号位置不符合预期,可以检查一下文本里是否有特殊字符影响了断点判断。
还有一个坑:ellipsizedWidth如果和width不一样,省略号的截断会以ellipsizedWidth为准,但断行还是以width为准。这会导致一种情况:文本在width内能放下,但省略号按ellipsizedWidth算,结果提前出现了。一般建议两者设成一样,除非你有特殊需求。
4.3 中文、英文、emoji混排时的断行差异
混排是断行问题的高发区。中文和英文的断行规则不同:中文可以在任意字之间断,英文必须在单词边界断。当一行里既有中文又有英文时,算法会分别处理。
比如“这是一个Android开发的问题”,断行时“Android”作为一个整体不能被拆开,如果行宽不够放整个单词,就会把整个单词移到下一行,导致上一行末尾留白。这个留白是正常的,但如果你用measureText自己算,可能就会把单词拆开。
Emoji的处理更特殊。一个emoji可能由多个Unicode码点组成(比如带肤色修饰的emoji),断行时不能从中间拆开。StaticLayout内部会识别这些组合字符,把它们当作一个整体。如果你自己处理,很容易把一个emoji拆成两个乱码。
实测建议:如果你的应用要显示多语言文本,尤其是包含emoji的,强烈建议用StaticLayout而不是自己测量。自己测量几乎不可能覆盖所有边界情况。
5. 在自定义View里复现TextView的文本渲染
5.1 创建并缓存StaticLayout的正确姿势
在自定义View里用StaticLayout,第一步是创建它。但创建有开销,尤其是长文本,断行计算可能耗时几毫秒到几十毫秒。所以如果文本不变,应该缓存。
缓存的策略是:在onSizeChanged或者文本变化时创建,存成成员变量,onDraw里直接用。如果文本会变,就在setText方法里重新创建。
创建时要注意Paint的配置。TextPaint必须设置好字号、字体、颜色。颜色虽然不影响测量,但会影响绘制。另外,如果文本里有Span,要确保Span已经应用到CharSequence上。
一个常见的错误是:在onDraw里直接new StaticLayout(...)。这样每帧都会重新计算断行,性能极差,而且可能因为频繁GC导致卡顿。我见过一个案例,一个列表项里的自定义View每帧创建StaticLayout,滑动时掉帧严重,改成缓存后流畅度立刻恢复。
5.2 逐行绘制时的坐标计算
创建好StaticLayout后,绘制就简单了。在onDraw里遍历每一行:
for (int i = 0; i < layout.getLineCount(); i++) { int lineStart = layout.getLineStart(i); int lineEnd = layout.getLineEnd(i); float lineBaseline = layout.getLineBaseline(i); CharSequence lineText = text.subSequence(lineStart, lineEnd); canvas.drawText(lineText, 0, lineText.length(), 0, lineBaseline, paint); }这里的关键是getLineBaseline(i)返回的是基线的Y坐标,drawText的y参数也是基线位置,所以直接传就行。x坐标传0表示从布局左边缘开始画,如果你想让文本居中,需要自己算偏移:(viewWidth - layout.getLineWidth(i)) / 2。
注意getLineEnd(i)返回的是该行最后一个字符的索引加一,所以subSequence(lineStart, lineEnd)正好是该行的文本。但有个细节:如果该行末尾有换行符,getLineEnd可能包含换行符,绘制时换行符不会显示,但会影响宽度计算。一般不用特殊处理,因为换行符宽度为0。
5.3 处理点击事件与行号映射
如果自定义View需要响应点击,比如点击某一行跳转,就需要把点击坐标映射到行号。StaticLayout提供了getLineForVertical(y)方法,传入Y坐标返回行号。然后可以用getOffsetForHorizontal(line, x)拿到该行内具体字符的偏移。
这两个方法配合,就能实现精确的字符级点击定位。比如做阅读器时,点击某个字弹出翻译,就需要这个能力。
注意getLineForVertical的y坐标是相对于布局顶部的,如果你的View有padding,要先减去padding。另外,如果点击位置在文本块之外,返回值可能不准确,需要先判断y是否在0到layout.getHeight()之间。
6. 那些年我在文本渲染上踩过的坑
6.1 行高不一致导致的跳动问题
有一次做聊天界面,消息气泡里的文本用自定义View绘制。测试发现,同样字号的中文和英文混排时,行高不一致,导致气泡高度忽高忽低。排查后发现,英文的FontMetrics和中文不同,includepad=false时,英文行的实际高度比中文行小,因为英文没有中文那种“下方留白”。
解决方案是统一用includepad=true,并且在计算气泡高度时用layout.getHeight()而不是自己累加行高。getHeight()返回的是整个布局的精确高度,包含了所有行的实际占用。
6.2 省略号与maxLines配合时的边界情况
另一个坑是省略号和maxLines的配合。我设了maxLines=2、ellipsize=END,期望文本超过两行时第二行末尾出现省略号。但实际测试发现,如果第二行刚好能放下所有剩余文本,省略号不出现;如果放不下,省略号出现,但有时候第二行会变成空白,省略号单独占一行。
原因是:当剩余文本的宽度刚好超过第二行可用宽度一点点时,断行算法会把最后一个字符移到第三行,但maxLines=2限制了只显示两行,于是第二行末尾加省略号,而那个被移走的字符就不显示了。如果剩余文本只有一个字符,且这个字符放不下,就会出现第二行只有省略号的情况。
解决办法是:适当增加ellipsizedWidth,给省略号留出更多空间,或者调整文本内容。这个没有完美的通用方案,只能根据实际场景调。
6.3 频繁创建StaticLayout带来的性能问题
前面提过,在onDraw里创建StaticLayout是性能杀手。但还有一种情况:在onMeasure里创建。如果View的测量频繁触发(比如在ScrollView里),每次测量都创建StaticLayout,也会卡。
正确的做法是:只在文本或宽度变化时创建。可以用一个标记记录上次的文本和宽度,如果没变就复用。另外,StaticLayout本身有reflow方法可以在宽度变化时重新布局,比重新创建稍快,但差别不大。
实测数据:一段500字的中文文本,创建StaticLayout大约耗时2到5毫秒(中端机)。如果每帧都创建,60帧就是120到300毫秒,必然掉帧。缓存后,每帧只做绘制,耗时降到1毫秒以内。
6.4 字体缩放与系统设置的影响
Android系统允许用户调整字体大小(在设置里改“字体大小”),这会影响TextPaint的textSize。如果你的自定义View里硬编码了字号,用户调整系统字体后,你的文本不会变,但TextView里的文本会变,导致界面不一致。
解决方案是:用TypedValue.applyDimension配合COMPLEX_UNIT_SP来设置字号,这样会跟随系统字体缩放。或者监听Configuration变化,在onConfigurationChanged里重新创建StaticLayout。
还有一个隐藏问题:系统字体缩放后,Paint的FontMetrics也会变,之前缓存的行高数据可能失效。所以字体变化时,缓存必须重建。
7. 进阶:StaticLayout与Spannable的配合
7.1 Span如何影响测量结果
Spannable是Android里给文本加样式的机制,比如粗体、斜体、下划线、超链接、自定义背景等。当StaticLayout的source是Spannable时,它会遍历所有Span,根据Span类型调整测量。
比如StyleSpan(BOLD)会让对应文本用粗体测量,宽度比普通字体大。RelativeSizeSpan(1.5f)会让字号变大,行高也可能变。ImageSpan会插入一张图片,宽度是图片宽度,高度可能超过文本行高。
这些Span在测量时都会被考虑进去,所以StaticLayout算出来的行高和行宽是包含了Span效果的。这也是为什么自己测量很难做对——你得手动处理每一种Span。
7.2 自定义Span的测量回调
如果内置Span不够用,可以自定义Span。自定义Span如果要影响测量,需要实现MetricAffectingSpan接口,在updateMeasureState方法里修改TextPaint。比如做一个“字间距Span”,在updateMeasureState里设置paint.setLetterSpacing()。
注意updateMeasureState里修改的Paint是测量用的,绘制时还会调用updateDrawState,那里修改的是绘制用的Paint。两个方法要配合好,否则可能出现测量宽度和实际绘制宽度不一致的情况。
7.3 实测:Span对断行位置的影响
我做过一个测试:一段文本,前半段普通,后半段加粗。不加粗时,断行位置在某个字之后;加粗后,由于后半段变宽,断行位置提前了。这说明Span确实会影响断行。
这个特性在做富文本编辑器时很重要。用户加粗一段文字后,后面的文字可能被挤到下一行,布局会变化。如果你自己测量,必须重新测量所有受影响的文本,而StaticLayout会自动处理。
8. 性能优化与缓存策略
8.1 什么情况下应该复用StaticLayout
复用StaticLayout的条件是:文本内容不变、布局宽度不变、Paint配置不变。只要这三个有一个变了,就得重建。
实际开发中,最常见的复用场景是:列表项里的文本、静态页面的段落、聊天记录里的历史消息。这些内容一旦确定就不会变,可以放心缓存。
如果文本会变但变化不频繁(比如用户输入),可以用DynamicLayout,它内部做了增量更新。但DynamicLayout的增量更新也有开销,如果变化太频繁,可能还不如重建StaticLayout。
8.2 预测量与延迟布局的取舍
有时候View还没测量出宽度,但你已经想提前算好布局。这时候可以用一个预估宽度先创建StaticLayout,等实际宽度出来后再重建。但预估宽度不准的话,重建开销就白费了。
另一种策略是延迟布局:在onSizeChanged里才创建StaticLayout,之前不创建。这样onDraw里如果layout为null就直接返回,等宽度确定后再画。这个策略适合宽度不确定的场景。
8.3 长文本的分段布局思路
如果文本非常长(比如几万字的小说),一次性创建StaticLayout会占用大量内存,而且创建耗时长。这时候可以分段布局:只对当前可见区域附近的文本创建StaticLayout,滚动时动态加载。
分段的关键是确定每段的起止位置。可以按段落分,也可以按固定字数分。每段独立创建StaticLayout,绘制时根据滚动偏移决定画哪几段。这个方案实现复杂,但对付长文本很有效。
9. 一些实用的调试技巧
9.1 用getLineTop和getLineBottom验证行边界
调试文本布局时,可以在onDraw里把每一行的top和bottom用不同颜色的线画出来,直观看到行边界。代码很简单:
for (int i = 0; i < layout.getLineCount(); i++) { canvas.drawLine(0, layout.getLineTop(i), getWidth(), layout.getLineTop(i), debugPaint); canvas.drawLine(0, layout.getLineBottom(i), getWidth(), layout.getLineBottom(i), debugPaint); }这样一眼就能看出行高是否均匀、是否有异常留白。
9.2 对比TextView与自定义View的渲染结果
如果自定义View的渲染和TextView不一致,可以把两者并排放在同一个界面里,用同样的文本和Paint配置,对比差异。常见差异点包括:行高、首行顶部留白、末行底部留白、省略号位置。
对比时注意,TextView可能有额外的padding或includeFontPadding设置,要确保两者配置一致。
9.3 常见异常与对应排查方向
| 异常现象 | 可能原因 | 排查方向 |
|---|---|---|
| 行高不均匀 | includepad不一致或Span影响 | 检查includepad和Span |
| 省略号位置不对 | ellipsizedWidth与width不一致 | 统一两者或调整截断策略 |
| 文本被截断 | maxLines太小或宽度不够 | 检查maxLines和布局宽度 |
| 中文断行错误 | 标点避头尾导致 | 检查文本标点,必要时预处理 |
| 性能卡顿 | StaticLayout频繁创建 | 检查是否缓存,是否在onDraw里创建 |
这些是我在实际项目中遇到过的典型问题,大部分都能通过检查配置和缓存策略解决。文本渲染看起来简单,但细节极多,多调试、多对比是唯一的办法。