两像素引发的惨案---ConstraintLayout偏差溯源

1,598 阅读7分钟

ConstraintLayout作为Google为了降低布局层级推出的组件,相信大家已经有广泛的应用,今天来和大家分享一下ConstraintLayout使用中遇到的一个奇怪的问题。

一、疑起

首先我们来思考一下在ConstraintLayout中为一个View添加下面的约束会是什么效果

app:layout_constraintBottom_toTopOf="parent"

从约束的意图来看是想让子控件底部与父控件上边缘对齐,这样控件会被移出父控件,之所以这样是为了实现类似drawer的效果,满足一定条件会通过动画将子控件移入。

完整布局如下:

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="#29B6F6"
    tools:context=".MainActivity">

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="256dp"
        android:layout_height="256dp"
        android:layout_centerInParent="true"
        android:background="#FFF">

        <View
            android:id="@+id/clcTop"
            android:layout_width="128dp"
            android:layout_height="128dp"
            android:background="#FFC107"
            android:tag="Top"
            app:layout_constraintBottom_toTopOf="parent"
            app:layout_constraintLeft_toLeftOf="parent"
            app:layout_constraintRight_toRightOf="parent" />


    </androidx.constraintlayout.widget.ConstraintLayout>

</RelativeLayout>

那实际效果如何呢?

问题效果图

嗯,看起来和预想的一样,等等,好像。。。那是什么,我们放大十倍来看:

问题效果图局部放大

似乎子控件没有完全移出父控件,漏了点马脚,这是AndroidStudio渲染的问题吗?上真机!

问题真机效果图 看效果图子控件确实没有完全移出父控件,留下淡淡的一抹黄色,进一步确认问题,我们来看一下Layout Inspector

问题布局Layout Inspector 这下问题确认了,这种约束方式子控件没有完全移出,会在父控件中留下两像素,看起来依依不舍,爱的深沉呀! 那这是我使用不当还是ConstraintLayout的问题呢?

二、溯源

经过各种搜索,并没有找到答案,看来没有捷径,只能靠自己了。

因为是为了解决问题,所以没有采用通读ConstraintLayout源码的方式,事实证明这也是对的,整套系统很复杂,虽然ConstraintLayout只有两千多行,但是这只是这个约束布局系统的冰山一角,通读可能会陷进去无法自拔。通过下面的问题溯源,大家也会对约束布局系统有一个更加直观的印象。

我采取的是倒推的方式,子控件的位置是由父控件也就是ConstraintLayout决定的,那什么时候告诉子控件它的位置的呢?对,onLayout,我们采取断点调试的方式追查。

断点调试onLayout

看执行过程,onLayout的时候并没有复杂的位置计算,此时数据已经出错了。所有的参数是从ConstraintLayout.LayoutParams中一个ConstraintWidget对象取的,这个对象是干什么的呢? 我们来看一下ConstraintWidget的成员,从名字和数据类型上看都是各种距离长宽,值得注意的是这几个:

从看对象的名字看ConstraintAnchor,约束锚点,有没有想到布局时ConstraintLayout上那几个小圈圈,官方叫做约束定位点,英文就是constraint anchor,聪明的你是不是想到了什么?

ConstraintLayout为了将子控件放置到指定的位置,涉及到各种计算,而这个ConstraintWidget对象由LayoutParams对象持有,就是为了承载和记录这些计算,所以,onLayout的时候,子控件位置信息可以直接从ConstraintWidget中取出来。

好了,我们重新回到onLayout中

int l = widget.getDrawX();
int t = widget.getDrawY();
int r = l + widget.getWidth();
int b = t + widget.getHeight();
child.layout(l, t, r, b);

我们看到,顶部位置t是通过ConstraintWidget的getDrawY()方法得到的

public int getDrawY() {
    return this.mDrawY + this.mOffsetY;
}

断点调试时发现mOffsetY为0,所以我们追查mDrawY变化过程就可以了,方法就是为这个变量添加断点,这样这个变量每次改变我们都可以观察到,经过进一步调试,找到它改变的地方,ConstraintWidget中的updateDrawPosition方法:

ConstraintWidget中的updateDrawPosition方法

当然,此时数据同样已经是错误的了,子控件长度为256,因此mDrawY应该是-256,而此时是-254,我们在继续追查mY的变化过程,ConstraintWidget中的setFrame方法:

ConstraintWidget中的setFrame方法

同样,数据已经错误,错误来源于入参,我们跳出,看看何方神圣,还在ConstraintWidget中,updateFromSolver方法:

ConstraintWidget中updateFromSolver方法

数据是由mTop,经过LinearSystem取出来的,mTop就是我们讲的顶部约束点,那LinearSystem又是什么呢?直译就是线性方程系统,后面根据代码看,位置确实都是通过LinearSystem计算出来的,但是我并没有弄清具体流程,我们先不求甚解,专心追查问题。

继续,我们看一下线性方程系统如何根据锚点计算出位置的,进入LinearSystem的getObjectVariableValue方法,看代码,重点来了哦

public int getObjectVariableValue(Object anchor) {
    SolverVariable variable = ((ConstraintAnchor)anchor).getSolverVariable();
    return variable != null ? (int)(variable.computedValue + 0.5F) : 0;
}

并没有复杂计算,数据已经计算好了,这一步只是取出来,取的是ConstraintAnchor中的mSolverVariable成员,再进一步取SolverVariable的computedValue成员,等等,取出来怎么加了0.5F?

我们看到computedValue其实是浮点类型,而返回值要求整形,所以要强转,但是它并没有直接强转,而是先加0.5F再强转,那加0.5再强转是干啥?先看一下这里具体发生了什么?我们看debug:

强转debug

我们看到variable.computedValue此时其实是-255,但是经过这种强转,最后返回值是-254,看起来我们找到偏差的源头了。

浮点类型转整型是一定损失精度的,但具体的规则是怎么样的,查了一下java官方也没有具体解释,只说这是窄转换,会丢失精度,经过测试,如果是正数,会直接抹去小数位,如果是负数,小数部分不为零就会进一,而google这种方法,本意可能是为了实现四舍五入,但是没有考虑负数的情况,导致坐标为负的时候产生了偏差。

经过对源码的查看,发现代码中存在大量这种转换,每经过一次转换,位置就发生一像素的偏移。。。

至此,问题的直接原因我们已经找到了,不过,来都来了,ConstraintLayout源码再瞅两眼。下面分享一下源码查看过程中对ConstraintLayout的一些认识:

就像前面说的,ConstraintLayout只是约束布局系统的冰山一角,更大的冰山是ConstraintLayout依赖的另一个库constraintlayout-solver,这个库真正负责根据约束条件完成控件位置计算。

onLayout的时候控件位置实际已经计算完成,那计算的入口在哪里呢?实际在onMeasure里:

protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    // 省略
    if (this.getChildCount() > 0) {
        this.solveLinearSystem("First pass");
    }
    // 省略
    if (needSolverPass) {
    // 
        this.solveLinearSystem("2nd pass");
        // 省略
        if (needSolverPass) {
            this.solveLinearSystem("3rd pass");
        }
    }
    // 省略
}

onMeasure中多次调用solveLinearSystem方法,去计算控件位置:

protected void solveLinearSystem(String reason) {
    this.mLayoutWidget.layout();
    if (this.mMetrics != null) {
        ++this.mMetrics.resolutions;
    }
}

而solveLinearSystem方法,委托给mLayoutWidget来计算,这是个ConstraintWidgetContainer对象,它是ConstraintWidget的子类,它同时持有所有子控件的LayoutParameter中的ConstraintWidget对象,我们看一下它的layout方法:

public void layout() {
    // 省略
    if (this.mOptimizationLevel != 0) {
        if (!this.optimizeFor(8)) {
            this.optimizeReset();
        }
        if (!this.optimizeFor(32)) {
            this.optimize();
        }
        this.mSystem.graphOptimizer = true;
    } else {
        this.mSystem.graphOptimizer = false;
    }
    // 省略
    for(groupIndex = 0; groupIndex < groupSize && !this.mSkipSolver; ++groupIndex) {
        if (!((ConstraintWidgetGroup)this.mWidgetGroups.get(groupIndex)).mSkipSolver) {
            // 省略
           for(int i = 0; i < count; ++i) {
                ConstraintWidget widget = (ConstraintWidget)this.mChildren.get(i);
                if (widget instanceof WidgetContainer) {
                    ((WidgetContainer)widget).layout();
                }
            }
            // 省略
            while(needsSolving) {
                // 省略
                needsSolving = this.addChildrenToSolver(this.mSystem);
                if (needsSolving) {
                    this.updateChildrenFromSolver(this.mSystem, Optimizer.flags);
                } else {
                    this.updateFromSolver(this.mSystem);
                    // 省略
                }
            }
            // 省略
        }
    }
}

这个方法就比较复杂了,实际上在进入for循环之前,optimize相关的方法已经完成了子控件位置的基本计算。而for循环中,是把计算交给子控件,让他们各自根据LinearSystem去计算位置,最终会调用ConstraintWidget的addToSolver(LinearSystem system)方法。

至此就不在进一步追溯源码了,对ConstraintLayout原理已经有了直观认识,看这代码量,一脑袋下去可能十天半个月出不来。不过对这个LinearSystem还是很好奇的,具体是如何完成计算的呢?有时间再学习一下。

三、验证

经过我们的溯源,问题发生的原因是源码在浮点类型转整型的时候试图使用四舍五入的方法,但是负数的时候却会造成错误的偏差,既然这样,那就不仅仅是我们开始那种布局会有问题,所有布局时上边界和左边界超出父布局的,都会产生错误的偏移,我们来验证一下我们的猜想,布局如下

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="#29B6F6"
    tools:context=".MainActivity">

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="256dp"
        android:layout_height="256dp"
        android:layout_centerInParent="true"
        android:background="#FFF">

        <View
            android:id="@+id/clcCenter"
            android:layout_width="128dp"
            android:layout_height="128dp"
            android:background="#664CAF50"
            android:tag="Center"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintLeft_toLeftOf="parent"
            app:layout_constraintRight_toRightOf="parent" />

        <View
            android:id="@+id/clcTop"
            android:layout_width="128dp"
            android:layout_height="128dp"
            android:background="#80FF5722"
            android:tag="Top"
            app:layout_constraintBottom_toTopOf="@id/clcCenter"
            app:layout_constraintLeft_toLeftOf="parent"
            app:layout_constraintRight_toRightOf="parent" />

        <View
            android:id="@+id/clcLeft"
            android:layout_width="128dp"
            android:layout_height="128dp"
            android:background="#80C6AFEF"
            android:tag="Left"
            app:layout_constraintRight_toLeftOf="@id/clcCenter"
            app:layout_constraintTop_toTopOf="parent"
            app:layout_constraintBottom_toBottomOf="parent"/>


    </androidx.constraintlayout.widget.ConstraintLayout>

</RelativeLayout>

效果图如下

问题验证

好像有那么回事,看不清,来,放大

问题验证放大

和我们的预期相符,控件重叠了。

四、解决

问题已经清楚了,虽然不知道是bug还是google有意为之,但是问题总是要解决的。

我先试图升级到ConstraintLayout最新版本2.0.0-beta6,问题依旧,毕竟这约束条件异想天开,而且两像素的重叠难以察觉。但只要我们知道这个问题,还是很容易避免的。如果要实现那种子控件移出父控件的效果,可以通过添加translationX或translationY来实现。

ConstraintLayout这个库并没有在github上开源,没有反馈bug的途径,有方法的小伙伴可以去反馈一下或者看一下官方有没有解释。

五、后记

好在最近闲下来才有时间追查这两像素引发惨案,其实要不是示例中使用高对比度的颜色,也看不出来,要是忙的话也就放过去了。工作中放过太多这种细节了,疲于应付各种需求,没时间打磨基本功,其实每次这种追查都是一个进步的机会。

程序员朴实无华又枯燥的一天又过去了。