ConstraintLayout - 初探,以及为什么

对 ConstraintLayout 基本属性的简单梳理,以及选择它的原因

image.png

写下这篇文章之前,我在项目的 repo 里搜索了一下,发现自己使用 ConstraintLayout 已经一周年了。去年开始学习使用 ConstraintLayout 的起因是,业务上需要实现一个比较复杂的比例布局的 layout。Google deprecate 了之前推的 PercentageLayout,转而从前年的 io 大会开始推 ConstraintLayout,这让我觉得开始使用 ConstraintLayout 来做手上的事情会是一个正确的选择。事实上,经过一年的实践,我发现的确如此。我自己总结的好处有如下:

  • ConstraintLayout 在写法上更加简单

    这里的 简单 并不是单纯地指可以少写几行 xml 代码。事实上,如果算上对 layout 中子 view 的限制属性描述的话,可能表面上看需要写的 xml 代码反而是更多了。这里所说的 简单,是指开发者可以用子 view 之间的相互限制(constraint)来进行布局描述。我们在拿到设计师提供的标注图时,往往也可以用这种思考方式来进行布局的放置。

    举一个例子,子 view 的左右 margin,事实上可以看成是这个 view 和外部 view 之间的限制关系 (左边界和外部边界之间的距离是 margin);居中 ——可以看成是子 view 的 top 被 parent view 的 top 限制,子 view 的 bottom 被 parent view 的 bottom 限制。

  • ConstraintLayout 能让复杂 layout 的嵌套层级有效减少

    这是一个老生常谈的话题了。我们知道,如果一个 layout 中嵌套了太多层 ViewGroup,会造成这个 layout 在渲染的时候需要遍历更多的节点。熟悉 View 的绘制过程的话,会知道 View 在绘制过程中,会从上往下地调用 onLayout 的方法,来确定众多子 View 位置的摆放。减少 layout 中层级的嵌套,是 UI 优化中很有必要去思考,并且有许多优化空间的地方。

    在 ConstraintLayout 出来之前,我们往往会用 RelativeLayout 在做这个优化工作。而在采用了ConstraintLayout 后,我再也没有回头考虑过 RelativeLayout ……原因是, ConstraintLayout 描述子部件之间关系的方法更加直观,以及:

  • ConstraintLayout 的表现力更强

    这里的 表现力更强,指的是 ConstraintLayout 中自带的一些属性和技能。当然,在实现同样效果的时候,用 LinearLayout 可以更无脑甚至代码写得更快。但此时如果往 ConstraintLayout 想想,往往会找到 四两拨千斤 的小技巧,让开发的过程事半功倍。

关于 ConstraintLayout 的总结,探索和思考,大概会分三篇 blog 来完成,希望能把我一年来的感受表达出来。本文是这个系列的第一篇。

Tips 1:不要用拖拽工具,尽情地手写 xml 吧

ConstraintLayout 在推出之时,经常会放在一起提起的是 Android Studio 中与之配套使用的 layout editor 。我自己的使用感受是觉得这个 layout editor 并不能在日常开发中给我带来巨大的效率提升。进行一些简单的拖拽进行摆放后,细节的调整还是需要进行数值设定来微调,layout editor 让我写代码时候的状态,脑海中对于布局的理解,到最后呈现到设备上,这个路径反而是增加了的。所以我推荐不适用这个 layout editor,而是直接上手写 xml 语句,反而会让思考的过程更快。

Tips 2:限制了属性定义,但却没看到 constraint 生效 —— 很可能是没有 match constraint

如果在一个 xml 文件中写了下面的语句,目的是为了让里头的 TextView 能填满剩余的空间,同时自带一些 margin。很容易想到的是,让 TextView 的 left, top, right, bottom 都 match 到父 view 的四个方向即可,如下:

<android.support.constraint.ConstraintLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="40dp"
    >

    <TextView
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginTop="10dp"
        android:layout_marginBottom="10dp"
        android:layout_marginLeft="10dp"
        android:layout_marginRight="10dp"
        android:text="This is Tips 2"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintBottom_toBottomOf="parent"
        />

</android.support.constraint.ConstraintLayout>

但这个 layout 最终出现的效果却和预期有点儿不一样:

image.png

可以看到,子 view 四边都 match 到父 view 的四边,出现的效果是 居中,而并非一开始想象中的 贴边。实际上这正好是 ConstraintLayout 文档中描述过的关于 居中 的实现:1

When you add a constraint to both sides of a view (and the view size for the same dimension is either "fixed" or "wrap content"), the view becomes centered between the two constraints with a bias of 50% by default.

那么要如何实现 贴边 的效果呢?这里可以把 TextView 的 layout_width 设置成 0dp,从而让 TextView 的四边能够展开,实现对 constraint 的完全符合。这里实现的是文档中 match constraint 的效果。具体来说的话,ConstraintLayout 中的子 view 可以有三种计算宽高的模式:

  • Fix. 顾名思义,就是完全按照 xml 中 layoutheight 和 layoutwidth 的描述来计算宽高;
  • Wrap Content. 也很好理解,子 view 没有具体设定宽高数值,但希望能对 view 的内容包裹;
  • Match Constraints. 子 view 会让四边尽量地延展,来符合 constraint 对子 view 的限制。

我在 Telegram 上的 AndroidDevCN 群组中就遇到了有人提问这个问题。通过设置 layout_width 为 0dp,就可以实现 match constraints 的效果,来让 xml 的布局符合自己的设计。

这里也许会有人问,使用 match_parent 是否能够实现想要的效果呢?在上面举例的这个场景中是可以的。但 ConstraintLayout 的文档中,明确地指出来了应该避免在 ConstraintLayout 中使用 match_parent 的属性值:

Note: You cannot use match_parent for any view in a ConstraintLayout. Instead use "match constraints" (0dp).

我的理解是,一个 view 最终在 ViewGroup 中的实际大小,除了跟这个 view 自己的 measureSpec 有关外,也和 parent view 对这个子 view 的限制有关。在 《Android 开发艺术探索》一书中,有过一个详细的表格,分别谈论了 parent view 和子 view 在设定了不同的参数后,会对子 view 最终 size 的影响。ConstraintLayout 里头对于 match_parent 这个数值的判定应该在那些显式声明的 attributes 描述之下,更能影响 ConstraintLayout 中布局的应该是那些 constraint 属性。所以文档中才会更推荐使用 0dp 这样并不是很直观的写法。

最后附上改 layout_width 的值为 0dp 后的效果:

image.png

Tips3: 巧用 chain,快速实现一个方向上的平均分布

让我们从 Tips2 的例子进行拓展。Tips2 中描述的只有一个 view,在 ConstraintLayout 中,四边都分别与父 view 的边界互相限制,最终实现的是居中的效果:

image.png

(为了方便举例,这里适当地加了一些 padding,同时去掉了 margin)

那么如果在 A 的右边新增一个 B,在 layout 中:

  • A 的 right 与 B 的 left 对齐,
  • B 的 left 与 A 的 right 对齐,
  • B 的 right 与 parent 的 right 对齐

根据 Tips2 中 居中 的实现,可以做一个延伸。可以合理地推断,A 和 B 会把父 view 在水平方向上平分,事实效果如下:

image.png

为什么会出现这样的效果呢?我是从 Tips2 中 居中 的效果来进行推断的。让我们假设,parent 最左边的点称之为 left,最右边的点称之为 right。根据 Tips2 中 xml 的表述,在 Tips3 中,A 的布局描述其实跟 Tips2 中非常类似,不同的地方只有一个:

A 的右边界对齐的点变了,变成了 B 的左边界。

那么我们可以推断:A 应该在 left 点和 B 点之间的位置是居中的。即 left-A 的长度,和 A-B 的长度应该是相等的。 同理,对 B 来说,A-B 的长度,和 B-right 的长度,也是相等的。 于是就很容易得到如上图这样的结果了。

leftAB,和 right,无形中像是有一条由彼此之间的“约束” (constraint) 串起来的链子。A 和 B 变成了链子上的两个关键节点,把整个父 view 均匀地进行三等分。

以上就是对 ConstraintLayout 中 chain 用法的简单介绍了。2

###Tips4: chain 的拓展用法,多种形态的 chain 分布 在 Tips3 的基础上进行升级吧!假如类似 Tips3,在 B 的右边,再放置一个 C,会出现什么情况呢?很容易推断出来,A,B 和 C 会成为 chain 上平分的节点,共同把父 view 分割成四等份。如下图所示:

image.png

但 chain 可以客制化的地方远不止如此。之前演示的只是 chain 的默认形态。实际上 chain 一共有四种摆放子 view 的形式,如下图所示:3

image.png
  • Spread . 即散开分布。一条 chain 上的子 view 会平均分布,如之前的演示所示。这也是 chain 的默认表现。
  • Spread Inside. 左右两个端点的 view 会贴近 parent 的边,内部的其他子 view 会均分。
  • Weighted. 一个我觉得很好用的方法。之前的演示中,子 view 之间存在着并未被填满的空间。而通过设定 layout_constraintHorizontal_weight (或者是 vertical 方向上的 layout_constraintVertical_weight),可以为子 view 设定水平方向上占的 weight。类似 LinearLayout 中的 layout_weight 属性能够实现的效果。
  • Packed. 这个比较简单,就是子 view 不会分散开,而是会挤在中间的位置。

我认为上面的四种形态中,SpreadPacked 是互相呼应的,正好实现的是相反的效果。Weighted 则是非常强大而且经常用到的一种,日常开发中很可能会经常使用到,用来做代替 LinearLayout 的 layout_weight 实现。举例,想通过 Weighted 来实现下面的布局:

image.png

尝试使用以下代码:

(为了对比明显,我为 TextView 设置了一些样式)

<androidx.constraintlayout.widget.ConstraintLayout
    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="40dp"
    tools:context=".MainActivity">

    <TextView
        android:id="@+id/btn_a"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="A"
        android:textColor="@android:color/white"
        android:textStyle="bold"
        android:gravity="center"
        android:background="@android:color/holo_green_dark"
        app:layout_constraintHorizontal_weight="2"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toStartOf="@id/btn_b"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintBottom_toBottomOf="parent"
        />

    <TextView
        android:id="@+id/btn_b"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="B"
        android:textColor="@android:color/black"
        android:textStyle="bold"
        android:gravity="center"
        android:background="@android:color/holo_orange_dark"
        app:layout_constraintHorizontal_weight="1"
        app:layout_constraintStart_toEndOf="@id/btn_a"
        app:layout_constraintEnd_toStartOf="@id/btn_c"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintBottom_toBottomOf="parent"
        />

    <TextView
        android:id="@+id/btn_c"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="C"
        android:textColor="@android:color/black"
        android:textStyle="bold"
        android:gravity="center"
        android:background="@android:color/darker_gray"
        app:layout_constraintHorizontal_weight="1"
        app:layout_constraintStart_toEndOf="@id/btn_b"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintBottom_toBottomOf="parent"
        />

</androidx.constraintlayout.widget.ConstraintLayout>

然而,上面的代码并没有实现我们想要的效果。编译后看到的效果是这样的:

image.png

为什么不能出现我们想要的效果呢?似乎设置的 constraint 全都没有生效?

答案在 Tips2 中。这里 constraint 没有生效,主要是因为各个 TextView 的 left, top, right 和 bottom 四边仍然是默认的状态,并没有听话地跟着设置的 constraint 去往各个方向延展开。

只要把各个子 view 的 layout_widthlayout_height 设置成 0dp,就可以实现我们想要的分布效果。

似乎跟 LinearLayout 直接设置 layout_weight 的方法比起来,有些太繁琐了?

的确如此。一眼望过去,ConstraintLayout 要实现一个 LinearLayout 几行 xml 语句就能实现的效果,似乎总要多写一些代码。但同时,ConstraintLayout 从 1.1.2 版本开始,提供了一个更直观的属性,可以更方便地设置在一个方向上的百分比布局。用法如下:

app:layout_constraintWidth_percent="0.25"

相比起 LinearLayout 中对 layout_weight 的计算和掂量,这种写法的感觉直观了不少。

Tips5:margin 的方向性

日常业务中经常遇到的情况是,并不一定需要指定某个子 view 和 parent 的边界完全对齐。也就是说,子 view 的 left, top, right, bottom 有时候某一个的 constraint 可以缺省。

举个例子,下图中的 A 与 B 的摆放,只要求都放在左边,与 parent 的左边界对齐:

image.png

很容易写出下面这样的 xml 描述:

<androidx.constraintlayout.widget.ConstraintLayout
    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="40dp"
    tools:context=".MainActivity">

    <TextView
        android:id="@+id/btn_a"
        android:layout_width="90dp"
        android:layout_height="0dp"
        android:text="A"
        android:textColor="@android:color/white"
        android:textStyle="bold"
        android:gravity="center"
        android:background="@android:color/holo_green_dark"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toStartOf="@id/btn_b"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintBottom_toBottomOf="parent"
        />

    <TextView
        android:id="@+id/btn_b"
        android:layout_width="90dp"
        android:layout_height="0dp"
        android:text="B"
        android:textColor="@android:color/black"
        android:textStyle="bold"
        android:gravity="center"
        android:background="@android:color/holo_orange_dark"
        app:layout_constraintStart_toEndOf="@id/btn_a"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintBottom_toBottomOf="parent"
        />

</androidx.constraintlayout.widget.ConstraintLayout>

如果在这个时候,设计师要求** A 与 B 之间有一个 10dp 的 margin**,应该怎么做呢?试着在 A 的属性中加上一句 layout_marginRight="10dp" ,效果如下:

image.png

发现并没有出现想要的 margin。打开了 show layout bound 的 debug 开关后,可以看到实际上 A 的右侧是有 margin 的,但和 B 的左侧重叠在一起了。

为什么会出现这种现象呢?

我没有在文档中找到一条针对这种情况的解释,也没有在 StackOverflow 上找到类似的回答。我自己的理解的话,可能是 margin 在 ConstraintLayout 中也具有方向性。我们在 xml 中,设置了 A 右侧的 margin。但仔细审视 xml 中的描述,可以看到,

A 右侧出发,到 B 的左侧,再到 B 的右侧出发,到 parent 的右侧

这条路径上的限制是不明确的。原因是 B 的右侧的 constraint 并没有限制,我们在 xml 中省略了。

从这个思路出发,要怎么设置上 A 与 B 之间的 margin 呢?也并不难想到——A 的右侧的限制是不确定的,但 B 的左侧,穿过 A,一直到 parent 的 left border,这个方向上的限制是确定的。所以可以尝试,改成设置 B 的 layout_marginLeft="10dp",效果如图:

image.png

可以看到成功实现了 A B 之间的 margin 设置。

拓展:还有其他方法能实现这个效果吗?有的:

Tips6: 设置 bias 来控制水平 (or 垂直) 方向上的放置

根据 Tips5 中的推论,其实只要保证 A 右边方向出发的 constraint 是确定的,那么对 A 设置 marginRight 应该也能生效。我们尝试如下的 xml:

<androidx.constraintlayout.widget.ConstraintLayout
    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="40dp"
    tools:context=".MainActivity">

    <TextView
        android:id="@+id/btn_a"
        android:layout_width="90dp"
        android:layout_height="0dp"
        android:layout_marginRight="10dp"
        android:text="A"
        android:textColor="@android:color/white"
        android:textStyle="bold"
        android:gravity="center"
        android:background="@android:color/holo_green_dark"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toStartOf="@id/btn_b"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintBottom_toBottomOf="parent"
        />

    <TextView
        android:id="@+id/btn_b"
        android:layout_width="90dp"
        android:layout_height="0dp"
        android:text="B"
        android:textColor="@android:color/black"
        android:textStyle="bold"
        android:gravity="center"
        android:background="@android:color/holo_orange_dark"
        app:layout_constraintStart_toEndOf="@id/btn_a"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"  // <--  B 的右侧和 parent 对齐
        />

</androidx.constraintlayout.widget.ConstraintLayout>

效果如下图:

image.png

啊,和想要的效果相差甚远。

这里的原因是,我们设置上 B 右侧与 parent 对齐后,这个布局就变成了 Tips3 中的 chain 了,并且触发的是 chain 的默认样式,Spread 。

先尝试把 chain 的样式从 Spread 换成 Packed 吧!给 A 加上一句 app:layout_constraintHorizontal_chainStyle="packed" 试试,效果如下图:

image.png

成功了一半。接下来只要让 A 在水平方向上左对齐即可。

要实现 A 在水平方向上做对齐,需要用到 bias 的属性。当我们设定了一个子 view 的左右 constraint 分别与 parent 的左右 border 对齐时,会触发居中的效果。其实 ConstraintLayout 在此时就提供了 layout_constraintHorizontal_bias 的属性,能够控制子 view 在水平方向上的位置。默认这个属性的值是 0.5,所以就是居中的效果了;如果设置为 0,那么就会和 parent 左边界贴近。4

回到我们的需求,给 A 加上 app:layout_constraintHorizontal_bias="0" ,效果如下:

image.png

完美地解决了问题。可以看到,针对同一个业务上的需要,ConstraintLayout 非常灵活,往往会有多种实现的方案。


  1. 出处为 ConstraintLayout 文档的这一章节 

  2. 出处为 ConstraintLayout 文档的这一章节 

  3. 出处为 ConstraintLayout 文档的这一章节 

  4. 出处为 ConstraintLayout 文档 ,搜索 Bias 关键字可见