Android TextView Layout创建机制:BoringLayout、StaticLayout与DynamicLayout源码解析
1. 为什么值得深挖 TextView 的 Layout 创建过程很多做 Android 开发的朋友日常写业务代码时对TextView的态度基本是“拿来就用”setText、setTextSize、setGravity顶多再调个setLineSpacing。一旦遇到文本测量不准、getLineCount返回 0、getLineTop越界、StaticLayout和BoringLayout傻傻分不清或者自定义 View 里想在onDraw阶段精确控制每一行文字的位置就开始抓瞎了。我自己就踩过这个坑在一个图文混排的阅读器里需要在文字流中插入自定义的装饰元素结果发现TextView的getLayout()在onMeasure之前返回null导致整个排版逻辑崩掉。TextView的 Layout 创建过程说白了就是 Android 把一串CharSequence变成屏幕上可绘制、可测量、可定位的“行”的过程。这个过程的产物就是Layout对象抽象类具体实现主要有三个BoringLayout、StaticLayout和DynamicLayout。它们各自负责不同复杂度的文本场景选错了实现类轻则性能浪费重则显示错乱。理解这套机制能帮你解决三类问题第一自定义 View 里手动做文本排版第二排查TextView测量和绘制相关的诡异 bug第三在需要极致性能的场景下比如长列表里的富文本做出正确的取舍。这篇文章适合谁看如果你已经写过一些 Android 自定义 View对onMeasure、onDraw有基本概念但每次碰到文本排版就绕道走那这篇就是写给你的。如果你只是想搞清楚TextView内部到底怎么把字排到屏幕上的也能从这里拿到完整的链路。我会从整体设计思路讲起然后逐个拆解makeNewLayout、makeSingleLayout这些核心方法再给出可复现的实操代码和排查技巧。全程按我实际读 AOSP 源码和做项目时的理解来讲不堆砌 API 文档。2. 整体设计思路TextView 如何决定用哪种 Layout2.1 三种 Layout 实现的分工逻辑Android 在文本排版这件事上做了一个很务实的取舍不是所有文本都需要“动态更新 复杂样式 双向文字”的全套能力。如果一段文本是纯 ASCII、单行、没有 Span那用最轻量的BoringLayout就够了如果文本有多行、有换行、有 Span但内容不会频繁变化StaticLayout是首选只有当文本需要动态追加、删除并且要实时反映到排版上时才动用DynamicLayout。这个分工背后的逻辑是性能。BoringLayout的名字直译就是“无聊的布局”它假设文本是单行、方向从左到右、没有复杂换行所以它只需要测量一次宽度和基线位置后续的getLineTop、getLineBottom都是常量计算几乎没有额外开销。StaticLayout则需要在构造时把整个文本按换行规则切成行计算每一行的起始偏移、宽度、基线、行高这些结果在构造完成后就固定了。DynamicLayout在StaticLayout的基础上增加了一层“可编辑”的机制它内部维护了一个SpanWatcher和TextWatcher当文本变化时会重新计算受影响的行而不是全量重建。我个人的经验是如果你在自定义 View 里只是展示一段静态文本优先考虑StaticLayout它的 API 稳定、行为可预测。DynamicLayout虽然灵活但它的构造和更新逻辑更重而且在某些 Android 版本上对Spannable的处理有细微差异容易踩坑。BoringLayout则适合那些明确知道是单行、纯文本的场景比如列表项里的标题用它可以省下不少测量时间。2.2 从 setText 到 Layout 创建的触发链路很多人以为setText之后 Layout 就立刻创建了其实不是。TextView.setText内部会调用checkForRelayout()这个方法会根据当前状态决定是“请求重新布局”还是“直接重建 Layout”。具体来说如果文本变化前后都是单行且宽度没变它可能只触发invalidate而不重建 Layout如果文本长度变化导致换行数变了就会调用requestLayout()把 Layout 的创建推迟到下一次测量阶段。真正的 Layout 创建发生在onMeasure里。TextView.onMeasure会调用makeNewLayout()而makeNewLayout内部根据文本特征选择调用makeSingleLayout()或makeDynamicLayout()。makeSingleLayout里又会进一步判断如果文本是Spannable且需要动态更新走DynamicLayout否则尝试BoringLayout失败则回退到StaticLayout。这个链路我建议你亲手跟一遍因为很多“为什么我的 Layout 是 null”的问题答案就藏在这个触发时机里。注意getLayout()在onMeasure之前返回 null 是正常行为不要在onDraw之外的地方假设它一定存在。如果你需要在测量前拿到文本尺寸用Paint.measureText或StaticLayout自己构造一个临时对象。2.3 为什么不是所有文本都用 StaticLayout有人可能会问既然StaticLayout能处理多行、Span、双向文字为什么不干脆所有场景都用它答案是性能。StaticLayout的构造过程需要遍历整个文本处理换行符、测量每一行的宽度、计算基线偏移对于长文本来说这个开销不小。而BoringLayout只需要一次测量就能确定所有行的位置因为它假设文本是单行且方向固定。在列表滚动场景下每个 Item 的TextView如果都用StaticLayout累积起来的测量时间会明显影响帧率。另一个原因是内存。DynamicLayout内部会维护行映射表和 Span 监听器对象体积比StaticLayout大。如果只是展示静态内容用DynamicLayout就是浪费。Android 框架层做这个区分本质上是在“通用性”和“效率”之间找平衡。我们自己在自定义 View 里做文本排版时也应该遵循同样的思路先判断文本特征再选最轻量的实现。3. 核心细节解析makeNewLayout 内部到底做了什么3.1 makeSingleLayout 的分支判断与参数计算makeSingleLayout是 Layout 创建的核心入口它的签名大致是这样的基于 AOSP 源码简化private Layout makeSingleLayout(int wantWidth, BoringLayout.Metrics boring, int ellipsisWidth, Layout.Alignment alignment, boolean shouldEllipsize, TextUtils.TruncateAt effectiveEllipsize, boolean useSaved, int spannedEllipsisWidth) { Layout result null; if (mText instanceof Spannable) { result new DynamicLayout(mText, mTransformed, mTextPaint, wantWidth, alignment, mTextDir, mSpacingMult, mSpacingAdd, mIncludePad, mBreakStrategy, mHyphenationFrequency, ...); } if (result null) { result new StaticLayout(mText, mTransformed, mTextPaint, wantWidth, alignment, mTextDir, mSpacingMult, mSpacingAdd, mIncludePad, mBreakStrategy, mHyphenationFrequency, ...); } return result; }实际源码里还有BoringLayout的尝试逻辑但核心分支就是这三层。wantWidth是可用宽度它来自onMeasure里根据MeasureSpec和Padding计算出的值。boring是一个BoringLayout.Metrics对象通过BoringLayout.isBoring()方法得到如果文本是单行、纯文本、方向从左到右这个方法会返回有效的 Metrics否则返回 null。这里有个细节值得注意mTransformed是mText经过TransformationMethod处理后的结果。比如你设置了PasswordTransformationMethodmTransformed就是一堆圆点而mText还是原始密码。Layout 创建用的是mTransformed所以如果你在自定义 View 里直接拿mText去构造 Layout可能会和TextView的显示不一致。这个坑我在做密码输入框的自定义样式时踩过排查了半天才发现是 Transformation 的问题。3.2 BoringLayout 的适用条件与回退机制BoringLayout的适用条件非常严格我把它总结成一张表条件要求文本行数单行文本方向从左到右LTR字符类型不包含需要复杂换行的字符如换行符、制表符Span不包含影响排版的 Span如RelativeSizeSpan改变字号省略号如果设置了ellipsize需要额外处理当这些条件都满足时BoringLayout.isBoring()会返回一个Metrics对象里面包含宽度、基线、行高。然后makeSingleLayout会尝试用这个 Metrics 构造BoringLayout。如果构造成功后续的getLineTop、getLineBottom都是直接返回预计算的值速度极快。但如果文本包含换行符或者有RelativeSizeSpan导致某段文字字号不同isBoring就会返回 null此时回退到StaticLayout。这个回退是自动的开发者不需要手动干预。不过要注意BoringLayout对ellipsize的支持有限如果你设置了ellipsize且文本需要截断即使满足单行条件也可能走StaticLayout。我在做一个单行标题的列表时发现加了ellipsizeend之后性能反而下降了后来查源码才确认是回退到了StaticLayout。3.3 StaticLayout 的构造过程与换行计算StaticLayout的构造是 Layout 创建里最复杂的一环。它内部会调用StaticLayout.Builder然后通过MeasuredText和LineBreaker来做换行计算。整个过程大致分三步第一步把CharSequence转换成MeasuredText。MeasuredText是 Android 用来做文本测量的中间结构它会遍历每个字符根据TextPaint的字体信息计算每个字符的宽度同时记录 Span 的影响。这一步的耗时和文本长度成正比。第二步用LineBreaker根据breakStrategy和hyphenationFrequency决定在哪里换行。breakStrategy有三个值SIMPLE、HIGH_QUALITY、BALANCED。SIMPLE就是简单的贪心算法遇到放不下的字符就换行HIGH_QUALITY会做更复杂的优化尽量让每行宽度均匀BALANCED则是在HIGH_QUALITY基础上进一步平衡行宽。默认值是SIMPLE如果你做阅读类应用可以考虑改成HIGH_QUALITY但要注意性能开销。第三步根据换行结果计算每一行的start、end、top、bottom、baseline并存入内部的mLineDirections和mLines数组。这些数据在构造完成后就固定了后续的getLineTop等方法只是查表。提示StaticLayout的构造过程是线程安全的你可以在子线程里预先构造好 Layout然后 post 到主线程使用。这在做长文本预加载时很有用。3.4 DynamicLayout 的动态更新机制DynamicLayout继承自StaticLayout但它增加了一个DynamicLayout.Builder和内部的ChangeWatcher。当你把文本改成Spannable并调用setText时TextView会优先尝试DynamicLayout。DynamicLayout内部会注册一个SpanWatcher当 Span 发生变化比如ForegroundColorSpan被添加或移除时它会触发reflow操作重新计算受影响的行。这个机制的代价是内存和 CPU。DynamicLayout需要维护一个mInts数组来记录每行的信息还需要在文本变化时做差异计算。如果你的文本只是静态展示完全没必要用DynamicLayout。我见过一些项目为了“保险”把所有TextView都设成Spannable结果列表滚动时掉帧严重后来改成普通String就流畅了。另外DynamicLayout对Spannable的更新是异步的它会在下一次onMeasure或onDraw时生效。如果你在setText之后立刻调用getLayout().getLineCount()拿到的可能还是旧值。这个时序问题在动画场景下特别容易出 bug建议用post或者ViewTreeObserver来确保拿到最新结果。4. 实操过程手动复现 TextView 的 Layout 创建4.1 在自定义 View 中构造 StaticLayout假设我们要做一个自定义的文本展示 View支持多行、居中、带行间距。核心代码如下public class CustomTextView extends View { private StaticLayout mLayout; private TextPaint mPaint; private String mText 这是一段用于测试的文本用来演示 StaticLayout 的构造过程。; public CustomTextView(Context context) { super(context); init(); } private void init() { mPaint new TextPaint(Paint.ANTI_ALIAS_FLAG); mPaint.setTextSize(48); mPaint.setColor(Color.BLACK); } Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { int width MeasureSpec.getSize(widthMeasureSpec); int widthMode MeasureSpec.getMode(widthMeasureSpec); if (widthMode MeasureSpec.UNSPECIFIED) { width (int) mPaint.measureText(mText); } int availableWidth width - getPaddingLeft() - getPaddingRight(); mLayout StaticLayout.Builder .obtain(mText, 0, mText.length(), mPaint, availableWidth) .setAlignment(Layout.Alignment.ALIGN_CENTER) .setLineSpacing(8f, 1.2f) .setIncludePad(true) .build(); int height mLayout.getHeight() getPaddingTop() getPaddingBottom(); setMeasuredDimension(width, height); } Override protected void onDraw(Canvas canvas) { super.onDraw(canvas); canvas.save(); canvas.translate(getPaddingLeft(), getPaddingTop()); mLayout.draw(canvas); canvas.restore(); } }这段代码的关键点在于StaticLayout.Builder.obtain的第三个参数是TextPaint它决定了字体、字号、颜色第四个参数是可用宽度必须减去左右 PaddingsetLineSpacing的第一个参数是额外行距第二个参数是行距倍数。构造完成后getHeight()返回的是所有行的总高度可以直接用来设置 View 的高度。我实测下来这段代码在 API 23 以上都能正常工作。如果你需要在 API 23 以下使用得用new StaticLayout(...)的构造函数参数顺序不一样容易写错建议封装一个兼容方法。4.2 用 BoringLayout 优化单行文本测量如果你的文本确定是单行可以用BoringLayout来省掉换行计算的开销。代码大致如下BoringLayout.Metrics metrics BoringLayout.isBoring(mText, mPaint); if (metrics ! null) { mLayout new BoringLayout(mText, mPaint, availableWidth, Layout.Alignment.ALIGN_NORMAL, 1.0f, 0f, metrics, true, TextUtils.TruncateAt.END, availableWidth); } else { mLayout new StaticLayout(mText, mPaint, availableWidth, Layout.Alignment.ALIGN_NORMAL, 1.0f, 0f, true); }这里BoringLayout.isBoring会返回一个Metrics对象如果文本不满足条件就返回 null。BoringLayout的构造函数最后一个参数是省略号宽度如果不需要省略号可以传 0。注意BoringLayout的getLineCount()永远返回 1如果你在代码里依赖行数做逻辑判断要小心。我在一个列表项里用这个方案替换了原来的StaticLayout单行标题的测量时间从平均 0.3ms 降到了 0.1ms 左右。虽然绝对值不大但在长列表里累积起来还是很可观的。4.3 动态文本场景下的 DynamicLayout 使用如果你需要动态更新文本比如聊天消息里的表情替换可以用DynamicLayoutSpannableString spannable new SpannableString(Hello [smile] World); spannable.setSpan(new ImageSpan(bitmap), 6, 13, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); DynamicLayout layout new DynamicLayout(spannable, mPaint, availableWidth, Layout.Alignment.ALIGN_NORMAL, 1.0f, 0f, true);DynamicLayout的构造函数和StaticLayout类似但它会自动监听Spannable的变化。当你后续修改spannable里的 Span 时layout会在下一次绘制时自动更新。不过要注意DynamicLayout对ImageSpan的支持在某些版本上有问题图片可能不会正确占位。如果遇到这种情况可以改用StaticLayout并手动处理图片位置。注意DynamicLayout的更新不是实时的它依赖TextWatcher的回调。如果你在子线程修改Spannable可能会触发CalledFromWrongThreadException建议在主线程操作。4.4 参数计算如何确定 availableWidth 和行高availableWidth的计算看起来简单但实际项目里经常出错。正确的公式是availableWidth measuredWidth - paddingLeft - paddingRight - compoundPaddingLeft - compoundPaddingRightcompoundPadding是TextView内部用来处理drawableLeft、drawableRight的额外 Padding。如果你在自定义 View 里没有这些 drawable可以忽略。但如果你是从TextView继承并重写onMeasure就必须考虑它们否则文本会被 drawable 挤出去。行高的计算则依赖TextPaint的FontMetricsPaint.FontMetrics fm mPaint.getFontMetrics(); float lineHeight fm.descent - fm.ascent fm.leading;ascent是负值descent是正值leading是行间距。StaticLayout内部会用类似的方式计算每行高度但还会考虑SpacingAdd和SpacingMult。如果你手动计算行高来做点击区域判断建议直接用layout.getLineTop(line)和layout.getLineBottom(line)避免自己算错。5. 常见问题与排查技巧实录5.1 getLayout 返回 null 的三种原因这是被问得最多的问题。getLayout()返回 null 通常有三种情况第一种View 还没有经过测量。getLayout()是在onMeasure里创建的如果你在onCreate或onResume里直接调用肯定拿不到。解决办法是用post或者ViewTreeObserver.OnGlobalLayoutListener。第二种TextView的文本为空。当mText为 null 或空字符串时makeNewLayout可能不会创建 Layout而是直接返回 null。这种情况下getLayout()自然也是 null。第三种TextView被设置了setLayerType或者处于某种特殊状态导致 Layout 被回收。这种情况比较少见但如果你的 View 在RecyclerView里被复用可能会遇到。排查时可以先打印getWidth()和getHeight()如果都是 0说明还没测量如果宽度正常但getLayout()还是 null检查文本是否为空。5.2 文本测量不准的排查思路文本测量不准的表现有很多getLineCount比预期多一行、getLineWidth和实际显示宽度不一致、省略号位置不对。排查时按以下顺序检查检查项可能问题解决方法TextPaint 的 textSize单位是 px 还是 sp用TypedValue.applyDimension转换availableWidth是否减去了所有 Padding打印实际值对比breakStrategy默认 SIMPLE 可能导致断行位置不理想改成 HIGH_QUALITY 试试includePad是否包含字体上下 Padding根据需求设置 true/falseTransformationMethod是否影响了 mTransformed检查getTransformationMethod我遇到过一次getLineCount返回 0 的情况最后发现是TextPaint的textSize设成了 0导致所有字符宽度都是 0Layout 认为没有内容。这种低级错误在自定义 View 里很常见建议在构造 Layout 之前先断言textSize 0。5.3 性能优化避免重复创建 LayoutLayout的创建是有成本的尤其是在onMeasure里频繁调用。如果你在onMeasure里每次都重新构造StaticLayout列表滚动时就会掉帧。优化的思路是缓存只有当文本、宽度、Paint 属性发生变化时才重建。private void ensureLayout(int width) { if (mLayout ! null mLastWidth width mLastText.equals(mText)) { return; } mLayout StaticLayout.Builder.obtain(mText, 0, mText.length(), mPaint, width) .build(); mLastWidth width; mLastText mText; }这个缓存策略在RecyclerView的 Item 里特别有效。我实测过加上缓存后快速滚动时的测量耗时降低了 60% 以上。注意mLastText要用equals比较不要用因为String可能是新对象。5.4 省略号与 maxLines 的交互问题ellipsize和maxLines一起使用时TextView的行为有时不符合预期。比如你设置了maxLines2和ellipsizeEND但文本只有一行省略号不会显示如果文本有三行第二行末尾会显示省略号。这个逻辑是StaticLayout在构造时通过getEllipsisCount处理的。如果你在自定义 View 里手动实现省略号要注意TextUtils.ellipsize的用法CharSequence ellipsized TextUtils.ellipsize(mText, mPaint, availableWidth, TextUtils.TruncateAt.END);这个方法会返回截断后的文本但不会处理多行。多行省略号需要自己计算每行能放多少字符比较麻烦。我的建议是尽量用TextView自带的ellipsize除非你有特殊需求。6. 从源码到实战的几点个人体会读TextView的 Layout 创建源码最大的收获不是记住了多少方法名而是理解了 Android 在文本排版上的设计哲学用最合适的工具做最合适的事。BoringLayout、StaticLayout、DynamicLayout这三层结构本质上是在性能、灵活性和功能之间做权衡。我们在做自定义 View 时也应该先问自己这段文本是静态的还是动态的是单行还是多行有没有 Span回答完这几个问题选型就清楚了。另一个体会是时序的重要性。getLayout()返回 null、getLineCount拿到旧值、DynamicLayout更新延迟这些问题归根结底都是时序问题。解决时序问题的通用思路是不要假设某个对象在某个时刻一定存在或一定是最新的用回调、监听器或者post来确保时机正确。最后分享一个小技巧如果你在排查 Layout 相关的问题可以在onDraw里把layout.getLineTop(i)和layout.getLineBottom(i)画出来用不同颜色的线标记每一行的边界。这样能直观地看到 Layout 认为的行位置和实际显示是否一致比看日志快得多。我在做图文混排时用这个方法定位了好几个基线偏移的 bug。这个内容后续还可以这样扩展研究TextPaint的字体加载机制或者深入LineBreaker的换行算法甚至自己实现一个简化版的Layout类来加深理解。文本排版是 Android 里少有的“看起来简单、做起来深不见底”的领域值得花时间啃一啃。