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

概述

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

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


Bug情况

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

核心代码如下:


 
 
  1. public class MainActivity extends AppCompatActivity {
  2. private TextView mTextView;
  3. private ImageView mImageView;
  4. /**
  5. * 最后一条内容必须有严格限制,就是在 {@link MainActivity#mImageView} 不显示的时候,2行能够显示下,在{@link MainActivity#mImageView}
  6. * 显示的时候,需要3行。手机分辨率不一样,可能导致该Bug无法复现。
  7. */
  8. private String[] mData = new String[]{
  9. "Supplies of food are almost exhausted.",
  10. "She refused to be intimidated by their threats.",
  11. "The stock price of J-Stream rose by the daily limit the next day.",
  12. "Drawing on their practical experience, they designed an air-cooled diesel motor."
  13. };
  14. private int mIndex = 0;
  15. @Override
  16. protected void onCreate(Bundle savedInstanceState) {
  17. super.onCreate(savedInstanceState);
  18. setContentView(R.layout.activity_main);
  19. mTextView = (TextView) findViewById(R.id.tv);
  20. mImageView = (ImageView) findViewById(R.id.iv);
  21. }
  22. public void change(View view) {
  23. mTextView.setText(mData[mIndex]);
  24. if (mIndex % 2 == 1) {
  25. mImageView.setVisibility(View.VISIBLE);
  26. } else {
  27. mImageView.setVisibility(View.GONE);
  28. }
  29. mIndex++;
  30. if (mIndex == mData.length) {
  31. mIndex = 0;
  32. }
  33. }
  34. }

布局文件如下:


 
 
  1. <?xml version="1.0" encoding="utf-8"?>
  2. <RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
  3. android:layout_width= "match_parent"
  4. android:layout_height= "match_parent">
  5. <LinearLayout
  6. android:layout_width= "match_parent"
  7. android:layout_height= "wrap_content"
  8. android:background= "#5555ff00"
  9. android:orientation= "horizontal">
  10. <TextView
  11. android:id= "@+id/tv"
  12. android:layout_width= "0dp"
  13. android:layout_height= "wrap_content"
  14. android:layout_weight= "1"
  15. android:background= "#33ff0000"
  16. android:textSize= "18dp" />
  17. <ImageView
  18. android:id= "@+id/iv"
  19. android:layout_width= "wrap_content"
  20. android:layout_height= "wrap_content"
  21. android:layout_marginLeft= "12dp"
  22. android:src= "@mipmap/ic_launcher" />
  23. </LinearLayout>
  24. <Button
  25. android:layout_width= "match_parent"
  26. android:layout_height= "wrap_content"
  27. android:layout_alignParentBottom= "true"
  28. android:onClick= "change"
  29. android:text= "change" />
  30. </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 的测量,下面是部分源代码:


 
 
  1. public void layout(int l, int t, int r, int b) {
  2. if ((mPrivateFlags3 & PFLAG3_MEASURE_NEEDED_BEFORE_LAYOUT) != 0) {
  3. onMeasure(mOldWidthMeasureSpec, mOldHeightMeasureSpec);
  4. mPrivateFlags3 &= ~PFLAG3_MEASURE_NEEDED_BEFORE_LAYOUT;
  5. }
  6. ........
  7. ........
  8. }
        从代码可知,PFLAG3_MEASURE_NEEDED_BEFORE_LAYOUT 标志位被设置时,就会在 layout 的时候被测量。


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

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


 
 
  1. public final void measure(int widthMeasureSpec, int heightMeasureSpec) {
  2. ........
  3. // Suppress sign extension for the low bytes
  4. long key = ( long) widthMeasureSpec << 32 | ( long) heightMeasureSpec & 0xffffffffL;
  5. if (mMeasureCache == null) mMeasureCache = new LongSparseLongArray( 2);
  6. final boolean forceLayout = (mPrivateFlags & PFLAG_FORCE_LAYOUT) == PFLAG_FORCE_LAYOUT;
  7. // Optimize layout by avoiding an extra EXACTLY pass when the view is
  8. // already measured as the correct size. In API 23 and below, this
  9. // extra pass is required to make LinearLayout re-distribute weight.
  10. final boolean specChanged = widthMeasureSpec != mOldWidthMeasureSpec
  11. || heightMeasureSpec != mOldHeightMeasureSpec;
  12. final boolean isSpecExactly = MeasureSpec.getMode(widthMeasureSpec) == MeasureSpec.EXACTLY
  13. && MeasureSpec.getMode(heightMeasureSpec) == MeasureSpec.EXACTLY;
  14. final boolean matchesSpecSize = getMeasuredWidth() == MeasureSpec.getSize(widthMeasureSpec)
  15. && getMeasuredHeight() == MeasureSpec.getSize(heightMeasureSpec);
  16. final boolean needsLayout = specChanged
  17. && (sAlwaysRemeasureExactly || !isSpecExactly || !matchesSpecSize);
  18. if (forceLayout || needsLayout) {
  19. // first clears the measured dimension flag
  20. mPrivateFlags &= ~PFLAG_MEASURED_DIMENSION_SET;
  21. resolveRtlPropertiesIfNeeded();
  22. int cacheIndex = forceLayout ? - 1 : mMeasureCache.indexOfKey(key);
  23. if (cacheIndex < 0 || sIgnoreMeasureCache) {
  24. // measure ourselves, this should set the measured dimension flag back
  25. onMeasure(widthMeasureSpec, heightMeasureSpec);
  26. mPrivateFlags3 &= ~PFLAG3_MEASURE_NEEDED_BEFORE_LAYOUT;
  27. } else {
  28. long value = mMeasureCache.valueAt(cacheIndex);
  29. // Casting a long to int drops the high 32 bits, no mask needed
  30. setMeasuredDimensionRaw(( int) (value >> 32), ( int) value);
  31. mPrivateFlags3 |= PFLAG3_MEASURE_NEEDED_BEFORE_LAYOUT;
  32. }
  33. // flag not set, setMeasuredDimension() was not invoked, we raise
  34. // an exception to warn the developer
  35. if ((mPrivateFlags & PFLAG_MEASURED_DIMENSION_SET) != PFLAG_MEASURED_DIMENSION_SET) {
  36. throw new IllegalStateException( "View with id " + getId() + ": "
  37. + getClass().getName() + "#onMeasure() did not set the"
  38. + " measured dimension by calling"
  39. + " setMeasuredDimension()");
  40. }
  41. mPrivateFlags |= PFLAG_LAYOUT_REQUIRED;
  42. }
  43. ........
  44. mMeasureCache.put(key, (( long) mMeasuredWidth) << 32 |
  45. ( long) mMeasuredHeight & 0xffffffffL); // suppress sign extension
  46. }
        从代码可知,要么该标志位被设置,然后再 layout 过程中调用 onMeasure,要么不会设置该标志位,直接调用 onMeasure 方法。

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

        1、measure 过程中,调用 onMeasure。

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


研究控制逻辑

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

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

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


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

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


MeasureCache 是什么

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

根据 measureSpec 生成 key:


 
 
  1. // Suppress sign extension for the low bytes
  2. 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,那么该标志位又是在哪里被设置的呢?搜索代码:


 
 
  1. public void requestLayout() {
  2. ......
  3. mPrivateFlags |= PFLAG_FORCE_LAYOUT;
  4. ......
  5. }

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


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



分析原因

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

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



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

我们继续查看源码:


 
 
  1. private void setText(CharSequence text, BufferType type, boolean notifyBefore, int oldlen) {
  2. ........
  3. if (mLayout != null) {
  4. checkForRelayout();
  5. }
  6. ........
  7. }

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


 
 
  1. private void checkForRelayout() {
  2. // If we have a fixed width, we can just swap in a new text layout
  3. // if the text height stays the same or if the view height is fixed.
  4. if ((mLayoutParams.width != ViewGroup.LayoutParams.WRAP_CONTENT ||
  5. (mMaxWidthMode == mMinWidthMode && mMaxWidth == mMinWidth)) &&
  6. (mHint == null || mHintLayout != null) &&
  7. (mRight - mLeft - getCompoundPaddingLeft() - getCompoundPaddingRight() > 0)) {
  8. // Static width, so try making a new text layout.
  9. int oldht = mLayout.getHeight();
  10. int want = mLayout.getWidth();
  11. int hintWant = mHintLayout == null ? 0 : mHintLayout.getWidth();
  12. /*
  13. * No need to bring the text into view, since the size is not
  14. * changing (unless we do the requestLayout(), in which case it
  15. * will happen at measure).
  16. */
  17. makeNewLayout(want, hintWant, UNKNOWN_BORING, UNKNOWN_BORING,
  18. mRight - mLeft - getCompoundPaddingLeft() - getCompoundPaddingRight(),
  19. false);
  20. if (mEllipsize != TextUtils.TruncateAt.MARQUEE) {
  21. // In a fixed-height view, so use our new text layout.
  22. if (mLayoutParams.height != ViewGroup.LayoutParams.WRAP_CONTENT &&
  23. mLayoutParams.height != ViewGroup.LayoutParams.MATCH_PARENT) {
  24. invalidate();
  25. return;
  26. }
  27. // Dynamic height, but height has stayed the same,
  28. // so use our new text layout.
  29. if (mLayout.getHeight() == oldht &&
  30. (mHintLayout == null || mHintLayout.getHeight() == oldht)) {
  31. invalidate();
  32. return;
  33. }
  34. }
  35. // We lose: the height has changed and we have a dynamic height.
  36. // Request a new view layout using our new text layout.
  37. requestLayout();
  38. invalidate();
  39. } else {
  40. // Dynamic width, so we have no choice but to request a new
  41. // view layout with a new text layout.
  42. nullLayouts();
  43. requestLayout();
  44. invalidate();
  45. }
  46. }

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

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

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


 
 
  1. 4 11: 50: 47.304 31201- 31201/cn.hjf.scrollmeasuretest W/O_O: TextView before setText
  2. 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.
  3. 04- 04 11: 50: 47.304 31201- 31201/cn.hjf.scrollmeasuretest W/O_O: layout.getHeight : 136 , layout.getLineCount : 2
  4. 04- 04 11: 50: 47.308 31201- 31201/cn.hjf.scrollmeasuretest W/O_O: TextView after setText
  5. 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的,因为我的代码是写成下面这样的:


 
 
  1. public void change(View view) {
  2. mTextView.setText(mData[mIndex]);
  3. if (mIndex == mData.length - 1) {
  4. mImageView.setVisibility(View.VISIBLE);
  5. } else {
  6. mImageView.setVisibility(View.GONE);
  7. }
  8. mIndex++;
  9. if (mIndex == mData.length) {
  10. mIndex = 0;
  11. }
  12. }

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


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

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

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



解决方案

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

1、setText 后主动调用 requestLayout


 
 
  1. public void change(View view) {
  2. mTextView.setText(mData[mIndex]);
  3. mTextView.requestLayout();
  4. if (mIndex % 2 == 1) {
  5. mImageView.setVisibility(View.VISIBLE);
  6. } else {
  7. mImageView.setVisibility(View.GONE);
  8. }
  9. mIndex++;
  10. if (mIndex == mData.length) {
  11. mIndex = 0;
  12. }
  13. }
这样在下一个流程中,就会开启 forceLayout。


2、修改 TextView 的 layout_width


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

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

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







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







评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值