高度为wrap_content的TextView内容居然显示不全?

时间:2022-03-01 17:17:21

转载请注明出处:https://blog.csdn.net/hjf_huangjinfu/article/details/79813172



概述

        最近碰到一个bug,花了很大力气才搞定,所以值得写一篇文章来纪念一下。

        备注:用于分析的源码版本为 android-25


Bug情况

        根据我们实际的应用场景,编写了一个可以复现该 bug 的 demo。

核心代码如下:

public class MainActivity extends AppCompatActivity {

	private TextView mTextView;
	private ImageView mImageView;

	/**
	 * 最后一条内容必须有严格限制,就是在 {@link MainActivity#mImageView} 不显示的时候,2行能够显示下,在{@link MainActivity#mImageView}
	 * 显示的时候,需要3行。手机分辨率不一样,可能导致该Bug无法复现。
	 */
	private String[] mData = new String[]{
			"Supplies of food are almost exhausted.",
			"She refused to be intimidated by their threats.",
			"The stock price of J-Stream rose by the daily limit the next day.",
			"Drawing on their practical experience, they designed an air-cooled diesel motor."
	};
	private int mIndex = 0;

	@Override
	protected void onCreate(Bundle savedInstanceState) {
		super.onCreate(savedInstanceState);
		setContentView(R.layout.activity_main);

		mTextView = (TextView) findViewById(R.id.tv);
		mImageView = (ImageView) findViewById(R.id.iv);
	}

	public void change(View view) {
		mTextView.setText(mData[mIndex]);

		if (mIndex % 2 == 1) {
			mImageView.setVisibility(View.VISIBLE);
		} else {
			mImageView.setVisibility(View.GONE);
		}

		mIndex++;
		if (mIndex == mData.length) {
			mIndex = 0;
		}
	}
}

布局文件如下:

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:background="#5555ff00"
        android:orientation="horizontal">

        <TextView
            android:id="@+id/tv"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:background="#33ff0000"
            android:textSize="18dp" />

        <ImageView
            android:id="@+id/iv"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginLeft="12dp"
            android:src="@mipmap/ic_launcher" />
    </LinearLayout>

    <Button
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_alignParentBottom="true"
        android:onClick="change"
        android:text="change" />
</RelativeLayout>
        代码大概意思是,我们点击屏幕下方按钮,文本框( 被重用的TextView对象)会轮流显示我们预设定的内容( mData ),然后图标会交替显示,也就是 『隐藏-显示-隐藏-显示』 这样。然后当显示到最后一条数据的时候,也就是 mIndex = mData.length - 1 的时候,我们期待的运行结果是这样的:

高度为wrap_content的TextView内容居然显示不全?
但是,实际运行结果却是这样的:

高度为wrap_content的TextView内容居然显示不全?


开启痛苦的Debug之旅

第一反应

        嗯,一定是 TextView 的高度算错了,又是国产Rom的Bug,哈哈,开个玩笑。

        因为 TextView 的高度为 wrap_content,所以按道理来说不应该啊,通过 Log 追踪了 TextView 的测量高度后,发现,在文本只有2行的时候,测量出的高度为 136,而出现 Bug 的场景下,测出来的高度却是 199,也就是3行的高度,也是没问题的啊。好,再通过Log验证一下外层 LinearLayout 的高度,为 144。所以结论就是:TextView 的高度没有问题,显示不全,是因为外层的 LinearLayout 的高度不够。


那么为什么 LinearLayout 的高度不够呢

        从布局文件来看,LinearLayout 的高度也是 wrap_content 啊,那么也不应该啊。继续打Log,内容如下:

04-04 10:49:04.010 27091-27091/cn.hjf.scrollmeasuretest E/O_O: LinearLayout requestLayout()
04-04 10:49:04.021 27091-27091/cn.hjf.scrollmeasuretest E/O_O: LinearLayout before onMeasure
04-04 10:49:04.021 27091-27091/cn.hjf.scrollmeasuretest E/O_O: LinearLayout after onMeasure
04-04 10:49:04.021 27091-27091/cn.hjf.scrollmeasuretest E/O_O:         getMeasuredHeight : 144
04-04 10:49:04.022 27091-27091/cn.hjf.scrollmeasuretest E/O_O: LinearLayout before onMeasure
04-04 10:49:04.022 27091-27091/cn.hjf.scrollmeasuretest E/O_O: LinearLayout after onMeasure
04-04 10:49:04.022 27091-27091/cn.hjf.scrollmeasuretest E/O_O:         getMeasuredHeight : 144
04-04 10:49:04.022 27091-27091/cn.hjf.scrollmeasuretest E/O_O: LinearLayout before onLayout
04-04 10:49:04.022 27091-27091/cn.hjf.scrollmeasuretest W/O_O: TextView before onMeasure
04-04 10:49:04.023 27091-27091/cn.hjf.scrollmeasuretest W/O_O: TextView after onMeasure
04-04 10:49:04.023 27091-27091/cn.hjf.scrollmeasuretest W/O_O:         getMeasuredHeight : 199
04-04 10:49:04.023 27091-27091/cn.hjf.scrollmeasuretest E/O_O: LinearLayout after onLayout

        我们发现了下面的一些细节,在 LinearLayout 的 measure 过程中,并没有调用 TextView 的 onMeasure,反而是在 LinearLayout 的 layout 流程中,触发了 TextView 的测量。所以我们看一下究竟发生了什么。

        我们先看为什么会在 layout 过程中触发 TextView 的测量,下面是部分源代码:

	public void layout(int l, int t, int r, int b) {
		if ((mPrivateFlags3 & PFLAG3_MEASURE_NEEDED_BEFORE_LAYOUT) != 0) {
			onMeasure(mOldWidthMeasureSpec, mOldHeightMeasureSpec);
			mPrivateFlags3 &= ~PFLAG3_MEASURE_NEEDED_BEFORE_LAYOUT;
		}

		........
		........
	}
        从代码可知,PFLAG3_MEASURE_NEEDED_BEFORE_LAYOUT 标志位被设置时,就会在 layout 的时候被测量。

    /**
     * Flag indicating that a call to measure() was skipped and should be done
     * instead when layout() is invoked.
     */
    static final int PFLAG3_MEASURE_NEEDED_BEFORE_LAYOUT = 0x8;
        该标志位也就是说,onMeasure 方法不会在 measure 流程中执行,而是会在 layout 过程中执行。

        那么再看一下,该标志位什么时候被设置的,搜索代码,发现是在 measure 方法中被设置的,部分 measure 方法源码如下:

	public final void measure(int widthMeasureSpec, int heightMeasureSpec) {
		........

		// Suppress sign extension for the low bytes
		long key = (long) widthMeasureSpec << 32 | (long) heightMeasureSpec & 0xffffffffL;
		if (mMeasureCache == null) mMeasureCache = new LongSparseLongArray(2);

		final boolean forceLayout = (mPrivateFlags & PFLAG_FORCE_LAYOUT) == PFLAG_FORCE_LAYOUT;

		// Optimize layout by avoiding an extra EXACTLY pass when the view is
		// already measured as the correct size. In API 23 and below, this
		// extra pass is required to make LinearLayout re-distribute weight.
		final boolean specChanged = widthMeasureSpec != mOldWidthMeasureSpec
				|| heightMeasureSpec != mOldHeightMeasureSpec;
		final boolean isSpecExactly = MeasureSpec.getMode(widthMeasureSpec) == MeasureSpec.EXACTLY
				&& MeasureSpec.getMode(heightMeasureSpec) == MeasureSpec.EXACTLY;
		final boolean matchesSpecSize = getMeasuredWidth() == MeasureSpec.getSize(widthMeasureSpec)
				&& getMeasuredHeight() == MeasureSpec.getSize(heightMeasureSpec);
		final boolean needsLayout = specChanged
				&& (sAlwaysRemeasureExactly || !isSpecExactly || !matchesSpecSize);

		if (forceLayout || needsLayout) {
			// first clears the measured dimension flag
			mPrivateFlags &= ~PFLAG_MEASURED_DIMENSION_SET;

			resolveRtlPropertiesIfNeeded();

			int cacheIndex = forceLayout ? -1 : mMeasureCache.indexOfKey(key);
			if (cacheIndex < 0 || sIgnoreMeasureCache) {
				// measure ourselves, this should set the measured dimension flag back
				onMeasure(widthMeasureSpec, heightMeasureSpec);
				mPrivateFlags3 &= ~PFLAG3_MEASURE_NEEDED_BEFORE_LAYOUT;
			} else {
				long value = mMeasureCache.valueAt(cacheIndex);
				// Casting a long to int drops the high 32 bits, no mask needed
				setMeasuredDimensionRaw((int) (value >> 32), (int) value);
				mPrivateFlags3 |= PFLAG3_MEASURE_NEEDED_BEFORE_LAYOUT;
			}

			// flag not set, setMeasuredDimension() was not invoked, we raise
			// an exception to warn the developer
			if ((mPrivateFlags & PFLAG_MEASURED_DIMENSION_SET) != PFLAG_MEASURED_DIMENSION_SET) {
				throw new IllegalStateException("View with id " + getId() + ": "
						+ getClass().getName() + "#onMeasure() did not set the"
						+ " measured dimension by calling"
						+ " setMeasuredDimension()");
			}

			mPrivateFlags |= PFLAG_LAYOUT_REQUIRED;
		}

		........

		mMeasureCache.put(key, ((long) mMeasuredWidth) << 32 |
				(long) mMeasuredHeight & 0xffffffffL); // suppress sign extension
	}
        从代码可知,要么该标志位被设置,然后再 layout 过程中调用 onMeasure,要么不会设置该标志位,直接调用 onMeasure 方法。

        也就是只有2条路可走,而且必走其中一条:

        1、measure 过程中,调用 onMeasure。

        2、measure 过程中,跳过 onMeasure,然后设置标志位,在 layout 中调用 onMeasure。


研究控制逻辑

        从上段代码看来,控制逻辑就是是否使用上一次的测量缓存,计算逻辑有2个,

1:是否忽略测量缓存,也就是 sIgnoreMeasureCache,如果忽略测量缓存,那么就会调用 onMeasure 测量。那么这个值是在哪里被赋值的?

View的构造方法里面,有下面的代码:

            // Older apps expect onMeasure() to always be called on a layout pass, regardless
            // of whether a layout was requested on that View.
            sIgnoreMeasureCache = targetSdkVersion < KITKAT;
也就是在4.4以上的版本,默认会使用测量缓存。

2:是否能获取到对应测量参数的测量结果,这里先补充一点其他的知识。


MeasureCache 是什么

        MeasureCache 就是测量结果的缓存,它是一个 long -> long 的映射结构,存储每一组测量参数和测量结果的映射关系,看一下相关代码:

根据 measureSpec 生成 key:

        // Suppress sign extension for the low bytes
        long key = (long) widthMeasureSpec << 32 | (long) heightMeasureSpec & 0xffffffffL;

存放测量结果:

        mMeasureCache.put(key, ((long) mMeasuredWidth) << 32 | (long) mMeasuredHeight & 0xffffffffL); // suppress sign extension

很简单,就是以每一组 widthMeasureSpec 和 heightMeasureSpec 组合,作为key,把 measuredWidth 和 measuredHeight 组合,作为value。

 


forceLayout

        从代码可知,当 PFLAG_FORCE_LAYOUT 标志位被设置时,forceLayout 就是 true,那么该标志位又是在哪里被设置的呢?搜索代码:

	public void requestLayout() {
		......
		mPrivateFlags |= PFLAG_FORCE_LAYOUT;
		......
	}

        是在 requestLayout 中,也就是说,如果调用了某个控件的 requestLayout,那么下一次它就会被强制测量。


        回到上面说的第二个计算逻辑,如果是 forceLayout 或者 拿不到对应的缓存,都会导致 onMeasure 被调用。



分析原因

        写到这里,基本有点眉目了,就是因为 TextView 在 measure 过程中,没有调用 onMeasure,导致了Bug出现,那么,后面,我们继续探究一下,有哪些因素导致了它没有调用 onMeasure。

该Bug能出现,触发了很多临界条件,这个跟该Bug出现时,运行时的状态,有严密的关系,下面探索一下



setText之后,为什么没有开启 forceLayout

我们继续查看源码:

	private void setText(CharSequence text, BufferType type, boolean notifyBefore, int oldlen) {
		........

		if (mLayout != null) {
			checkForRelayout();
		}

		........
	}

在设置文本后,会调用 checkForRelayout,看看这个方法干了什么:

	private void checkForRelayout() {
		// If we have a fixed width, we can just swap in a new text layout
		// if the text height stays the same or if the view height is fixed.

		if ((mLayoutParams.width != ViewGroup.LayoutParams.WRAP_CONTENT ||
				(mMaxWidthMode == mMinWidthMode && mMaxWidth == mMinWidth)) &&
				(mHint == null || mHintLayout != null) &&
				(mRight - mLeft - getCompoundPaddingLeft() - getCompoundPaddingRight() > 0)) {
			// Static width, so try making a new text layout.

			int oldht = mLayout.getHeight();
			int want = mLayout.getWidth();
			int hintWant = mHintLayout == null ? 0 : mHintLayout.getWidth();

            /*
             * No need to bring the text into view, since the size is not
             * changing (unless we do the requestLayout(), in which case it
             * will happen at measure).
             */
			makeNewLayout(want, hintWant, UNKNOWN_BORING, UNKNOWN_BORING,
					mRight - mLeft - getCompoundPaddingLeft() - getCompoundPaddingRight(),
					false);

			if (mEllipsize != TextUtils.TruncateAt.MARQUEE) {
				// In a fixed-height view, so use our new text layout.
				if (mLayoutParams.height != ViewGroup.LayoutParams.WRAP_CONTENT &&
						mLayoutParams.height != ViewGroup.LayoutParams.MATCH_PARENT) {
					invalidate();
					return;
				}

				// Dynamic height, but height has stayed the same,
				// so use our new text layout.
				if (mLayout.getHeight() == oldht &&
						(mHintLayout == null || mHintLayout.getHeight() == oldht)) {
					invalidate();
					return;
				}
			}

			// We lose: the height has changed and we have a dynamic height.
			// Request a new view layout using our new text layout.
			requestLayout();
			invalidate();
		} else {
			// Dynamic width, so we have no choice but to request a new
			// view layout with a new text layout.
			nullLayouts();
			requestLayout();
			invalidate();
		}
	}

        大概意思就是,如果TextView的宽度是 wrap_content ,会重新计算尺寸,否则,如果排版后,尺寸不需要变化,就能满足显示,那么就不会触发 requestLayout 方法,也就不会开启 forceLayout。那么明明我们的文本从2行变成了3行,怎么就不需要重新测量了?逗我呢???

        于是深入研究,看了一下代码,大概就是说,设置了新文本后,会为新文本重新生成 Layout 对象,根据 新旧Layout 的高度来判断是否需要重新测量。

        所以,除非有一个难以置信的可能,那就是,该文本新生成的 Layout 对象,就是2行的,所以我们分别追踪了新旧 Layout 的值:

4 11:50:47.304 31201-31201/cn.hjf.scrollmeasuretest W/O_O: TextView before setText
04-04 11:50:47.304 31201-31201/cn.hjf.scrollmeasuretest W/O_O:         text : Drawing on their practical experience, they designed an air-cooled diesel motor.
04-04 11:50:47.304 31201-31201/cn.hjf.scrollmeasuretest W/O_O:         layout.getHeight : 136 , layout.getLineCount : 2
04-04 11:50:47.308 31201-31201/cn.hjf.scrollmeasuretest W/O_O: TextView after setText
04-04 11:50:47.308 31201-31201/cn.hjf.scrollmeasuretest W/O_O:         layout.getHeight : 136 , layout.getLineCount : 2

        果然如猜测一样,那么为什么3行的文本,会被测量为2行呢?

        对,这就是其中一个临界条件,因为在上一轮,右侧图标是不显示的,所以这时的 TextView 宽度是比较大的,这时候,在设置文本后,这个文本在这个宽度下,正好可以在2行显示完全,所以不会触发 requestLayout。

        临界条件1:在图标不显示的情况下,文本行数要和上一轮文本行数一致,并且在图标显示状态下,文本行数必须要比在图标不显示状态下,多出一行。



何时才能拿到测量缓存

        我在刚开始编写该demo的时候,是不能复现该bug的,因为我的代码是写成下面这样的:

	public void change(View view) {
		mTextView.setText(mData[mIndex]);

		if (mIndex == mData.length - 1) {
			mImageView.setVisibility(View.VISIBLE);
		} else {
			mImageView.setVisibility(View.GONE);
		}

		mIndex++;
		if (mIndex == mData.length) {
			mIndex = 0;
		}
	}

注意控制右侧图标的显示逻辑,这是我一开始的写法,只有在最后一条数据的时候,才显示图标,否则不显示。


        所以我把目光聚焦到了 mMeasureCache 上面,为什么拿不到缓存,因为前面3条数据,TextView 的宽度是和 LinearLayout 一样的,最后一条才缩小了一些。这样就导致了 measure方法中传入的 widthMeasureSpec 不一致,所以就找不到缓存。

        于是我改了写法,让右侧图标交替隐藏显示,所以由于在第2条数据时,使用了相同的 measureSpec 测量了结果,并且存放在了缓存中,所以在显示第4条数据的时候,就拿到了第2条数据测量的缓存,所以就不会走 onMeasure 方法。

        临界条件2:如果前面的数据,都没有显示过右侧图标,那么就不会有缓存可用,只要前面显示过图标,就能找到对应的测量缓存。



解决方案

        其实所有的解决方案都是如何让 TextView 在 measure 的过程中 执行 onMeasure 方法。

1、setText 后主动调用 requestLayout

	public void change(View view) {
		mTextView.setText(mData[mIndex]);
		mTextView.requestLayout();

		if (mIndex % 2 == 1) {
			mImageView.setVisibility(View.VISIBLE);
		} else {
			mImageView.setVisibility(View.GONE);
		}

		mIndex++;
		if (mIndex == mData.length) {
			mIndex = 0;
		}
	}
这样在下一个流程中,就会开启 forceLayout。


2、修改 TextView 的 layout_width

        <TextView
            android:id="@+id/tv"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:background="#33ff0000"
            android:textSize="18dp" />
把 layout_width 修改为 wrap_content, 可以在 setText 后,能调用 requestLayout。

这个要考虑具体需求,因为我们的需求就是,如果图标显示,就显示,然后文本填充剩余空间,如果图标不显示,文本就和父布局一样宽。

layout_weight = "1" 和 layout_width = "wrap_content" 可以满足这个需求。







我一定是人品爆发了,才会遇上这个Bug。。。。