阅读 516

从measure角度来优化ConstraintLayout

  熟悉ConstraintLayout的同学都知道ConstraintLayout内部的子View最少会measure两次,一旦内部有某些View的measure阶段比较耗时,那么measure多次就会把这个耗时问题放大。在我们的项目中,我们通过Trace信息发现App的一部分耗时是因为这个造成,所以优化ConstraintLayout显得至关重要。

  最初,我们想到的办法是替换布局,将会measure多次的布局比如说RelativeLayout和ConstraintLayout换成只会measure一次的FrameLayout,这在一定程度上能够缓解这个问题,但是这样做毕竟治标不治本。因为在替换布局过程中,会发现很多布局文件根本就换不了,相关的同学在开发过程中选择其他布局肯定是要使用到其特别的属性。那么有没有一种办法,既能减少原有布局的measure次数,又能保证不影响到其本身的特性呢?基于此,我去阅读了ConstraintLayout相关源码,了解其内部实现原理,思考出一种方案,用以减少ConstraintLayout的measure次数,进而减少measure的耗时。

  为啥选择ConstraintLayout来优化,而不是较为简单的RelativeLayout呢?那是因为ConstraintLayout的使用太为广泛,而且RelativeLayout能够实现的布局,ConstraintLayout都能实现;其次,还有一点点私心,想要学习一下ConstraintLayout的内部实现原理。

  特别注意,本文ConstraintLayout的源码来自于2.0.4版本

在后续内容之前,大家一定要记住,本文使用的是2.0.4版本的ConstraintLayout。因为不同版本的ConstraintLayout,内部实现不完全相同,所以最终实现的细节可能不同。

1. 实现方案

  我们直接开门见山,来介绍一下整个方案,主要分为两步:

  1. 自定义ConstraintLayout,重写onMeasure方法,增加一个判断,减少没必要测量
  2. 设置ConstrainLayout的optimizationLevel属性,将其修改为OPTIMIZATION_GRAPHOPTIMIZATION_GRAPH_WRAP,默认值为OPTIMIZATION_DIRECT

(1). 重写onMeasure

  我直接贴代码:

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        if (mMeasureOpt && skipMeasure(widthMeasureSpec, heightMeasureSpec)) {
            return;
        }
        mOnMeasureWidthMeasureSpec = widthMeasureSpec;
        mOnMeasureHeightMeasureSpec = heightMeasureSpec;
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
    }

    /**
     * 用以判断是否跳过本次Measure。
     */
    private boolean skipMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        if (mDirtyHierarchy) {
            return false;
        }
        final int childCount = getChildCount();
        for (int index = 0; index < childCount; index++) {
            View child = getChildAt(index);
            if (child.isLayoutRequested() && !(child.getMeasuredHeight() > 0 && child.getMeasuredWidth() > 0)) {
                return false;
            }
        }
        if (mOnMeasureWidthMeasureSpec == widthMeasureSpec && mOnMeasureHeightMeasureSpec == heightMeasureSpec) {
            resolveMeasuredDimension(widthMeasureSpec, heightMeasureSpec, mLayoutWidget.getWidth(), mLayoutWidget.getHeight(), mLayoutWidget.isWidthMeasuredTooSmall(), mLayoutWidget.isHeightMeasuredTooSmall());
            return true;
        }
        if (mOnMeasureWidthMeasureSpec == widthMeasureSpec && MeasureSpec.getMode(widthMeasureSpec) == MeasureSpec.EXACTLY && MeasureSpec.getMode(heightMeasureSpec) == MeasureSpec.AT_MOST && MeasureSpec.getMode(mOnMeasureHeightMeasureSpec) == MeasureSpec.AT_MOST) {
            int newSize = MeasureSpec.getSize(heightMeasureSpec);
            if (newSize >= mLayoutWidget.getHeight()) {
                mOnMeasureWidthMeasureSpec = widthMeasureSpec;
                mOnMeasureHeightMeasureSpec = heightMeasureSpec;
                resolveMeasuredDimension(
                        widthMeasureSpec,
                        heightMeasureSpec,
                        mLayoutWidget.getWidth(),
                        mLayoutWidget.getHeight(),
                        mLayoutWidget.isWidthMeasuredTooSmall(),
                        mLayoutWidget.isHeightMeasuredTooSmall()
                );
                return true;
            }
        }
        return false;
    }
复制代码

  大家从上面的代码可以看出来几点:

  1. 在onMeasure方法中调用skipMeasure方法,用以判断是否跳过当前Measure。
  2. skipMeasure方法中,需要注意两个点:先是判断了mDirtyHierarchy,如果mDirtyHierarchy为true,那么就不跳过measure;其次,遍历了每个Child,并且判断child.isLayoutRequested() && !(child.getMeasuredHeight() > 0 && child.getMeasuredWidth() > 0),如果这个条件为true,那么也不跳过measure。如果前面两个条件都不满足,那么就继续往下判断是否需要跳过,后面会详细解释为啥要这么做,这里先不多说。

(2). 设置optimizationLevel

  设置optimizationLevel有两个方法,一是在xml文件中,通过layout_optimizationLevel属性设置,二是通过setOptimizationLevel方法设置。至于为啥需要设置optimizationLevel,下面的内容会有解释。

  通过如上两步操作进行设置,然后将布局里面的ConstraintLayout替换成为自定义的ConstraintLayout,就可以让其内部的View measure一次。

  我相信,大家在使用此方案之前,内心有一个疑问:这个会影响使用ConstraintLayout的原有特性吗?经过我简单的测试,此方案定义的ConstraintLayout并不影响其常规属性。大家可以在KotlinDemo里面找到详细的实现代码,参考MyConstraintLayout的实现。

2.揭露原理

  在上面的内容当中,我们进行了两步操作实现了measure 一次。那么这两步为啥要这么做呢?上面没有解释,在这里我将揭露其内部原理。

  通过已有的知识和了解到的ConstraintLayout的实现,我们可以知道ConstraintLayout会measure多次,主要体现在两个地方:ViewRootImpl可能会多次调用performMeasure方法,最终会导致ConstraintLayout的onMeasure方法会调用多次;ConstraintLayout内部在measure child的时候,也有可能导致多次measure。所以,上面的两步操作分别解决的这两个问题:重写onMeasure方法是避免它被调用多次;设置optimizationLevel是避免child 被measure多次。

  我们来看一下这其中的细节。

(1). ConstraintLayout的onMeasure方法

  我们直接来看onMeasure方法的源码:

    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        // 1. 如果当前View树的状态是最新的,也尝试遍历每个child,
        // 看看每个child是否重新layout。
        if (!mDirtyHierarchy) {
            // it's possible that, if we are already marked for a relayout, a view would not call to request a layout;
            // in that case we'd miss updating the hierarchy correctly.
            // We have to iterate on our children to verify that none set a request layout flag...
            final int count = getChildCount();
            for (int i = 0; i < count; i++) {
                final View child = getChildAt(i);
                if (child.isLayoutRequested()) {
                    mDirtyHierarchy = true;
                    break;
                }
            }
        }
        // 3. 经过上面的重新判断,再来判断是否舍弃本次的measure(不measure child就理解为舍弃本次measure)
        if (!mDirtyHierarchy) {
            if (mOnMeasureWidthMeasureSpec == widthMeasureSpec && mOnMeasureHeightMeasureSpec == heightMeasureSpec) {
                resolveMeasuredDimension(widthMeasureSpec, heightMeasureSpec, mLayoutWidget.getWidth(), mLayoutWidget.getHeight(),
                        mLayoutWidget.isWidthMeasuredTooSmall(), mLayoutWidget.isHeightMeasuredTooSmall());
                return;
            }
            if (mOnMeasureWidthMeasureSpec == widthMeasureSpec
                    && MeasureSpec.getMode(widthMeasureSpec) == MeasureSpec.EXACTLY
                    && MeasureSpec.getMode(heightMeasureSpec) == MeasureSpec.AT_MOST
                    && MeasureSpec.getMode(mOnMeasureHeightMeasureSpec) == MeasureSpec.AT_MOST) {
                int newSize = MeasureSpec.getSize(heightMeasureSpec);
                if (DEBUG) {
                    System.out.println("### COMPATIBLE REQ " + newSize + " >= ? " + mLayoutWidget.getHeight());
                }
                if (newSize >= mLayoutWidget.getHeight()) {
                    mOnMeasureWidthMeasureSpec = widthMeasureSpec;
                    mOnMeasureHeightMeasureSpec = heightMeasureSpec;
                    resolveMeasuredDimension(widthMeasureSpec, heightMeasureSpec, mLayoutWidget.getWidth(), mLayoutWidget.getHeight(),
                            mLayoutWidget.isWidthMeasuredTooSmall(), mLayoutWidget.isHeightMeasuredTooSmall());
                    return;
                }
            }
        }
        mOnMeasureWidthMeasureSpec = widthMeasureSpec;
        mOnMeasureHeightMeasureSpec = heightMeasureSpec;

        mLayoutWidget.setRtl(isRtl());

        if (mDirtyHierarchy) {
            mDirtyHierarchy = false;
            if (updateHierarchy()) {
                mLayoutWidget.updateHierarchy();
            }
        }
        // 3. measure child
        resolveSystem(mLayoutWidget, mOptimizationLevel, widthMeasureSpec, heightMeasureSpec);
        resolveMeasuredDimension(widthMeasureSpec, heightMeasureSpec, mLayoutWidget.getWidth(), mLayoutWidget.getHeight(),
                mLayoutWidget.isWidthMeasuredTooSmall(), mLayoutWidget.isHeightMeasuredTooSmall());
    }
复制代码

  这个onMeasure方法的实现,我将其分为三步:

  1. mDirtyHierarchy为false时,表示当前View 树已经经历过测量了。但是此时要从每个child的isLayoutRequested状态来判断是否需要重新测量,如果为true,表示当前child进行了requestLayout操作或者forceLayout操作,所以需要重新测量。这么看好像没有毛病,但是为啥我们将isLayoutRequested修改为child.isLayoutRequested() && !(child.getMeasuredHeight() > 0 && child.getMeasuredWidth() > 0)呢?这个要从ConstrainLayout的第一次测量说起,当整个布局添加到ViewRootImpl上去的时候,ViewRootImpl会调用Constraintlayout的onMeasure方法。这里有一个点需要注意的是,在正式layout之前,onMeasure方法可能会调用多次,同时isLayoutRequested会一直为true,因为这个状态在layout阶段才清空的。也就是说,在layout之前,尽管mDirtyHierarchy已经为false了,还是会重新测量一遍所有的child。可实际上,此时child的width和height已经确定了,没必要在测量一遍,所以这里我增加了宽高的限制,保证child已经measure了,不会再measure。
  2. 经过第一点的判断,如果此时mDirtyHierarchy还为false,表示当前View树不需要再测量,因此就直接return即可(实际上,这里没有直接return,而是另外做了一些判断,用以保证measure没有问题。)。我们在定义skipMeasure方法的时候,就是这部分的代码拷贝出来的,用以保证内外判断一致。
  3. 如果上面两个条件都不满足,那么就表示需要测量child,就调用resolveSystem方法测量所有的child。

  上面的第一点中,我已经解释了为啥我们需要重写onMeasure方法,目的是为了过滤没必要的测量。那么可能有人要问,正常的测量会被过滤吗?其实重点在于mDirtyHierarchy为false的情况下,会影响到某些测量吗?从一个方面来看,第一次测量基本没有什么问题,还有一种情况就是,动态的修改View的宽高会有影响吗?动态修改布局,最终都会导致requestLayout,然而我们从ConstraintLayout的实现可以看出来,Google爸爸在requestLayout和forceLayout两个方法里面都将mDirtyHierarchy设置为true了,所以理论上不会造成影响。

(2). measure child

  从上面的介绍,我们知道ConstraintLayout在measure child,也有可能measure多次,我们来看一下为啥会measure多次。细节我们就不分析了,我们直接跳到measure child的地方--BasicMeasure的solverMeasure方法里面:

    public long solverMeasure(ConstraintWidgetContainer layout,
                              int optimizationLevel,
                              int paddingX, int paddingY,
                              int widthMode, int widthSize,
                              int heightMode, int heightSize,
                              int lastMeasureWidth,
                              int lastMeasureHeight) {
        // ······

        boolean optimizeWrap = Optimizer.enabled(optimizationLevel, Optimizer.OPTIMIZATION_GRAPH_WRAP);
        boolean optimize = optimizeWrap || Optimizer.enabled(optimizationLevel, Optimizer.OPTIMIZATION_GRAPH);

        if (optimize) {
           // 判断优化是否失效
        }
        // ······
        optimize &= (widthMode == EXACTLY && heightMode == EXACTLY) || optimizeWrap;

        int computations = 0;

        if (optimize) {
           // 如果优化生效,那么通过Graph的方式测量child,这个过程中只会measure child 一次。
        } else {
           // ·······
        }

        if (!allSolved || computations != 2) {
           // 如果没有优化,或者优化的measure没有完全解决measure,会兜底测量
           // 这个过程可能会有多次measure child
        }
        if (LinearSystem.MEASURE) {
            layoutTime = (System.nanoTime() - layoutTime);
        }
        return layoutTime;
    }
复制代码

  从这里,我们可以看出来,只要我们设置了optimizationLevel,就有可能让所有的child只measure一次,这也是我们想要的结果。而且,就算measure有问题,ConstaintLayout在测量过程中发现了问题,即allSolved为false,也会进行兜底。

3. 总结

  经过上面的介绍,我们基本能理解整个优化ConstraintLayout measure具体内容,在这里,我简单的做一个总结。

  1. 重写onMeasure方法是为了保证ConstraintLayout的onMeasure只会执行一次。
  2. 设置optimizationLevel,是为了保证child只会被measure一次。
文章分类
Android
文章标签