转载请注明出处: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 的时候,我们期待的运行结果是这样的:
但是,实际运行结果却是这样的:
开启痛苦的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。。。。