This page looks best with JavaScript enabled

TextView 的布局细节

 ·  ☕ 5 min read

TextView 在布局方面提供了一些特别的属性来控制文本的布局。比如现在要显示一个句子 What is a good time for you?。 TextView 在发现一行布局不足以显示整个文本内容时,会进行自动的换行。

长文本

那么 TextView 是如何知道或者说它是如何做到自动换行的呢? 这就要涉及下图所示的内容。

概要

Layout

TextView Layout

官方介绍: android.text.Layout 是一个管理屏幕上文本布局的基类。在 TextView 内部,对于会编辑的文本(EditText),会使用 DynamicLayout ,它会随着文本的更改而更新;对于不会更改的文本,会使用 StaticLayoutBroingLayout

TextView Layout 创建流程

getLineXXX

由于文本可能会被显示为多行,所以 Layout 提供了一系列获取每一行文本具体位置的方法。通过 getLineCount() 可以获取一个 Layout 包含了多少行文本,然后通过一系列 getLineXXX(int line) 方法可以获取每行的具体参数。

方法 作用
getLineTop 当前行文本区域的最顶部在屏幕y方向的位置
getLineBottom 当前行文本区域的最底部在屏幕y方向的位置, 会包含实际的行间距X(最后一行不会包含)
getLineBaseline 当前行文字的 baseline
getLineStart 绘制在当前行的第一个字符在所有字符中的索引
getLineEnd 绘制在当前行的最后一个字符在所有字符中的索引
getLineLeft 绘制在当前行的第一个字符在屏幕x方向的位置
getLineRight 绘制在当前行的最后一个字符在屏幕x方向的位置
getLineAscent 获取字体头部的额外高度,相对于 Bottom 的偏移
getLineDescent 获取字体底部相对于 Bottom 的偏移
getPrimaryHorizontal 获取文本中某个字符在水平方向上的x位置

自己尝试画这些线:

1
2
3
4
5
6
7
repeat(layout.lineCount) {
   drawLine(canvas, Color.RED, layout.getLineTop(it), "top")
   drawLine(canvas, Color.YELLOW, layout.getLineAscent(it) + layout.getLineBottom(it), "ascent")
   drawLine(canvas, Color.BLUE, layout.getLineBaseline(it), "baseline")
   drawLine(canvas, Color.GREEN, layout.getLineBottom(it), "bottom")
   drawLine(canvas, Color.BLACK, layout.getLineDescent(it) + layout.getLineBottom(it), "descent")
}
单行 多行

FontMetricsInt

与文本布局有关的还有一个数据类,FontMetricsInt,它封装了一系列字体相对与 baseline 的偏移量。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public static class FontMetricsInt {
   /**
    * The maximum distance above the baseline for the tallest glyph in
    * the font at a given text size.
    */
   public int   top;
   /**
    * The recommended distance above the baseline for singled spaced text.
    */
   public int   ascent;
   /**
    * The recommended distance below the baseline for singled spaced text.
    */
   public int   descent;
   /**
    * The maximum distance below the baseline for the lowest glyph in
    * the font at a given text size.
    */
   public int   bottom;
   /**
    * The recommended additional space to add between lines of text.
    */
   public int   leading;
}

实验

通过继承一个 TextView,可以方便的在布局和绘制相关方法中输出当前 TextView 使用的 Layout。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
class MTv @JvmOverloads constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int = 0) :
    androidx.appcompat.widget.AppCompatTextView(context, attrs, defStyleAttr) {

    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec)
        Log.d(TAG, "[onMeasure] $layout")
    }

    override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
        super.onLayout(changed, left, top, right, bottom)
        Log.d(TAG, "[onLayout] $layout")
    }

    override fun onDraw(canvas: Canvas) {
        Log.d(TAG, "[onDraw] $layout")
    }
}

然后做以下实验,让 TextView 分别显示长字符串和短字符串,以及使用 EditText。

  • 当文本为 What is a good time for you? 时,输出:

    StaticLayout

    D/TAG: [onMeasure] android.text.StaticLayout@b677650
    D/TAG: [onMeasure] android.text.StaticLayout@b677650
    D/TAG: [onMeasure] android.text.StaticLayout@19ea28b
    D/TAG: [onMeasure] android.text.StaticLayout@19ea28b
    D/TAG: [onLayout] android.text.StaticLayout@19ea28b
    D/TAG: [onDraw] android.text.StaticLayout@19ea28b
    

    会发现在整体的绘制过程中,创建了两个 StaticLayout 对象(b677650 和 19ea28b)。

  • 当文本为较短的 a good time 时,输出:

    BoringLayout

    D/TAG: [onMeasure] android.text.BoringLayout@fb6605a
    D/TAG: [onMeasure] android.text.BoringLayout@fb6605a
    D/TAG: [onMeasure] android.text.BoringLayout@fb6605a
    D/TAG: [onMeasure] android.text.BoringLayout@fb6605a
    D/TAG: [onLayout] android.text.BoringLayout@fb6605a
    D/TAG: [onDraw] android.text.BoringLayout@fb6605a
    

    因为没有涉及换行的操作,所以在整体的绘制过程中,创建了一个 BoringLayout 对象。

  • 使用 EditText 时:

    DynamicLayout

    D/TAG: [onMeasure] android.text.DynamicLayout@aa9b261
    D/TAG: [onMeasure] android.text.DynamicLayout@aa9b261
    D/TAG: [onMeasure] android.text.DynamicLayout@c1d4c99
    D/TAG: [onMeasure] android.text.DynamicLayout@c1d4c99
    D/TAG: [onLayout] android.text.DynamicLayout@c1d4c99
    D/TAG: [onDraw] android.text.DynamicLayout@c1d4c99
    

    会发现在整体的绘制过程中,创建了两个 DynamicLayout 对象(aa9b261 和 c1d4c99)

通过以上实验也部分验证了官方的说法,但是官方对 Layout 的介绍中没有提及 BoringLayout 和 StaticLayout 的区别。于是需要去他们各自的文档中去详细了解。

BoringLayout

官方介绍:BoringLayout 是一种非常简单的 Layout 实现,用于从左到右的单行文本。你可能永远都不想自己做一个。 如果这样做,请确保首先调用 isBoring(CharSequence,TextPaint) 以确保文本符合条件。

所以使用 BoringLayout 的场景是:

  • 单行文本
  • 从左到右:TextView 有个 textDirection 属性,可以控制文本的显示方向。ltr 就表示从左到右

isBoring

上面提到过判断文本是否符合使用 BoringLayout 的条件,可以通过调用 isBoring 方法。

1
2
3
4
//Returns null if not boring; the width, ascent, and descent if boring.
public static Metrics isBoring(CharSequence text, TextPaint paint) {
    return isBoring(text, paint, TextDirectionHeuristics.FIRSTSTRONG_LTR, null);
}

isBoring 方法会返回一个 BoringLayout.Metrics 对象,如果返回了 null,则说明目标文本不符合使用 BoringLayout 的条件。

1
2
3
4
5
6
7
8
public static Metrics isBoring(CharSequence text, TextPaint paint, TextDirectionHeuristic textDir, Metrics metrics) {
   final int textLength = text.length();
   //1
   //2
   //3
   //4
   return fm;
}
  1. 判断文本中是否存在一些特殊字符

    1
    2
    3
    
    if (hasAnyInterestingChars(text, textLength)) {
       return null;  // There are some interesting characters. Not boring.
    }
    
  2. 判断是不是从右到左的方向

    1
    2
    3
    
    if (textDir != null && textDir.isRtl(text, 0, textLength)) {
       return null;  // The heuristic considers the whole text RTL. Not boring.
    }
    
  3. 判断有没有存在 ParagraphStyle

    1
    2
    3
    4
    5
    6
    7
    
    if (text instanceof Spanned) {
       Spanned sp = (Spanned) text;
       Object[] styles = sp.getSpans(0, textLength, ParagraphStyle.class);
       if (styles.length > 0) {
          return null;  // There are some PargraphStyle spans. Not boring.
       }
    }
    
  4. 以上条件都通过之后,就会创建布局相关的参数

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    
    Metrics fm = metrics;
    if (fm == null) {
        fm = new Metrics();
    } else {
        fm.reset();
    }
    
    TextLine line = TextLine.obtain(); // 从 TextLine 对象池复用一个 TextLine
    // 将参数注入 TextLine
    line.set(paint, text, 0, textLength, Layout.DIR_LEFT_TO_RIGHT, Layout.DIRS_ALL_LEFT_TO_RIGHT, false,null);
    // 将经过测量之后的文本将要占用的宽赋值到 BoringLayout.Metrics#width
    fm.width = (int) Math.ceil(line.metrics(fm));
    TextLine.recycle(line); // 对象回收进池子
    

TextLine

TextLine 代表一行样式文本,以视觉顺序进行度量,用于渲染。通俗说就是它负责对单行的文本进行宽度测量。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
public void set(TextPaint paint, CharSequence text, int start, int limit, int dir, Directions directions, boolean hasTabs, TabStops tabStops) {
   mPaint = paint;
   mText = text;
   mStart = start;
   mLen = limit - start;
   mDir = dir;
   mDirections = directions;
   if (mDirections == null) {
       throw new IllegalArgumentException("Directions cannot be null");
   }
   mHasTabs = hasTabs;
   mSpanned = null;
   //...
}
1
2
3
4
public float metrics(FontMetricsInt fmi) {
   //对于 BoringLayout, mLen 就是文本的长度
    return measure(mLen, false, fmi);
}

measure 最终会调用到 TextPaint#getRunAdvance 去获取文本占用的宽。对于 TextPaint 如何去测量文本占用的宽,这里不再深入。其对应的 native 实现在 Paint.cpp 中。

StaticLayout

StaticLayout 中主要做了测量文本需要多少行的工作。

在测量过程中会对 StaticLayout 中的成员变量 mLineCount 进行 ++ 操作。

lineCount

DynamicLayout

//TODO

lineSpacingExtra

每行文本之间的间隔高度(不会应用到最后一行)

与其相关的还有一个属性是 lineSpacingMultiplier, 它代表希望应用到默认行高的倍数。

TextView 某一行的行高计算:

1
2
3
public int getLineHeight() {
    return FastMath.round(mTextPaint.getFontMetricsInt(null) * mSpacingMult + mSpacingAdd);
}
  • mTextPaint.getFontMetricsInt(null) 就是默认行高,是一个根据具体字体和 textSize 属性计算出来的值
  • mSpacingAdd 就是 lineSpacingExtra

=> 实际行高 = 默认行高 * lineSpaceMultiplier + lineSpaceExtra

实例

  • 第一个数字是 lineSpaceExtra,第二个数字是 lineSpaceMultiplier
  • 红色是 TOP 线,黑色是 BOTTOM 线
default 0 0 100 0
default 0 0 100 0
100 3 100 1 100 0.5
100 3 100 1 100 0.5

运用

知道了 TextView 中 3 种 Layout 的存在之后,就能在需要的时候直接利用 Layout 提前测量文本的布局信息。

  • 使用 BoringLayout

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    
    fun measureTextByBoringLayout(width: Int, paint: TextPaint, text: String) {
        val layout = BoringLayout(
            text,
            paint,
            width,
            Layout.Alignment.ALIGN_NORMAL,
            0f,
            0f,
            BoringLayout.Metrics(),
            true
        )
    }
    
  • 使用 StaticLayout

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    
    fun measureTextByStaticLayout(width: Int, paint: TextPaint, text: String): ArrayList<Pair<Int, Int>> {
        val layout = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
            val builder = StaticLayout.Builder.obtain(
                text,
                0,
                text.length,
                paint,
                width
            )
            builder.build()
        } else {
            StaticLayout(
                text,
                0,
                text.length,
                paint,
                width,
                Layout.Alignment.ALIGN_NORMAL,
                0f,
                0f,
                true
            )
        }
    }
    
  • DynamicLayout

    1
    
    //TODO
    

总结

  • TextView 的布局是代理给 Layout 的 3 个子类来完成的
  • 利用 3 种 Layout,可以提前测量文本是否会存在换行的情况,也能提前拿到文本的最终布局信息
  • 在涉及会分配大量对象的场景时,Android SDK 内广泛采用了对象池方法来避免创建大量对象。
    • TextLine, obtain recycle
    • StaticLayot.Builder, obtain recycle
    • DynamicLayout.Builder, obtain recycle

Yang
WRITTEN BY
Yang
Developer