Android性能优化-TextView的setText方法会导致界面重绘?

问题现象

大概就是我们在自定义一个视频组件的ui时,发现了一段异常的效果。 我简述一下:

  • 视频的控制器 底部一般都是 显示时间(textview)和进度条(seekbar)的
  • 一般要实现这个效果 都是开个定时任务 每隔一段时间去重新setText一个时间。

效果如下:

然后测试mm们发现一个必现的异常,视频的进度条 也就是这个seekbar 总是会在视频开始的前几秒的时候 回退一下。然后才能正常展示进度条。

修复此问题的方法

经过一段时间的努力,我们发现 这个问题的解决方案 是把textview的 width属性 从wrap_content 改成 固定的xxdp值就可以。 问题的解决看似比较简单,但是背后的逻辑没有弄清楚。为什么把textview的属性 改了一下,这个关于seekbar的 问题就修复了?

还原问题现场

为了找到事情的根本原因,我们做了一次最小粒度还原。也就是新建一个干净的工程,排除其他问题的干扰, 看看到底是哪里出了问题?

首先看一下布局,这个布局和一开始我们出bug的布局是差不多的。

 <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="200dp"
        android:background="@color/colorPrimary"
        android:orientation="horizontal"
        tools:ignore="MissingConstraints">

        <TextView
            android:id="@+id/tv"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_gravity="center_vertical"
            android:gravity="center"
            android:text="Hello World!" />

        <SeekBar
            android:id="@+id/sb"
            android:layout_width="100dp"
            android:layout_height="match_parent"
            android:layout_marginLeft="10dp">

        </SeekBar>
    </LinearLayout>
复制代码

然后看一下我们的关键 复现问题的代码:

 button = findViewById(R.id.bt);

        button.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                new Thread() {
                    @Override
                    public void run() {
                        //每隔一段时间 去刷新一下界面
                        while (true) {
                            try {
                                Thread.sleep(3000);
                            } catch (InterruptedException e) {
                                e.printStackTrace();
                            }
    
                            runOnUiThread(new Runnable() {
                                @Override
                                public void run() {
                                    // 问题就在这里了,setText 以后 seekbar的状态会被莫名奇妙设置一次
                                    tv.setText(System.currentTimeMillis() + "");
                                }
                            });
                        }

                    }
                }.start();
            }
        });
复制代码

源码分析

首先我们看看为什么在wrap_content的时候 textview的setText 会导致一系列的问题?

跟一下 setText的源码:

我们把这个 函数整体贴上来

 /**
     * Check whether entirely new text requires a new view layout
     * or merely a new text layout.
     */
    @UnsupportedAppUsage
    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 != 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 != LayoutParams.WRAP_CONTENT
                        && mLayoutParams.height != LayoutParams.MATCH_PARENT) {
                    autoSizeText();
                    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)) {
                    autoSizeText();
                    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();
        }
    }
复制代码

翻译一下,就是 只要满足一定的条件就不会触发 requestLayout这个操作。 稍微看看源码 也可以知道 这个条件,大概就是 宽度不要是wrap_content的属性,并且不能是跑马灯的属性。 当然对高度也有一定的要求,但是最重要的条件就是width的wrap_content属性(其他属性我们用的比较少),只要不是他,我们就不会 走到requestLayout的流程中。

原因真的分析完毕了吗?

再仔细想一想,在一个view树中,一个子view的重绘 必定会导致 整个view树 全部进行重绘吗?

看一下view本身的源码

我们翻译一下这段代码 其实就是

只要View自己没有requestLayout或者再次measure时,MeasureSpec没变,就不需要重新measure

所以得出来一个结论,虽然 在满足width 等于wrap content的情况下,setText会触发 requestLayout, 但是 这并不一定导致 这个textview的兄弟节点 每次都会measure。 这也就是这个bug的表象是 只有第一次setText会导致seekbar 重绘进而造成bug,但是后面的setText 都不会导致seekBar重绘的真正原因了。

结论

对于textview这样的控件而言,setText是有一个隐形的触发界面重绘的操作,我们在周期性的对textview的 setText方法进行调用时,一定多加考虑,最好将textview的宽度设置成固定值或者是match,避免引发各种奇葩bug,或者是降低了界面渲染的效率。 特别是对于我们动画功能的编写,一定要多去看看api,看看这个api下面是不是会引发requestLayout,从而提高效率。

分类:
Android
标签: