概述
最近碰到一个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。。。。