187

超长干货! 为你详解ConstraintLayout 源码分析与京东 App 中的实践

 3 years ago
source link: http://mp.weixin.qq.com/s?__biz=MzUyMDAxMjQ3Ng%3D%3D&%3Bmid=2247494156&%3Bidx=1&%3Bsn=e1402c29aa259c66c34e04ff870a5bd4
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

ConstraintLayout 可以使用扁平的视图层次结构来构建复杂的大型布局,而不需要使用嵌套的 ViewGroup,它与 RelativeLayout 相似,但灵活度更高,且可以更方便地使用可视化布局编辑器。得益于其扁平化的布局方式,我们可以减少布局层级,优化渲染性能;其灵活度高,在需求迭代时,可以轻易地实现布局的变更;其可视化编辑,也对刚上手 Android 开发的同学更加友好;相信大家在项目中已经越来越多地使用 ConstraintLayout,既然经常使用,我们就应该做到知其所以然。本文就与大家一起,学习 ConstraintLayout 源码,了解其布局原理。

我们将先通过示例布局了解 ConstraintLayout 布局的总体过程,然后对其功能逐一介绍,学习相关功能并了解其实现原理,最后以几个布局为例,回顾源码,具体实践。

布局原理总览

当我们创建一个新的 Android 项目时,一般会生成如下的布局文件及其预览:

640?wx_fmt=png

可以看到,为实现一个居中效果,需要 4 个属性配合才完成,这是因为在 ConstraintLayout 中,通常每一个 View 都需要至少两个约束才能定位,一个水平约束,一个垂直约束。以水平约束为例, Left_toLeftOf parent 配合 Right_toRightOf parent 就实现了水平居中,下面我们就以水平居中的实现过程为例,使用 1.1.3 的源码,学习一下 ConstraintLayout 的布局过程。

androidx.constraintlayout:constraintlayout:1.1.3

com.android.support.constraint:constraint-layout:1.1.3

为方便调试时查看数值,我们设置宽高时直接使用 px 作为单位,id 分别命名为 root、view1、view2 等:

640?wx_fmt=png

类图总览

首先,我们先整体看一下 ConstraintLayout 相关的类图,图中会注明具体类的作用,后文中如果有不太清晰的地方,可以回头看一下该类图,方便理解。

640?wx_fmt=png

1.1

onMeasure 和 onLayout

我们知道 ViewGroup 主要重写 onMeasure 和 onLayout 方法,我们先看一下 onLayout 的实现。

ConstraintLayout#onLayout
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
final int widgetsCount = getChildCount();
final boolean isInEditMode = isInEditMode();
for (int i = 0; i < widgetsCount; i++) {
final View child = getChildAt(i);
LayoutParams params = (LayoutParams) child.getLayoutParams();
ConstraintWidget widget = params.widget; // 从布局参数中取 ConstraintWidget
if (child.getVisibility() == GONE && !params.isGuideline && !params.isHelper && !isInEditMode) {
continue;
}
if (params.isInPlaceholder) {
continue;
}
int l = widget.getDrawX(); // 计算 l t r b
int t = widget.getDrawY();
int r = l + widget.getWidth();
int b = t + widget.getHeight();
child.layout(l, t, r, b); // 直接调用 layout 方法
if (child instanceof Placeholder) {
...
}
}
...
}

可以看到,在 onLayout 中,遍历 child,然后从布局参数中取出 ConstraintWidget,调用 getDrawX()、getDrawY()、getWidth()、getHeight() 就可以计算 left、top、right、bottom,然后直接调用 child.layout 就完成了布局,因此关键是看 ConstraintWidget 如何完成相关属性的计算的,主要计算过程在 onMeasure 中完成:

ConstraintLayout#onMeasure
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
...
boolean runAnalyzer = false;
if (mDirtyHierarchy) { // 在 requestLayout onViewAdded onViewRemoved 赋值
mDirtyHierarchy = false;
updateHierarchy(); // 1.2 建立约束
runAnalyzer = true;
}
...
internalMeasureChildren(widthMeasureSpec, heightMeasureSpec); // 内部测量 children
...
if (getChildCount() > 0) {
solveLinearSystem("First pass"); // 1.3 求解
}
...

1.2

建立约束

可以看到在 onMeasure 中会判断 mDirtyHierarchy,如果认为布局层次结构为脏,则会调用 updateHierarchy() 更新层次结构。mDirtyHierarchy 在 requestLayout()、onViewAdded()、onViewRemoved() 等方法中赋值,显然,当调用相关方法的时候,布局层次已经发生了变化,需要更新,以 requestLayout() 为例:

ConstraintLayout#requestLayout
public void requestLayout() {
super.requestLayout();
mDirtyHierarchy = true;
...
}

继续看 updateHierarchy() 方法:

ConstraintLayout#updateHierarchy
private void updateHierarchy() {
final int count = getChildCount();
boolean recompute = false;
for (int i = 0; i < count; i++) {
final View child = getChildAt(i);
if (child.isLayoutRequested()) {
recompute = true;
break;
}
}
if (recompute) {
mVariableDimensionsWidgets.clear();
setChildrenConstraints(); // 设置子 view 约束
}
}

updateHierarchy() 遍历所有子 view,如果有子 view 请求布局,则会调用 setChildrenConstraints() 设置所有子 view 的约束。

ConstraintLayout#setChildrenConstraints
private void setChildrenConstraints() {
...
for (int i = 0; i < count; i++) { // 对所有 child 的 widget 执行 reset()
View child = getChildAt(i);
ConstraintWidget widget = getViewWidget(child);
if (widget == null) {
continue;
}
widget.reset();
}
...
mLayoutWidget.removeAllChildren(); // 移除所有子 widget
...
for (int i = 0; i < count; i++) {
final View child = getChildAt(i);
ConstraintWidget widget = getViewWidget(child);
if (widget == null) {
continue;
}
final LayoutParams layoutParams = (LayoutParams) child.getLayoutParams();
layoutParams.validate();
...
widget.setVisibility(child.getVisibility());
if (layoutParams.isInPlaceholder) {
widget.setVisibility(View.GONE);
}
widget.setCompanionWidget(child);
mLayoutWidget.add(widget); // 添加进控件容器
if (layoutParams.isGuideline) {
...
} else if ((layoutParams.leftToLeft != UNSET)
...) {
// Get the left/right constraints resolved for RTL
int resolvedLeftToLeft = layoutParams.resolvedLeftToLeft;
...
// Circular constraint
if (layoutParams.circleConstraint != UNSET) {
ConstraintWidget target = getTargetWidget(layoutParams.circleConstraint);
if (target != null) {
widget.connectCircularConstraint(target, layoutParams.circleAngle, layoutParams.circleRadius);
}
} else {
// Left constraint
if (resolvedLeftToLeft != UNSET) {
ConstraintWidget target = getTargetWidget(resolvedLeftToLeft);
if (target != null) { // 连接锚点
widget.immediateConnect(ConstraintAnchor.Type.LEFT, target,
ConstraintAnchor.Type.LEFT, layoutParams.leftMargin,
resolveGoneLeftMargin);
}
} else if (resolvedLeftToRight != UNSET) {
ConstraintWidget target = getTargetWidget(resolvedLeftToRight);
if (target != null) {
widget.immediateConnect(ConstraintAnchor.Type.LEFT, target,
ConstraintAnchor.Type.RIGHT, layoutParams.leftMargin,
resolveGoneLeftMargin);
}
}

根据之前的类图,我们知道每一个子 View 的 LayoutParams 都含有一个 ConstraintWidget,每一个 ConstraintLayout 都有一个 mLayoutWidget(ConstraintWidgetContainer),它作为 ConstraintWidget 的容器。

在 ConstraintLayout 中,并不是直接对 ViewGroup 和 View 进行操作,而是对ConstraintWidgetContainer(mLayoutWidget)和 ConstraintWidget(从 LayoutParams 中取)进行操作。

在 setChildrenConstraints() 设置约束过程中,会移除所有 childWidget,然后遍历 child 重新添加。

在添加过程中,如果 child 有约束,会建立相关的约束,以示例布局为例,调用 widget.immediateConnect 将控件连接到锚点,也是 view:left 连接到 root:left,我们看一下 immediateConnect 方法:

ConstraintWidget#immediateConnect
public void immediateConnect(ConstraintAnchor.Type startType, ConstraintWidget target,
ConstraintAnchor.Type endType, int margin, int goneMargin) {
ConstraintAnchor startAnchor = getAnchor(startType); // 当前 widget 的 left
ConstraintAnchor endAnchor = target.getAnchor(endType); // target 的 left,
startAnchor.connect(endAnchor, margin, goneMargin, ConstraintAnchor.Strength.STRONG,
ConstraintAnchor.USER_CREATOR, true);
}
ConstraintAnchor#connect
public boolean connect(ConstraintAnchor toAnchor, int margin, int goneMargin,
Strength strength, int creator, boolean forceConnection) {
...
mTarget = toAnchor; // 赋值了 mTarget
if (margin > 0) {
mMargin = margin; // 设置 margin
} else {
mMargin = 0; // 可以看到负的 margin 无效
}
mGoneMargin = goneMargin; // 设置 goneMargin
mStrength = strength;
mConnectionCreator = creator;
return true;
}

经过相关的连接方法,控件的 mLeft(ConstraintAnchor)和容器的 mLeft 建立了连接,即产生了约束,接下来会进行求解。

1.3

求解

求解过程主要在 ConstraintWidgetContainer 的 layout 方法中完成:

ConstraintLayout#solveLinearSystem
protected void solveLinearSystem(String reason) {
mLayoutWidget.layout();
if (mMetrics != null) {
mMetrics.resolutions++;
}
}
ConstraintWidgetContainer#layout
public void layout() {
...
optimize(); // 1.3.1 使用优化器求解
...
// 1.3.2 使用求解器求解
needsSolving = addChildrenToSolver(mSystem); // 1.3.2.1 将 children 添加到求解器
...
updateChildrenFromSolver(mSystem, Optimizer.flags); // 1.3.2.2 从求解器更新
...
updateDrawPosition(); // 1.3.3 更新绘制位置
}

LinearSystem 表示一个线性方程组,本文中也将其称为求解器,对应 Optimizer 表示的优化器。LinearSystem 有各种等式可以表示各约束间的关系,比如:

root:right = root:left + root:width
view:left = root:left

但是并不是所有的约束都需要完全用求解器求解,在此之前,可以根据配置的优化级别进行优化,比如默认的优化级别会对直接连接进行优化,也就是说 view:left = root:left,只要 root 的 left 解析出来,就可以得到 view 的 left,同理root:right = root:left + root:width 也一样。

与求解相关的类图如下:

640?wx_fmt=png

1.3.1 使用优化器求解

ConstraintWidgetContainer#optimize
public void optimize() {
...
if (!optimizeFor(Optimizer.OPTIMIZATION_DIMENSIONS)) {
analyze(mOptimizationLevel); // 分析
}
...
solveGraph(); // 解图
}
ConstraintWidgetContainer#analyze
public void analyze(int optimizationLevel) {
super.analyze(optimizationLevel); // 1.3.1.1 分析容器自身
final int count = mChildren.size();
for (int i = 0; i < count; i++) {
mChildren.get(i).analyze(optimizationLevel); // 1.3.1.2 分析各个 child
}
}
ConstraintWidget#analyze
public void analyze(int optimizationLevel) {
Optimizer.analyze(optimizationLevel,this);
}

analyze 是 ConstraintWidget 的方法,因此作为容器类,ConstraintWidgetContainer 会先调用 super.analyze 分析自身,然后遍历 children 分析各个 child,两个分析方法都很具代表性,我们以示例布局依次查看一下。

1.3.1.1 分析容器自身

 Optimizer#analyze
static void analyze(int optimisationLevel, ConstraintWidget widget) {
widget.updateResolutionNodes();
...
if (widget.mLeft.mTarget == null && widget.mRight.mTarget == null) { // Container 左右为 null
leftNode.setType(ResolutionAnchor.DIRECT_CONNECTION); // 直接连接
rightNode.setType(ResolutionAnchor.DIRECT_CONNECTION);
if (optimiseDimensions) {
rightNode.dependsOn(leftNode, 1, widget.getResolutionWidth());
} else {
rightNode.dependsOn(leftNode, widget.getWidth()); // 右结点依赖于左结点,offset 为宽度
}
}
}

由于容器的 mLeft.mTarget 和 mRight.mTarget 都为 null,所以将其类型设置为 DIRECT_CONNECTION,同时设置右结点依赖于左结点,offset 为控件的宽度。

这容易理解,当我们确定了 left,那么加上控件宽度,就得到了 right,所以 right 依赖于 left,我们看一下 dependsOn 方法:

ResolutionAnchor#dependsOn(ResolutionAnchor, int)
public void dependsOn(ResolutionAnchor node, int offset) {
target = node;
this.offset = offset;
target.addDependent(this);
}
ResolutionNode#addDependent
public void addDependent(ResolutionNode node) {
dependents.add(node);
}

添加依赖的方法也很简单,right 依赖于 left,则将 right 的 target(依赖目标)设为 left, left 的 dependents(被依赖对象集合,即 left 被哪些控件依赖)中添加 right

了解 RelativeLayout 源码的同学应该可以发现,在处理依赖时,基本原理是类似的,都是分析出依赖图。ResolutionNode 类似于 RelativeLayout 中的 DependencyGraph.Node,用 dependencies 保存依赖对象,用 dependents 保存被依赖对象,只是 ConstraintWidget 的依赖对象已经细分为了 mLeft、mRight 等,所以依赖对象只需要一个 target 即可表示。

1.3.1.2 分析 children

 static void analyze(int optimisationLevel, ConstraintWidget widget) {
widget.updateResolutionNodes(); // 更新求解结点
...else if (widget.mLeft.mTarget != null && widget.mRight.mTarget != null) { // 左右均不为空
leftNode.setType(ResolutionAnchor.CENTER_CONNECTION); // 居中连接
rightNode.setType(ResolutionAnchor.CENTER_CONNECTION);
if (optimiseDimensions) {
widget.getResolutionWidth().addDependent(leftNode);
widget.getResolutionWidth().addDependent(rightNode);
leftNode.setOpposite(rightNode, -1, widget.getResolutionWidth());
rightNode.setOpposite(leftNode, 1, widget.getResolutionWidth());
} else {
leftNode.setOpposite(rightNode, -widget.getWidth()); // 设置相对结点
rightNode.setOpposite(leftNode, widget.getWidth());
}
}
}
ConstraintWidget#updateResolutionNodes
public void updateResolutionNodes() {
for (int i = 0; i < 6; i++) {
mListAnchors[i].getResolutionNode().update();
}
}
ResolutionAnchor#update
public void update() {
ConstraintAnchor targetAnchor = myAnchor.getTarget();
if (targetAnchor == null) {
return;
}
if (targetAnchor.getTarget() == myAnchor) {
type = CHAIN_CONNECTION; // 链
targetAnchor.getResolutionNode().type = CHAIN_CONNECTION;
}
int margin = myAnchor.getMargin();
if (myAnchor.mType == ConstraintAnchor.Type.RIGHT
|| myAnchor.mType == ConstraintAnchor.Type.BOTTOM) {
margin = -margin;
}
dependsOn(targetAnchor.getResolutionNode(), margin);
}

之前我们在 updateHierarchy() 中的 setChildrenConstraints() 看到,通过连接方法,childWidget 的 left 会连接 Container 的 left。因此在 updateResolutionNodes() 中,会添加为依赖,将 childWidget 的 left 设置为依赖 Container 的 left

同时,由于 childWidget 的 left 和 right 都不为 null,在 analyze() 中会将 type 设置为 CENTER_CONNECTION,同时设置相对结点。

经过 analyze 过程,整个依赖图就分析完成了,示例布局得到如下依赖图:

640?wx_fmt=png

1.3.1.3 解图

在 ConstraintWidgetContainer.optimize() 方法中,在 analyze 分析完后,有了依赖图,就可以进行下一步,根据分析的依赖图进行解图。

求解过程如下,绿色箭头表示已求解。

640?wx_fmt=png

ConstraintWidgetContainer#solveGraph
public void solveGraph() {
ResolutionAnchor leftNode = getAnchor(ConstraintAnchor.Type.LEFT).getResolutionNode();
ResolutionAnchor topNode = getAnchor(ConstraintAnchor.Type.TOP).getResolutionNode();
leftNode.resolve(null, 0);
topNode.resolve(null, 0);
}

以 leftNode 开始,求解水平方向的依赖。

以 topNode 开始,求解垂直方向的依赖。

ResolutionAnchor#resolve(ResolutionAnchor, float)
public void resolve(ResolutionAnchor target, float offset) {
if (state == UNRESOLVED || (resolvedTarget != target && resolvedOffset != offset)) {
resolvedTarget = target; // 赋值 resolvedTarget 和 resolvedOffset
resolvedOffset = offset;
if (state == RESOLVED) {
invalidate();
}
didResolve();
}
}
ResolutionNode#didResolve
public void didResolve() {
state = RESOLVED; // 标记为已解决
for (ResolutionNode node : dependents) { // 求解所有被依赖项
node.resolve();
}
}

将状态标记为已解决后,使用深度优先方式,遍历所有依赖当前结点的结点,调用 resolve() 进行求解,ResolutionAnchor.resolve() 重写了方法,根据连接类型,使用 target 计算 resolvedTarget 和 resolvedOffset:

ResolutionAnchor#resolve()
public void resolve() {
...
if (type == DIRECT_CONNECTION
&& ((target == null) || (target.state == RESOLVED))) {
if (target == null) {
resolvedTarget = this;
resolvedOffset = offset;
} else {
resolvedTarget = target.resolvedTarget;
resolvedOffset = target.resolvedOffset + offset; // root:right 是直接连接,加上 offset,得到 1080
}
didResolve(); // 解析完成
} else if (type == CENTER_CONNECTION // view 的 left 和 right 是居中连接
&& target != null
&& target.state == RESOLVED
&& opposite != null && opposite.target != null
&& opposite.target.state == RESOLVED) {
if (LinearSystem.getMetrics() != null) {
LinearSystem.getMetrics().centerConnectionResolved++;
}
resolvedTarget = target.resolvedTarget;
opposite.resolvedTarget = opposite.target.resolvedTarget;
float distance = 0;
float percent = 0.5f;
boolean isEndAnchor = myAnchor.mType == Type.RIGHT || myAnchor.mType == Type.BOTTOM;
if (isEndAnchor) {
// we are right or bottom
distance = target.resolvedOffset - opposite.target.resolvedOffset; // 得到 1080
} else {
distance = opposite.target.resolvedOffset - target.resolvedOffset;
}
if (myAnchor.mType == ConstraintAnchor.Type.LEFT
|| myAnchor.mType == ConstraintAnchor.Type.RIGHT) {
distance -= myAnchor.mOwner.getWidth(); // 减去自身宽度 200
percent = myAnchor.mOwner.mHorizontalBiasPercent; // 0.5
} else {
distance -= myAnchor.mOwner.getHeight();
percent = myAnchor.mOwner.mVerticalBiasPercent;
}
int margin = myAnchor.getMargin();
int oppositeMargin = opposite.myAnchor.getMargin();
if (myAnchor.getTarget() == opposite.myAnchor.getTarget()) {
percent = 0.5f;
margin = 0;
oppositeMargin = 0;
}
distance -= margin;
distance -= oppositeMargin;
if (isEndAnchor) {
// we are right or bottom
opposite.resolvedOffset = opposite.target.resolvedOffset
+ oppositeMargin + distance * percent; // 对方为 880*0.5 =440
resolvedOffset = target.resolvedOffset - margin - (distance * (1 - percent)); // 自身为 1080 - 440 = 640
} else {
resolvedOffset = target.resolvedOffset + margin + distance * percent;
opposite.resolvedOffset = opposite.target.resolvedOffset
- oppositeMargin - (distance * (1 - percent));
}
didResolve(); // 自身解析完成
opposite.didResolve(); // 对方解析完成
}

至此 optimize() 方法结束,分析出了各个 View 的依赖,求解出了 resolvedTarget 和 resolvedOffset

1.3.2 使用求解器求解

1.3.2.1 添加到求解器

ConstraintWidgetContainer#addChildrenToSolver
public boolean addChildrenToSolver(LinearSystem system) {
addToSolver(system); // 添加 Container
final int count = mChildren.size();
for (int i = 0; i < count; i++) {
ConstraintWidget widget = mChildren.get(i);
if (widget instanceof ConstraintWidgetContainer) {
...
} else {
Optimizer.checkMatchParent(this, system, widget);
widget.addToSolver(system); // 添加 child
}
}
ConstraintWidget#addToSolver
public void addToSolver(LinearSystem system) {
...
if (mHorizontalResolution != DIRECT) {
SolverVariable parentMax = mParent != null ? system.createObjectVariable(mParent.mRight) : null;
SolverVariable parentMin = mParent != null ? system.createObjectVariable(mParent.mLeft) : null;
applyConstraints(system, horizontalParentWrapContent, parentMin, parentMax, mListDimensionBehaviors[DIMENSION_HORIZONTAL], wrapContent,
mLeft, mRight, mX, width,
mMinWidth, mMaxDimension[HORIZONTAL], mHorizontalBiasPercent, useHorizontalRatio,
inHorizontalChain, matchConstraintDefaultWidth, mMatchConstraintMinWidth, mMatchConstraintMaxWidth, mMatchConstraintPercentWidth, applyPosition);
}
ConstraintWidget#applyConstraints
SolverVariable begin = system.createObjectVariable(beginAnchor);
SolverVariable end = system.createObjectVariable(endAnchor);
SolverVariable beginTarget = system.createObjectVariable(beginAnchor.getTarget());
SolverVariable endTarget = system.createObjectVariable(endAnchor.getTarget());
if (system.graphOptimizer) {
if (beginAnchor.getResolutionNode().state == ResolutionAnchor.RESOLVED
&& endAnchor.getResolutionNode().state == ResolutionAnchor.RESOLVED) {
if (system.getMetrics() != null) {
system.getMetrics().resolvedWidgets++;
}
beginAnchor.getResolutionNode().addResolvedValue(system);
endAnchor.getResolutionNode().addResolvedValue(system);
if (!inChain && parentWrapContent) {
system.addGreaterThan(parentMax, end, 0, SolverVariable.STRENGTH_FIXED);
}
return;
}
}

使用 beginAnchor 和 endAnchor,分别获取 ResolutionAnchor 调用 addResolvedValue(system),水平方向传参就为 mLeft 和 mRight 当添加 Container 的 mLeft 和 mRight 时,由于 resolvedTarget 为 null,会使用 resolvedOffset 添加等式。

ResolutionAnchor#addResolvedValue
void addResolvedValue(LinearSystem system) {
SolverVariable sv = myAnchor.getSolverVariable();
if (resolvedTarget == null) {
system.addEquality(sv, (int) (resolvedOffset + 0.5f));
} else {
SolverVariable v = system.createObjectVariable(resolvedTarget.myAnchor);
system.addEquality(sv, v, (int) (resolvedOffset + 0.5f), SolverVariable.STRENGTH_FIXED);
}
}
LinearSystem#addEquality(SolverVariable, int)
public void addEquality(SolverVariable a, int value) {
int idx = a.definitionId;
if (a.definitionId != -1) {
...
} else {
ArrayRow row = createRow();
row.createRowDefinition(a, value);
addConstraint(row);
}
}
ArrayRow#createRowDefinition
ArrayRow createRowDefinition(SolverVariable variable, int value) {
this.variable = variable;
variable.computedValue = value;
constantValue = value;
isSimpleDefinition = true;
return this;
}

至此来自于解析锚点 ResolutionAnchor 的 resolvedOffset 被赋值给了约束锚点 ConstraintAnchor 的 mSolverVariable 的 computedValue 查看输出,我们可以看到最终添加的结果为。

Display Rows (8x11)
# Root.left:0 = 0.0
# Root.right:0 = 1080.0
# Root.top:0 = 0.0
# Root.bottom:0 = 2096.0
# view.left:0 = 440.0
# view.right:0 = 640.0
# view.top:0 = 948.0
# view.bottom:0 = 1148.0
# 0 = 0.0

1.3.2.3 从求解器更新

这一部分非常简单,从约束锚点的 mLeft、mRight 中取出 SolverVariable,并取出之前赋值的 computedValue 然后就可以计算宽和高,最后调用 setFrame 设置 mX、mY、mWidth、mHeight。

ConstraintWidgetContainer#updateChildrenFromSolver
public void updateChildrenFromSolver(LinearSystem system, boolean flags[]) {
flags[Optimizer.FLAG_RECOMPUTE_BOUNDS] = false;
updateFromSolver(system); // 容器更新
final int count = mChildren.size();
for (int i = 0; i < count; i++) {
ConstraintWidget widget = mChildren.get(i);
widget.updateFromSolver(system); // child 更新
...
}
}
ConstraintWidget#updateFromSolver
public void updateFromSolver(LinearSystem system) {
int left = system.getObjectVariableValue(mLeft);
int top = system.getObjectVariableValue(mTop);
int right = system.getObjectVariableValue(mRight);
int bottom = system.getObjectVariableValue(mBottom);
int w = right - left;
int h = bottom - top;
...
setFrame(left, top, right, bottom);
}
LinearSystem#getObjectVariableValue
public int getObjectVariableValue(Object anchor) {
SolverVariable variable = ((ConstraintAnchor) anchor).getSolverVariable();
if (variable != null) {
return (int) (variable.computedValue + 0.5f);
}
return 0;
}
ConstraintWidget#setFrame(int, int, int, int)
public void setFrame(int left, int top, int right, int bottom) {
int w = right - left;
int h = bottom - top;
mX = left;
mY = top;
...
mWidth = w;
mHeight = h;
...
}

1.3.3 更新绘制位置

这一部分只是将之前计算的结果转换为绘制需要参数:

WidgetContainer#updateDrawPosition
public void updateDrawPosition() {
super.updateDrawPosition();
if (mChildren == null) {
return;
}
final int count = mChildren.size();
for (int i = 0; i < count; i++) {
ConstraintWidget widget = mChildren.get(i);
widget.setOffset(getDrawX(), getDrawY());
if (!(widget instanceof ConstraintWidgetContainer)) {
widget.updateDrawPosition();
}
}
}
ConstraintWidget#updateDrawPosition
public void updateDrawPosition() {
int left = mX;
int top = mY;
int right = mX + mWidth;
int bottom = mY + mHeight;
mDrawX = left;
mDrawY = top;
mDrawWidth = right - left;
mDrawHeight = bottom - top;
}

至此,我们计算出了布局所需的所有参数,终于可以回到 onLayout 进行布局了。我们回顾一下整个流程,整理了一份时序图如下:

640?wx_fmt=png

功能实现细节

在第 1 部分中,我们分析了居中定位的整个布局过程,接下来我们结合官方文档中介绍的相关功能,看一下其在代码中的实现原理是什么样的。

2.1

针对同一锚点的约束

锚点与锚点之前可以通过连接建立约束,示例布局演示了居中定位的效果,有时,我们也可以对同一个锚点建立左、右约束,例如实现在右上角放小红点的功能。

640?wx_fmt=png    

原理为在 updateHierarchy() 之后,view2:left 和 view2:right 都与 view1:right 建立了约束。

在 resolve() 计算过程中,因为 target 和 opposite.target 都是 view1.right,所以 distance 相减得到 0,再减去宽度得到 -200,最后划分距离的时候就变成了加上负值和减去负值,因此得到了以锚点边为中心,左右平分的结果。

ResolutionAnchor#resolve()
public void resolve() {
...
if (isEndAnchor) {
// we are right or bottom
distance = target.resolvedOffset - opposite.target.resolvedOffset;
} else {
distance = opposite.target.resolvedOffset - target.resolvedOffset; // target 和 opposite.target 都是 view1.right,相减 distance 为 0
}
if (myAnchor.mType == ConstraintAnchor.Type.LEFT
|| myAnchor.mType == ConstraintAnchor.Type.RIGHT) {
distance -= myAnchor.mOwner.getWidth(); // 减去自身宽度 200 得到 -200
percent = myAnchor.mOwner.mHorizontalBiasPercent; // 0.5
} else {
distance -= myAnchor.mOwner.getHeight();
percent = myAnchor.mOwner.mVerticalBiasPercent;
}
int margin = myAnchor.getMargin();
int oppositeMargin = opposite.myAnchor.getMargin();
if (myAnchor.getTarget() == opposite.myAnchor.getTarget()) {
percent = 0.5f;
margin = 0;
oppositeMargin = 0;
}
distance -= margin;
distance -= oppositeMargin;
if (isEndAnchor) {
// we are right or bottom
opposite.resolvedOffset = opposite.target.resolvedOffset
+ oppositeMargin + distance * percent;
resolvedOffset = target.resolvedOffset - margin - (distance * (1 - percent));
} else {
resolvedOffset = target.resolvedOffset + margin + distance * percent; // 640 + (-200*0.5)=540
opposite.resolvedOffset = opposite.target.resolvedOffset
- oppositeMargin - (distance * (1 - percent)); // 640 - (-200*0.5) = 740
}

2.2

margin 与 goneMargin

2.2.1 margin 的计算

640?wx_fmt=png

布局中设置的 marginLeft 和 goneMarginLeft,在 ConstraintLayout.LayoutParams 中被解析为 leftMargin 和 goneLeftMargin,然后在 resolveLayoutDirection 中被赋值为 resolveGoneLeftMargin。

在 ConstraintLayout#setChildrenConstraints 方法中,将 leftMargin 或 resolveGoneLeftMargin 作为参数传递,最终调用 ConstraintAnchor.connect 将 mMargin 和 mGoneMargin 设为其属性。

ConstraintLayout#setChildrenConstraints
else if (resolvedLeftToRight != UNSET) {
ConstraintWidget target = getTargetWidget(resolvedLeftToRight);
if (target != null) {
widget.immediateConnect(ConstraintAnchor.Type.LEFT, target,
ConstraintAnchor.Type.RIGHT, layoutParams.leftMargin,
resolveGoneLeftMargin); // view2:left 连接 view1:right leftMargin 作为参数传递
}
}

接下来,在分析依赖图的过程中,在调用 ResolutionAnchor#update 方法时,会计算 margin 为 offset。

而 margin 的计算过程会判断连接的锚点是否不为 GONE 取 mMargin 或 mGoneMargin:

ResolutionAnchor#update
public void update() {
ConstraintAnchor targetAnchor = myAnchor.getTarget();
if (targetAnchor == null) {
return;
}
if (targetAnchor.getTarget() == myAnchor) {
type = CHAIN_CONNECTION; // 链
targetAnchor.getResolutionNode().type = CHAIN_CONNECTION;
}
int margin = myAnchor.getMargin(); // myAnchor 是对应的 ConstraintAnchor
if (myAnchor.mType == ConstraintAnchor.Type.RIGHT
|| myAnchor.mType == ConstraintAnchor.Type.BOTTOM) {
margin = -margin;
}
dependsOn(targetAnchor.getResolutionNode(), margin);
}
ConstraintAnchor#getMargin
public int getMargin() {
if (mOwner.getVisibility() == ConstraintWidget.GONE) {
return 0; // 如果 widget 为 GONE,则 margin 无效,返回 0
}
if (mGoneMargin > UNSET_GONE_MARGIN && mTarget != null
&& mTarget.mOwner.getVisibility() == ConstraintWidget.GONE) {
return mGoneMargin; // 如果连接的 widget 为 GONE 则取 mGoneMargin
}
return mMargin; // 在 connect 方法赋值
}

最后,在解图过程中,在 ResolutionAnchor.resolve() 方法中,之前 margin 传参作为 offset 用来计算 resolvedOffset。

ResolutionAnchor#resolve()
if (type == DIRECT_CONNECTION
&& ((target == null) || (target.state == RESOLVED))) {
// Let's solve direct connections...
if (target == null) {
resolvedTarget = this;
resolvedOffset = offset;
} else {
resolvedTarget = target.resolvedTarget;
resolvedOffset = target.resolvedOffset + offset; // target 为 view1 的 right,200 + 100
}
didResolve(); // 解析完成
}

2.2.2 goneMargin 的计算

640?wx_fmt=png

先看设为 GONE 的 view1,在建立约束过程,view1:left 连接 root:left,view2:left 连接 view1:right

在分析依赖图过程,ResolutionAnchor.update() 时,view1:left 连接 root:left,由于 view1 为 gone,所以虽然设置了 margin,也会将 offset 计算为 0。

view2:left 连接 view1:right,由于 view1 为 gone,所以会取 goneMargin

在 Optimizer.analyze() 中,view1:right 依赖 view1:left,在使用 getWidth() 计算 offset 的时候,会计算为 0:

Optimizer#analyze
else if (widget.mLeft.mTarget != null && widget.mRight.mTarget == null) {
leftNode.setType(ResolutionAnchor.DIRECT_CONNECTION);
rightNode.setType(ResolutionAnchor.DIRECT_CONNECTION);
if (optimiseDimensions) {
rightNode.dependsOn(leftNode, 1, widget.getResolutionWidth());
} else {
rightNode.dependsOn(leftNode, widget.getWidth()); // 右结点依赖于左结点
}
}
ConstraintWidget#getWidth
public int getWidth() {
if (mVisibility == ConstraintWidget.GONE) {
return 0;
}
return mWidth;
}

在求解图过程,view2:left 依赖 view1:right 与 2.2.1 中 margin 的计算相同,只是最后取了 goneMargin 最后,view2:left 依赖 view1:right,在 resolve() 计算过程中,求得 0:

ResolutionAnchor#resolve()
if (type == DIRECT_CONNECTION
&& ((target == null) || (target.state == RESOLVED))) {
// Let's solve direct connections...
if (target == null) {
resolvedTarget = this;
resolvedOffset = offset;
} else {
resolvedTarget = target.resolvedTarget;
resolvedOffset = target.resolvedOffset + offset; // target 为 view1 的 right,0 + goneMargin 为 0
}
didResolve(); // 解析完成
}

2.2.3 负 margin 无效

640?wx_fmt=png

ConstraintAnchor#connect
if (margin > 0) {
mMargin = margin; // 设置 margin
} else {
mMargin = 0; // 可以看到负的 margin 无效
}

因此如果要实现负的 margin 的效果,必须引入另一个 View。

640?wx_fmt=png

2.3

居中定位与 bias

640?wx_fmt=png

和之前的 margin 类似,布局中的 bias 被解析为 horizontalBias,然后处理为 resolvedHorizontalBias 在 asetChildrenConstraints() 传递给 ConstraintWidget。

ConstraintLayout#setChildrenConstraints
private void setChildrenConstraints() {
...
if (resolvedHorizontalBias >= 0 && resolvedHorizontalBias != 0.5f) {
widget.setHorizontalBiasPercent(resolvedHorizontalBias);
}
if (layoutParams.verticalBias >= 0 && layoutParams.verticalBias != 0.5f) {
widget.setVerticalBiasPercent(layoutParams.verticalBias);
}

最后就是求解过程中使用。

ResolutionAnchor#resolve()
if (myAnchor.mType == ConstraintAnchor.Type.LEFT
|| myAnchor.mType == ConstraintAnchor.Type.RIGHT) {
distance -= myAnchor.mOwner.getWidth(); // 1080-200 =880
percent = myAnchor.mOwner.mHorizontalBiasPercent; // 0.75
}
...
if (isEndAnchor) {
// we are right or bottom
opposite.resolvedOffset = opposite.target.resolvedOffset
+ oppositeMargin + distance * percent; // 对方为 880*0.75 =660
resolvedOffset = target.resolvedOffset - margin - (distance * (1 - percent)); // 自身为 1080 - 220 = 860
}

通过计算过程,我们再理解一下 bias 的含义,bias 意为偏压、偏差,0.75 并不是指 view 为于父布局的 0.75(实例为 760/1080=0.70),而是指左边约束占总约束的 75%,即右边约束占 25 %。因此计算的时候,是去除 widget 宽度,去除 margin 等值,剩下的距离的 75%为左侧距离(880 x 75%=660),另外 25% 为右侧距离(880 x 25%=220,1080 - 220 =860)。

2.4

圆形定位

官方的示例如下:

640?wx_fmt=png

为方例调试,我们使用以下布局:

640?wx_fmt=png

在建立约束时会创建圆形约束:

ConstraintLayout#setChildrenConstraints
if (layoutParams.circleConstraint != UNSET) {
ConstraintWidget target = getTargetWidget(layoutParams.circleConstraint);
if (target != null) {
widget.connectCircularConstraint(target, layoutParams.circleAngle, layoutParams.circleRadius);
}
}
ConstraintWidget#connectCircularConstraint
public void connectCircularConstraint(ConstraintWidget target, float angle, int radius) {
immediateConnect(ConstraintAnchor.Type.CENTER, target, ConstraintAnchor.Type.CENTER,
radius, 0);
mCircleConstraintAngle = angle;
}

求解圆形定位的位置需要用到求解器。

ConstraintWidget#addToSolver
if (mCenter.isConnected()) {
system.addCenterPoint(this, mCenter.getTarget().getOwner(), (float) Math.toRadians(mCircleConstraintAngle + 90), mCenter.getMargin());
}
LinearSystem#addCenterPoint
public void addCenterPoint(ConstraintWidget widget, ConstraintWidget target, float angle, int radius) {
SolverVariable Al = createObjectVariable(widget.getAnchor(ConstraintAnchor.Type.LEFT));
SolverVariable At = createObjectVariable(widget.getAnchor(ConstraintAnchor.Type.TOP));
SolverVariable Ar = createObjectVariable(widget.getAnchor(ConstraintAnchor.Type.RIGHT));
SolverVariable Ab = createObjectVariable(widget.getAnchor(ConstraintAnchor.Type.BOTTOM));
SolverVariable Bl = createObjectVariable(target.getAnchor(ConstraintAnchor.Type.LEFT));
SolverVariable Bt = createObjectVariable(target.getAnchor(ConstraintAnchor.Type.TOP));
SolverVariable Br = createObjectVariable(target.getAnchor(ConstraintAnchor.Type.RIGHT));
SolverVariable Bb = createObjectVariable(target.getAnchor(ConstraintAnchor.Type.BOTTOM));
ArrayRow row = createRow();
float angleComponent = (float) (Math.sin(angle) * radius);
row.createRowWithAngle(At, Ab, Bt, Bb, angleComponent);
addConstraint(row); // 垂直方向
row = createRow();
angleComponent = (float) (Math.cos(angle) * radius); // cos180°*200=-200
row.createRowWithAngle(Al, Ar, Bl, Br, angleComponent);
addConstraint(row); // 水平方向
}
ArrayRow#createRowWithAngle
public ArrayRow createRowWithAngle(SolverVariable at, SolverVariable ab, SolverVariable bt, SolverVariable bb, float angleComponent) {
variables.put(bt, 0.5f); // view1.left
variables.put(bb, 0.5f); // view1.right
variables.put(at, -0.5f); // view2.left
variables.put(ab, -0.5f); // view2.right
constantValue = - angleComponent; // 200
return this;
}

接下来,在 addConstraint 方法中,经过 updateRowFromVariables(row)、addRow(row)、row.variable.updateReferencesWithNewDefinition(row) 等方法,view2:left 和 view2:right 就成功解出来了,求解的过程我们不再具体分析,感兴趣的同学可以查看 LinearSystem 的相关源码。我们可以看添加过程的输出。

addConstraint <0 = 200.0 + 0.5 view1.left:0 + 0.5 view1.right:0 - 0.5 view2.left:0 - 0.5 view2.right:0>
addConstraint, updated row : 0 = 640.0 - view2.left:0
Row added, here is the system:
Display Rows (12x16)
# Root.left:0 = 0.0
# Root.right:0 = 1080.0
# Root.top:0 = 0.0
# Root.bottom:0 = 2096.0
# view1.left:0 = 440.0
# view1.right:0 = 640.0
# view1.top:0 = 948.0
# view1.bottom:0 = 1148.0
# view2.right:0 = 840.0
# view2.bottom:0 = 1148.0
# view2.top:0 = 948.0
# view2.left:0 = 640.0
# 0 = 0.0

2.5

ratio

我们可以使用 DimensionRatio 来限制宽高的比例,示例如下:

640?wx_fmt=png

接下来在 ConstraintLayout#setChildrenConstraints 方法中会设置比例:

ConstraintWidget#setDimensionRatio(java.lang.String)
public void setDimensionRatio(String ratio) {
if (ratio == null || ratio.length() == 0) {
mDimensionRatio = 0;
return;
}
int dimensionRatioSide = UNKNOWN; // 初始及默认值为未知
float dimensionRatio = 0;
int len = ratio.length();
int commaIndex = ratio.indexOf(',');
if (commaIndex > 0 && commaIndex < len - 1) { // 有逗号,读取约束边
String dimension = ratio.substring(0, commaIndex);
if (dimension.equalsIgnoreCase("W")) {
dimensionRatioSide = HORIZONTAL;
} else if (dimension.equalsIgnoreCase("H")) {
dimensionRatioSide = VERTICAL;
}
commaIndex++;
} else {
commaIndex = 0;
}
int colonIndex = ratio.indexOf(':');
if (colonIndex >= 0 && colonIndex < len - 1) { // 有冒号,求比例
String nominator = ratio.substring(commaIndex, colonIndex);
String denominator = ratio.substring(colonIndex + 1);
if (nominator.length() > 0 && denominator.length() > 0) {
try {
float nominatorValue = Float.parseFloat(nominator);
float denominatorValue = Float.parseFloat(denominator);
if (nominatorValue > 0 && denominatorValue > 0) {
if (dimensionRatioSide == VERTICAL) { // 如果约束垂直边,倒数
dimensionRatio = Math.abs(denominatorValue / nominatorValue);
} else {
dimensionRatio = Math.abs(nominatorValue / denominatorValue);
}
}
} catch (NumberFormatException e) {
// Ignore
}
}
} else { // 没有冒号,作为浮点求比例
String r = ratio.substring(commaIndex);
if (r.length() > 0) {
try {
dimensionRatio = Float.parseFloat(r);
} catch (NumberFormatException e) {
// Ignore
}
}
}
if (dimensionRatio > 0) {
mDimensionRatio = dimensionRatio;
mDimensionRatioSide = dimensionRatioSide;
}
}

通过设置方法我们知道

  • 可以用逗号分隔的前缀 w、h 指定受约束的边,不区分大小写。

  • 如果以 h 开头,求比例时会取倒数

  • 可以用浮点来表示比例

接下来看生效源码。

ConstraintWidget#addToSolver
boolean useRatio = false;
mResolvedDimensionRatioSide = mDimensionRatioSide; // UNKNOWN
mResolvedDimensionRatio = mDimensionRatio; // 2.0
int matchConstraintDefaultWidth = mMatchConstraintDefaultWidth;
int matchConstraintDefaultHeight = mMatchConstraintDefaultHeight;
if (mDimensionRatio > 0 && mVisibility != GONE) {
useRatio = true; // 使用比例
if (mListDimensionBehaviors[DIMENSION_HORIZONTAL] == DimensionBehaviour.MATCH_CONSTRAINT
&& matchConstraintDefaultWidth == MATCH_CONSTRAINT_SPREAD) {
matchConstraintDefaultWidth = MATCH_CONSTRAINT_RATIO;
}
if (mListDimensionBehaviors[DIMENSION_VERTICAL] == DimensionBehaviour.MATCH_CONSTRAINT
&& matchConstraintDefaultHeight == MATCH_CONSTRAINT_SPREAD) {
matchConstraintDefaultHeight = MATCH_CONSTRAINT_RATIO; // 匹配约束比例
}
if (mListDimensionBehaviors[DIMENSION_HORIZONTAL] == DimensionBehaviour.MATCH_CONSTRAINT
&& mListDimensionBehaviors[DIMENSION_VERTICAL] == DimensionBehaviour.MATCH_CONSTRAINT
&& matchConstraintDefaultWidth == MATCH_CONSTRAINT_RATIO
&& matchConstraintDefaultHeight == MATCH_CONSTRAINT_RATIO) {
setupDimensionRatio(horizontalParentWrapContent, verticalParentWrapContent, horizontalDimensionFixed, verticalDimensionFixed);
} else if (mListDimensionBehaviors[DIMENSION_HORIZONTAL] == DimensionBehaviour.MATCH_CONSTRAINT
&& matchConstraintDefaultWidth == MATCH_CONSTRAINT_RATIO) { // 约束宽
mResolvedDimensionRatioSide = HORIZONTAL;
width = (int) (mResolvedDimensionRatio * mHeight); // 高 * 比例
if (mListDimensionBehaviors[DIMENSION_VERTICAL] != DimensionBehaviour.MATCH_CONSTRAINT) {
matchConstraintDefaultWidth = MATCH_CONSTRAINT_RATIO_RESOLVED;
useRatio = false;
}
} else if (mListDimensionBehaviors[DIMENSION_VERTICAL] == DimensionBehaviour.MATCH_CONSTRAINT
&& matchConstraintDefaultHeight == MATCH_CONSTRAINT_RATIO) { // 约束高
mResolvedDimensionRatioSide = VERTICAL;
if (mDimensionRatioSide == UNKNOWN) { // UNKNOWN 时求倒数
// need to reverse the ratio as the parsing is done in horizontal mode
mResolvedDimensionRatio = 1 / mResolvedDimensionRatio; // 2.0 -> 0.5
}
height = (int) (mResolvedDimensionRatio * mWidth); // 0.5 * 200 得到高度 100
if (mListDimensionBehaviors[DIMENSION_HORIZONTAL] != DimensionBehaviour.MATCH_CONSTRAINT) {
matchConstraintDefaultHeight = MATCH_CONSTRAINT_RATIO_RESOLVED; // 求解完成
useRatio = false;
}
}
}

宽和高也可以都设置为 0dp。

该场景会用来需要根据屏幕的宽高适配容器的高度,而容器内的 View 需要根据高度保持宽高比。

640?wx_fmt=png

由于宽高都未知,在 addToSolver 前一部分并不能求出宽高,会调用 ConstraintWidget#setupDimensionRatio。

  if (mResolvedDimensionRatioSide == HORIZONTAL && !(mTop.isConnected() && mBottom.isConnected())) {
mResolvedDimensionRatioSide = VERTICAL;
} else if (mResolvedDimensionRatioSide == VERTICAL && !(mLeft.isConnected() && mRight.isConnected())) {
mResolvedDimensionRatioSide = HORIZONTAL; // view2 的左右未连接,被修改为水平
}

然后,会根据是水平比例还是垂直比例,调用不同的方法。

ConstraintWidget#addToSolver
...
if (useRatio) {
int strength = SolverVariable.STRENGTH_FIXED;
if (mResolvedDimensionRatioSide == VERTICAL) {
system.addRatio(bottom, top, right, left, mResolvedDimensionRatio, strength);
} else {
system.addRatio(right, left, bottom, top, mResolvedDimensionRatio, strength);
}
}


android.support.constraint.solver.LinearSystem#addRatio
public void addRatio(SolverVariable a, SolverVariable b, SolverVariable c, SolverVariable d, float ratio, int strength) {
if (DEBUG) {
System.out.println("-> [ratio: " + ratio + "] : " + a + " = " + b + " + (" + c + " - " + d + ") * " + ratio + " " + getDisplayStrength(strength));
}
ArrayRow row = createRow();
row.createRowDimensionRatio(a, b, c, d, ratio);
if (strength != SolverVariable.STRENGTH_FIXED) {
row.addError(this, strength);
}
addConstraint(row);
}

可以看到输出为 :

[ratio: 0.5] : view1.bottom:0 = view1.top:0 + (view1.right:0 - view1.left:0) * 0.5 FIXED
[ratio: 0.5] : view2.right:0 = view2.left:0 + (view2.bottom:0 - view2.top:0) * 0.5 FIXED

因此可以看到,虽然都是使用 h,2:1 指定比例,但是由于 view2 right 未连接到锚点,被置为 HORIZONTAL。

最终 view1 的高度求为宽度的一半。

而 view2 则是宽度求为高度的一半。

如果不使用 w 或 h,则直接使用 width:height 也可以得到相同的结果

或者将 view2 的 h,2:1 改为 w,1:2 也是相同的结果,具体会在 3.1 中再进行更详细的分析。

2.6

0dp 与 match_parent

我们知道 0dp 相当于 MATCH_CONSTRAINT,并且 ConstraintLayout 中不推荐使用 match_parent。

但是如果使用 match_parent,布局一般也会正常工作,我们看一下 match_parent 的作用过程。

如下图,宽度设置为 match_parent,高度设置为 0,发现水平方向 margin 生效了,但是垂直方向未生效。

640?wx_fmt=png

ConstraintWidgetContainer#addChildrenToSolver
...
Optimizer.checkMatchParent(this, system, widget); // 检查 matchParent
widget.addToSolver(system); // 添加 child
Optimizer#checkMatchParent
static void checkMatchParent(ConstraintWidgetContainer container, LinearSystem system, ConstraintWidget widget) {
if (container.mListDimensionBehaviors[DIMENSION_HORIZONTAL] != ConstraintWidget.DimensionBehaviour.WRAP_CONTENT
&& widget.mListDimensionBehaviors[DIMENSION_HORIZONTAL] == ConstraintWidget.DimensionBehaviour.MATCH_PARENT) {
int left = widget.mLeft.mMargin;
int right = container.getWidth() - widget.mRight.mMargin;
if (false) {
widget.mLeft.getResolutionNode().resolve(null, left);
widget.mRight.getResolutionNode().resolve(null, right);
} else {
widget.mLeft.mSolverVariable = system.createObjectVariable(widget.mLeft);
widget.mRight.mSolverVariable = system.createObjectVariable(widget.mRight);
system.addEquality(widget.mLeft.mSolverVariable, left);
system.addEquality(widget.mRight.mSolverVariable, right);
widget.mHorizontalResolution = ConstraintWidget.DIRECT;
}
widget.setHorizontalDimension(left, right);
}

可以看到,在执行 addToSolver 之前,添加了 matchParent 的检查,如果是 matchParent 并且不是 WRAP_CONTENT,就会将 left 设置为 mLeft.mMargin 但是我们知道 ConstraintAnchor 只有建立边接时才会赋值 mMargin,matchParent 没有依赖,是如何赋值 mMargin 的呢?断点我们可以发现是在 setChildrenConstraints() 方法中完成的:

ConstraintLayout#setChildrenConstraints
if (!layoutParams.horizontalDimensionFixed) { // 水平不固定
if (layoutParams.width == LayoutParams.MATCH_PARENT) { // match_parent
widget.setHorizontalDimensionBehaviour(ConstraintWidget.DimensionBehaviour.MATCH_PARENT);
widget.getAnchor(ConstraintAnchor.Type.LEFT).mMargin = layoutParams.leftMargin; // 赋值 margin
widget.getAnchor(ConstraintAnchor.Type.RIGHT).mMargin = layoutParams.rightMargin;
} else {
widget.setHorizontalDimensionBehaviour(ConstraintWidget.DimensionBehaviour.MATCH_CONSTRAINT);
widget.setWidth(0);
}
} else {
widget.setHorizontalDimensionBehaviour(ConstraintWidget.DimensionBehaviour.FIXED);
widget.setWidth(layoutParams.width);
}
if (!layoutParams.verticalDimensionFixed) { // 垂直不固定
if (layoutParams.height == LayoutParams.MATCH_PARENT) {
widget.setVerticalDimensionBehaviour(ConstraintWidget.DimensionBehaviour.MATCH_PARENT);
widget.getAnchor(ConstraintAnchor.Type.TOP).mMargin = layoutParams.topMargin;
widget.getAnchor(ConstraintAnchor.Type.BOTTOM).mMargin = layoutParams.bottomMargin;
} else {
widget.setVerticalDimensionBehaviour(ConstraintWidget.DimensionBehaviour.MATCH_CONSTRAINT); // 匹配约束
widget.setHeight(0); // 高度设为 0
}
} else {
widget.setVerticalDimensionBehaviour(ConstraintWidget.DimensionBehaviour.FIXED);
widget.setHeight(layoutParams.height); // 设置高度
}

除了宽度,高度也是在 setChildrenConstraints 设置,可以看到因为不是 matchParent,所以将高度设为 0。

后续在 internalMeasureChildren 中执行测量后再赋值为测量的高度,因此垂直方向的 match_parent 只是恰好因为它是 View,测量高度为 parent 的高度,所以不可以单独使用 0dp,需要配合约束。

2.7

layout_constrainedWidth

当宽度为 wrap_content 的时候,可以使用 constrainedWidth="true" 设置约束宽。

或者当宽度为 0dp 的时候,使用 constraintWidth_default="wrap" 设置 wrap_content 的效果。

示例如下,更详细的使用会放在 3.2 中再介绍:

LayoutParams#validate
public void validate() {
isGuideline = false;
horizontalDimensionFixed = true;
verticalDimensionFixed = true;
if (width == WRAP_CONTENT && constrainedWidth) { // constrainedWidth="true"
horizontalDimensionFixed = false;
matchConstraintDefaultWidth = MATCH_CONSTRAINT_WRAP; // 相当于 layout_constraintWidth_default="wrap"
}
if (height == WRAP_CONTENT && constrainedHeight) {
verticalDimensionFixed = false;
matchConstraintDefaultHeight = MATCH_CONSTRAINT_WRAP;
}
if (width == MATCH_CONSTRAINT || width == MATCH_PARENT) { // 即 0dp
horizontalDimensionFixed = false;
// We have to reset LayoutParams width/height to WRAP_CONTENT here, as some widgets like TextView
// will use the layout params directly as a hint to know if they need to request a layout
// when their content change (e.g. during setTextView)
if (width == MATCH_CONSTRAINT && matchConstraintDefaultWidth == MATCH_CONSTRAINT_WRAP) { // wrap
width = WRAP_CONTENT; // 宽度置为 wrap_content
constrainedWidth = true; // 相当于 constrainedWidth="true"
}
}

2.8

链可以实现 View 之前的相关依赖。

640?wx_fmt=png

在更新解析结点时,会将类型标记为链:

ResolutionAnchor#update
if (targetAnchor.getTarget() == myAnchor) {
type = CHAIN_CONNECTION; // 链
targetAnchor.getResolutionNode().type = CHAIN_CONNECTION;
}

然后在添加到求解器过程中,应用链约束。

if (mHorizontalChainsSize > 0) {
Chain.applyChainConstraints(this, system, HORIZONTAL);
}
if (mVerticalChainsSize > 0) {
Chain.applyChainConstraints(this, system, VERTICAL);
}
Chain#applyChainConstraints
...
for (int i = 0; i < chainsSize; i++) {
ChainHead first = chainsArray[i];
first.define();
if (constraintWidgetContainer.optimizeFor(Optimizer.OPTIMIZATION_CHAIN)) {
if (!Optimizer.applyChainOptimized(constraintWidgetContainer, system, orientation, offset, first)) {
applyChainConstraints(constraintWidgetContainer, system, orientation, offset, first);
}
} else {
applyChainConstraints(constraintWidgetContainer, system, orientation, offset, first);
}
}
Optimizer#applyChainOptimized
...
float firstOffset = firstNode.target.resolvedOffset;
float lastOffset = lastNode.target.resolvedOffset;
float distance = 0;
if (firstOffset < lastOffset) {
distance = lastOffset - firstOffset - totalSize; // 求出距离
} else {
distance = firstOffset - lastOffset - totalSize;
}
...
} else if (isChainSpread || isChainSpreadInside) {
...
float gap = distance / (float) (numVisibleWidgets + 1); // 求出间距
distance = firstOffset;
if (first.getVisibility() != GONE) {
distance += gap; // start after the gap
}
...
while (widget != null) {
if (system.sMetrics != null) {
system.sMetrics.nonresolvedWidgets--;
system.sMetrics.resolvedWidgets++;
system.sMetrics.chainConnectionResolved++;
}
next = widget.mNextChainWidget[orientation];
if (next != null || widget == last) {
float dimension = 0;
if (orientation == HORIZONTAL) {
dimension = widget.getWidth();
} else {
dimension = widget.getHeight();
}
if (widget != firstVisibleWidget) {
distance += widget.mListAnchors[offset].getMargin();
}
widget.mListAnchors[offset].getResolutionNode().resolve(firstNode.resolvedTarget,
distance); // mLeft
widget.mListAnchors[offset + 1].getResolutionNode().resolve(firstNode.resolvedTarget,
distance + dimension); // mRight
widget.mListAnchors[offset].getResolutionNode().addResolvedValue(system);
widget.mListAnchors[offset + 1].getResolutionNode().addResolvedValue(system);
distance += dimension + widget.mListAnchors[offset + 1].getMargin(); // 累加宽度和 margin
if (next != null && next.getVisibility() != GONE) {
distance += gap; // 累加间距
}
}
widget = next;
}

另外还可以指定 chainStyle,默认是 spread,当指定为 packed 时,链上的 View 会打包挤到一起。

640?wx_fmt=png

Optimizer#applyChainOptimized
if (isChainPacked) {
distance -= extraMargin;
// Now let's iterate on those widgets
widget = first;
distance = firstOffset + (distance * first.getBiasPercent(orientation)); // 剩余距离乘以 bias 指定的值
while (widget != null) {
if (system.sMetrics != null) {
system.sMetrics.nonresolvedWidgets--;
system.sMetrics.resolvedWidgets++;
system.sMetrics.chainConnectionResolved++;
}
next = widget.mNextChainWidget[orientation];
if (next != null || widget == last) {
float dimension = 0;
if (orientation == HORIZONTAL) {
dimension = widget.getWidth();
} else {
dimension = widget.getHeight();
}
distance += widget.mListAnchors[offset].getMargin();
widget.mListAnchors[offset].getResolutionNode().resolve(firstNode.resolvedTarget,
distance);
widget.mListAnchors[offset + 1].getResolutionNode().resolve(firstNode.resolvedTarget,
distance + dimension);
widget.mListAnchors[offset].getResolutionNode().addResolvedValue(system);
widget.mListAnchors[offset + 1].getResolutionNode().addResolvedValue(system);
distance += dimension;
distance += widget.mListAnchors[offset + 1].getMargin();
}
widget = next;
}
}

2.9

助手类

除了上述功能,接下来我们介绍一下助手类,相关类的类图总结如下:

640?wx_fmt=png

2.9.1 GuideLine

参考线可以用于辅助定位,可以使用 layout_constraintGuide_begin、layout_constraintGuide_end 设置相对于左侧或右侧的间距,也可以使用 layout_constraintGuide_percent 设置百分比。

唯一的缺点是这三个属性都是相对于 ConstraintLayout 的,参考线的整体设计就只支持相对于容器,如果想相对于某个 View 设置参考线,就只能引入一个 View(但是在预览中不如 GuideLine 直观)。

640?wx_fmt=png

constraint.GuideLine 继承 View,在 ConstraintLayout.LayoutParams#validate 校验参数时,会将布局参数的 widget 设置为 widgets.GuideLine。

ConstraintLayout.LayoutParams#validate
...
if (guidePercent != UNSET || guideBegin != UNSET || guideEnd != UNSET) { // 相关属性属于 LayoutParams
isGuideline = true; // 赋值为 ture
horizontalDimensionFixed = true;
verticalDimensionFixed = true;
if (!(widget instanceof Guideline)) {
widget = new Guideline();
}
((Guideline) widget).setOrientation(orientation); // 方向设置给 Guideline
}

GuideLine 继承 ConstraintWidget,所以当分析依赖图时,会调用 Guideline#analyze 分析依赖。

Guideline#analyze
public void analyze(int optimizationLevel) {
ConstraintWidget constraintWidgetContainer = getParent();
if (constraintWidgetContainer == null) {
return;
}
if (getOrientation() == Guideline.VERTICAL) {
mTop.getResolutionNode().dependsOn(ResolutionAnchor.DIRECT_CONNECTION,constraintWidgetContainer.mTop.getResolutionNode(), 0);
mBottom.getResolutionNode().dependsOn(ResolutionAnchor.DIRECT_CONNECTION, constraintWidgetContainer.mTop.getResolutionNode(), 0);
if (mRelativeBegin != -1) { // 设置了 begin
mLeft.getResolutionNode().dependsOn(ResolutionAnchor.DIRECT_CONNECTION, constraintWidgetContainer.mLeft.getResolutionNode(), mRelativeBegin);
mRight.getResolutionNode().dependsOn(ResolutionAnchor.DIRECT_CONNECTION, constraintWidgetContainer.mLeft.getResolutionNode(), mRelativeBegin);
} else if (mRelativeEnd != -1) { // 设置了 end
mLeft.getResolutionNode().dependsOn(ResolutionAnchor.DIRECT_CONNECTION, constraintWidgetContainer.mRight.getResolutionNode(), -mRelativeEnd);
mRight.getResolutionNode().dependsOn(ResolutionAnchor.DIRECT_CONNECTION, constraintWidgetContainer.mRight.getResolutionNode(), -mRelativeEnd);
} else if (mRelativePercent != -1 && constraintWidgetContainer.getHorizontalDimensionBehaviour() == FIXED) { // 设置 percent
int position = (int) (constraintWidgetContainer.mWidth * mRelativePercent); // 宽度乘以 percent
mLeft.getResolutionNode().dependsOn(ResolutionAnchor.DIRECT_CONNECTION, constraintWidgetContainer.mLeft.getResolutionNode(), position);
mRight.getResolutionNode().dependsOn(ResolutionAnchor.DIRECT_CONNECTION, constraintWidgetContainer.mLeft.getResolutionNode(), position);
}
}

接下来添加到求解器求解。

Guideline#addToSolver
...
} else if (mRelativePercent != -1) {
SolverVariable guide = system.createObjectVariable(mAnchor);
SolverVariable parentLeft = system.createObjectVariable(begin);
SolverVariable parentRight = system.createObjectVariable(end);
system.addConstraint(LinearSystem
.createRowDimensionPercent(system, guide, parentLeft, parentRight,
mRelativePercent, mIsPositionRelaxed));
}

查看 log 可以看到添加的方程为。

addConstraint <0 = 0.5 Root.left:0 + 0.5 Root.right:0 - guideline1.left:0>

解下来就可以解出 guideline1.left。

2.9.1.2 小彩蛋

根据之前的分析,参考线的主要原理,是在布局参数中判断有没有设置相关的参考线属性,如果有就将 widget 设置为 widgets.Guideline,然后 widgets.Guideline负责相关的求解。而作为 View 的 constraint.Guideline 只负责 setVisibility(View.GONE)、setMeasuredDimension(0, 0) 添加了 setGuidelineBegin、setGuidelineEnd、setGuidelinePercent 等方法。

那么是不是可以不使用 constraint.Guideline,使用任何一个 View 都可以实现相同的效果?答案:是的。

640?wx_fmt=png

2.9.2 Barrier

Barrier 意为屏障、栅栏,可以在所引用的 View 中,在指定边上最突出的 View 的边创建参考线,示例如下。

当我们想让 view3 位于 view1 、view2 的右侧,但又无法确定哪一个最宽时,可以指定 barrierDirection="right"。

640?wx_fmt=png

其原理为在 constraint.Barrier 初始化时,会创建 mBarrier 并赋值给 mHelperWidget,然后调用 validateParams() 将 mHelperWidget 设置为布局参数的 widget。

constraint.Barrier#init
...
mBarrier = new android.support.constraint.solver.widgets.Barrier();
...
mHelperWidget = mBarrier;
validateParams();
ConstraintHelper#validateParams
public void validateParams() {
if (mHelperWidget == null) {
return;
}
ViewGroup.LayoutParams params = getLayoutParams();
if (params instanceof ConstraintLayout.LayoutParams) {
ConstraintLayout.LayoutParams layoutParams = (ConstraintLayout.LayoutParams) params;
layoutParams.widget = mHelperWidget;
}
}

当 Barrier 成为 widget 之后,在求解过程中就会调用 analyze 分析依赖。

widgets.Barrier#analyze
public void analyze(int optimizationLevel) {
...
ResolutionAnchor node;
switch (mBarrierType) {
case LEFT:
node = mLeft.getResolutionNode();
break;
case RIGHT:
node = mRight.getResolutionNode(); // 自己的右结点
break;
...
}
node.setType(ResolutionAnchor.BARRIER_CONNECTION);
...
mNodes.clear();
for (int i = 0; i < mWidgetsCount; i++) {
...
ResolutionAnchor depends = null;
switch (mBarrierType) {
case LEFT:
depends = widget.mLeft.getResolutionNode();
break;
case RIGHT:
depends = widget.mRight.getResolutionNode(); // 依赖引用 View 的右结点
break;
...
}
if (depends != null) {
mNodes.add(depends); // 添加引用的结点
depends.addDependent(node); // 引用结点的被依赖集合
}
}

在 resolve 求解。

widgets.Barrier#resolve
public void resolve() {
...
final int count = mNodes.size();
ResolutionAnchor resolvedTarget = null;
for (int i = 0; i < count; i++) { // 遍历所有的结点
ResolutionAnchor n = mNodes.get(i);
if (n.state != ResolutionAnchor.RESOLVED) { // 如果还未求解不处理
return;
}
if (mBarrierType == LEFT || mBarrierType == TOP) {
if (n.resolvedOffset < value) {
value = n.resolvedOffset;
resolvedTarget = n.resolvedTarget;
}
} else {
if (n.resolvedOffset > value) {
value = n.resolvedOffset; // 取较大的 resolvedOffset
resolvedTarget = n.resolvedTarget;
}
}
}
...
node.resolvedTarget = resolvedTarget;
node.resolvedOffset = value; // 赋值最终解果
node.didResolve();

2.9.3 Group

Group 可以引用一组 View,然后统一设置可见性、elevation。

640?wx_fmt=png

主要实现在于 updatePreLayout 方法。

public void updatePreLayout(ConstraintLayout container) {
int visibility = getVisibility();
float elevation = 0;
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.LOLLIPOP) {
elevation = getElevation();
}
for (int i = 0; i < mCount; i++) {
int id = mIds[i];
View view = container.getViewById(id);
if (view != null) {
view.setVisibility(visibility);
if (elevation > 0 && android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.LOLLIPOP) {
view.setElevation(elevation);
}
}
}
}

2.9.4 Placeholder

占位,可以给 Placeholder 设置好相应位置,然后为其设置内容时,内容将会处理 Placeholder 的位置。

640?wx_fmt=png

主要原理为测量自己后,将宽高设置给内容。

Placeholder#updatePostMeasure
public void updatePostMeasure(ConstraintLayout container) {
if (mContent == null) {
return;
}
ConstraintLayout.LayoutParams layoutParams = (ConstraintLayout.LayoutParams) getLayoutParams();
ConstraintLayout.LayoutParams layoutParamsContent = (ConstraintLayout.LayoutParams) mContent
.getLayoutParams();
layoutParamsContent.widget.setVisibility(View.VISIBLE);
layoutParams.widget.setWidth(layoutParamsContent.widget.getWidth());
layoutParams.widget.setHeight(layoutParamsContent.widget.getHeight());
layoutParamsContent.widget.setVisibility(View.GONE);
}

然后在布局的时候,使用相同的参数布局内容。

ConstraintLayout#onLayout
...
child.layout(l, t, r, b); // 直接调用 layout 方法
if (child instanceof Placeholder) {
Placeholder holder = (Placeholder) child;
View content = holder.getContent();
if (content != null) {
content.setVisibility(VISIBLE); // 设为可见
content.layout(l, t, r, b); // 布局内容
}
}

也可以通过 setContentId 来动态的设置内容,比如切换不同的 view 的位置, 但是要注意之前的内容会被置为可见,而不能实现一个隐藏一个展示的效果,只能实现交换位置的效果。

public void setContentId(int id) {
if (mContentId == id) {
return;
}
if (mContent != null) {
mContent.setVisibility(VISIBLE); // 注意之前的 content 会被设为可见
ConstraintLayout.LayoutParams layoutParamsContent = (ConstraintLayout.LayoutParams) mContent
.getLayoutParams();
layoutParamsContent.isInPlaceholder = false;
mContent = null;
}




mContentId = id;
if (id != ConstraintLayout.LayoutParams.UNSET) {
View v = ((View) getParent()).findViewById(id);
if (v != null) {
v.setVisibility(GONE);
}
}
}


京东 App 中的实践

以上两部分我们介绍了 ConstraintLayout 的布局原理和功能实现细节,接下来我们结合在京东 App 中的实际使用经验,对一些问题进行更具体的分析。

3.1

ratio 实践-以屏幕宽度确定 view 高度,再以高度确定宽度

在 2.5 节中我们介绍了 ratio 的实现原理,在实践中,我们有这样的场景,View 是充满屏幕宽度的,其高度需要参照屏幕宽度等比适配,又有 ImageView 需要保持宽高比。

由于 ratio 的设置比较复杂,我们先具体介绍一下 ratio 不同值的使用情况及其原理。

1、ratio=2:1 width=200 height=0

640?wx_fmt=png

由于高度为 0,认为高度受比例约束,由于未指定受约束边,mDimensionRatioSide == UNKNOWN,所以计算 mResolvedDimensionRatio 时 2.0 变为 0.5,所以height = width * 0.5 = 200 * 0.5 = 100。

ConstraintWidget#addToSolver
...
} else if (mListDimensionBehaviors[DIMENSION_VERTICAL] == DimensionBehaviour.MATCH_CONSTRAINT
&& matchConstraintDefaultHeight == MATCH_CONSTRAINT_RATIO) { // 约束高
mResolvedDimensionRatioSide = VERTICAL;
if (mDimensionRatioSide == UNKNOWN) { // UNKNOWN 时求倒数
// need to reverse the ratio as the parsing is done in horizontal mode
mResolvedDimensionRatio = 1 / mResolvedDimensionRatio; // 2.0 -> 0.5
}
height = (int) (mResolvedDimensionRatio * mWidth); // 0.5 * 200 得到高度 100
if (mListDimensionBehaviors[DIMENSION_HORIZONTAL] != DimensionBehaviour.MATCH_CONSTRAINT) {
matchConstraintDefaultHeight = MATCH_CONSTRAINT_RATIO_RESOLVED; // 求解完成
useRatio = false;
}
}

2、 ratio=2.0 width=200 height=0

比例可以用 a:b 表示,也可以用浮点表示,结果与 3.1.1 一致。

3、ratio=1:2 width=200 height=0

与 3.1.1 相同,但是比例为 0.5,mResolvedDimensionRatio 求倒数得到 2,height = width * 2 = 200 * 2 = 400。

640?wx_fmt=png

4、ratio=0.5 width=200 height=0

同上

5、 ratio=h,2:1 width=200 height=0

指定了 mDimensionRatioSide 为 VERTICAL,在 setDimensionRatio 方法中会反过来求 dimensionRatio=1/2 得到 0.5

在 addToSolver 方法中,mDimensionRatioSide 不是 UNKNOWN 不再需要反转,height = width * 0.5 = 200 * 0.5 = 100。

640?wx_fmt=png

ConstraintWidget#setDimensionRatio
...
if (dimensionRatioSide == VERTICAL) { // 如果约束垂直边,倒数
dimensionRatio = Math.abs(denominatorValue / nominatorValue);
} else {
dimensionRatio = Math.abs(nominatorValue / denominatorValue);
}

6、ratio=h,2.0 width=200 height=0

与 3.1.5 相同,但是 dimensionRatio 已经指定为 2.0, height = width * 2 = 200 * 2 = 400。

640?wx_fmt=png

7、ratio=h,1:2 width=200 height=0

指定了 mDimensionRatioSide 为 VERTICAL,在 setDimensionRatio 方法中会返过来求 dimensionRatio=2/1 得到时 2,height = width * 2 = 200 * 2 = 400。

8、ratio=h,0.5 width=200 height=0

height = width * 0.5 = 200 * 0.5 = 100

9、 ratio=w,2:1 width=200 height=0

dimensionRatio = 2/1 = 2, height = width * 2 = 200 * 2 = 400

10、 ratio=w,2.0 width=200 height=0

同上

11、 ratio=w,1:2 width=200 height=0

dimensionRatio = 1/2 = 0.5, height = width * 2 = 200 * 0.5 = 100

12、 ratio=w,0.5 width=200 height=0

共 12 种情况总结如下表。

640?wx_fmt=png

当宽度未知时,有如下表:

640?wx_fmt=png

当宽度、高度都未知时,系统会指定满足约足并满足比例的最大尺寸,不再列出表。

分析以上各表,我们作出总结:

  • 比例可以用 a:b 表示,也可以用浮点数表示,但最好使用浮点数表示

因为使用 a:b 的时候,如果有前缀 w 或 h,为了使 h,2:1 与 w,2:1 的效果相反,在ConstraintWidget#setDimensionRatio 中会对以 h 开头的求倒数,为避免混淆,可以直接用浮点指定比例,然后受约束边(未知边)= 已知边 * 比例。

  • 当不使用 w 或 h 时,a:b 总是表示 width:height

为了在已知宽和已知高的情况下,都满足比例为 width:height,在ConstraintWidget#addToSolver 中,当约束高时(高为 0dp),会对比例求倒数。

  • 使用 w,h 时,未知边 = 已知边 * 指定的比例

回到我们最初的需求,需要整个 View 的宽度与高度成比例,那只需要设置宽度为 MATCH_CONSTRAINT,然后左右约束为 parent,然后设置宽高比:

640?wx_fmt=png

由于宽高都是 0dp,我们应该使用 h 指明高受比例约束,因为未知边(高)=已知边(宽)* 比例,因此比例应该是 h,0.5,当使用 : 来表示时,我们在前面介绍过,要取倒数,所以是 h,2:1。

640?wx_fmt=png

确定了整体布局的高度之后,可以再确定内部 View 的宽度(以保证宽高成比例),高度由垂直约束确定,宽度受比例约束,宽度为高度的一半,所以指定比例为 w,0.5:

640?wx_fmt=png

3.2

constrainedWidth 实践-TextView匹配约束与省略

有时我们需求 TextView 居中展示,带左右 margin,当文字过长时末尾可以省略,我们可以很轻易地写出如下布局:

640?wx_fmt=png

看似正常,我们试一下文字或长的情况,发现 margin 失效了。

640?wx_fmt=png

思考一下,因为在 ConstraintLayout 中,margin 是用来在计算约束时使用的,因为 TextView 的宽度没有 MATCH_CONSTRAINT,所以 margin 无效,因为我们把宽度改成 0dp。

640?wx_fmt=png

好像正常了,但是再试一下文字较短的情况,发现宽度不正常了:

640?wx_fmt=png

再思考一下,因为宽度设置为匹配约束,所以去除 margin 都是宽度的范围,这个时候,我们想让它受约束的情况下,宽度也是 wrap_content 的,这个时候就需要使用 layout_constraintWidth_default="wrap"。

640?wx_fmt=png

这下文字较短或过长都满足我们的需求了,但是运行的时候会提示 layout_constraintWidth_default 已经弃用了,所以我们使用 wrap_content,同时使用 layout_constrainedWidth="true" 让宽度也受约束。

640?wx_fmt=png

至此我们实现了需求,如果文字不需要居中,可以再加上 bias 即可。

相关源码之前在 2.7 中也介绍过。

LayoutParams#validate
public void validate() {
isGuideline = false;
horizontalDimensionFixed = true;
verticalDimensionFixed = true;
if (width == WRAP_CONTENT && constrainedWidth) { // constrainedWidth="true"
horizontalDimensionFixed = false;
matchConstraintDefaultWidth = MATCH_CONSTRAINT_WRAP; // 相当于 layout_constraintWidth_default="wrap"
}
if (height == WRAP_CONTENT && constrainedHeight) {
verticalDimensionFixed = false;
matchConstraintDefaultHeight = MATCH_CONSTRAINT_WRAP;
}
if (width == MATCH_CONSTRAINT || width == MATCH_PARENT) { // 即 0dp
horizontalDimensionFixed = false;
// We have to reset LayoutParams width/height to WRAP_CONTENT here, as some widgets like TextView
// will use the layout params directly as a hint to know if they need to request a layout
// when their content change (e.g. during setTextView)
if (width == MATCH_CONSTRAINT && matchConstraintDefaultWidth == MATCH_CONSTRAINT_WRAP) { // wrap
width = WRAP_CONTENT; // 宽度置为 wrap_content
constrainedWidth = true; // 相当于 constrainedWidth="true"
}
}


3.3

链实践-View 跟随 TextView

有时我们要让一个图标跟随在 TextView 后面,当文字过长时文字省略,图标展示,类似 drawableRight 的效果。

640?wx_fmt=png

但是使用 drawableRight 的话,如果 TextView 与图标背景不一致,或者需要为图标设置点击事件,或者右侧需要跟随一个布局,就会比较麻烦。

如果我们尝试使用 ConstraintLayout 来实现,要实现跟随效果,我们可以用链来实现:

640?wx_fmt=png

建好链之后,指定样式为 packed 使两者连在一起:

640?wx_fmt=png

为了使内容居左,指定 bias 为 0:

640?wx_fmt=png

试一下长文字:

640?wx_fmt=png

还是之前宽度未受约束的问题,需要指定 layout_constrainedWidth="true":

640?wx_fmt=png

最后补充相关 margin 完成:

640?wx_fmt=png

3.4

0dp与match_parent实践-不同版本的区别

在京东 App 的开发过程中,笔者曾经遇到这样的问题:

0dp 和 match_parent 都可以实现填充父容器的效果,在使用 match_parent 的时候,发现相同的布局,在开发分支可以正常展示,集成到发版分支后出现了无法展示的问题。

经过排查我们发现是因为两个分支的 ConstraintLayout 的版本不一致导致的(由于开发分支引入了某工具升级了版本,后续已统一),但是不同版本为什么会导致不一致呢?

在学习了 ConstraintLayout 的源码以后,我们可以解答这个问题了:

两个分支的 ConstraintLayout 版本分别为 1.1.3 和 1.0.2, 以下示例布局,在 1.1.3 是正常的,但是在 1.0.2 就无法展示。

<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/root"
android:layout_width="match_parent"
android:layout_height="match_parent">
<ImageView
android:id="@+id/view1"
android:layout_width="match_parent"
android:layout_height="0dp"
android:background="#00F"
app:layout_constraintDimensionRatio="1:1"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</android.support.constraint.ConstraintLayout>

宽高比解析的主要逻辑还是在 ConstraintWidget#addToSolver。

ConstraintWidget#addToSolver 1.0.2
boolean useRatio = false;
int dimensionRatioSide = mDimensionRatioSide;
float dimensionRatio = mDimensionRatio;
if (mDimensionRatio > 0 && mVisibility != GONE) {
if (mHorizontalDimensionBehaviour == DimensionBehaviour.MATCH_CONSTRAINT
&& mVerticalDimensionBehaviour == DimensionBehaviour.MATCH_CONSTRAINT) { // 0dp时都为 0dp
useRatio = true; // 后续添加进求解器,可正常求解
if (horizontalDimensionFixed && !verticalDimensionFixed) {
dimensionRatioSide = HORIZONTAL;
} else if (!horizontalDimensionFixed && verticalDimensionFixed) {
dimensionRatioSide = VERTICAL;
if (mDimensionRatioSide == UNKNOWN) {
// need to reverse the ratio as the parsing is done in horizontal mode
dimensionRatio = 1 / dimensionRatio;
}
}
} else if (mHorizontalDimensionBehaviour == DimensionBehaviour.MATCH_CONSTRAINT) {
dimensionRatioSide = HORIZONTAL;
width = (int) (dimensionRatio * mHeight);
horizontalDimensionFixed = true;
} else if (mVerticalDimensionBehaviour == DimensionBehaviour.MATCH_CONSTRAINT) {
dimensionRatioSide = VERTICAL;
if (mDimensionRatioSide == UNKNOWN) {
// need to reverse the ratio as the parsing is done in horizontal mode
dimensionRatio = 1 / dimensionRatio;
}
height = (int) (dimensionRatio * mWidth); // match_parent 时mWidth 为 0,出现异常
verticalDimensionFixed = true;
}
}
...
if (useRatio) { // 添加进求解器

而在 1.1.3 中,mWidth 是正常的,所以原因是 mWidth 的测量不正确,查看测量 children 的方法,在 1.0.2 版本中。

 ConstraintLayout#internalMeasureChildren 1.0.2
if (doMeasure) {
final int childWidthMeasureSpec;
final int childHeightMeasureSpec;
if (width == MATCH_CONSTRAINT || width == MATCH_PARENT) {
childWidthMeasureSpec = getChildMeasureSpec(parentWidthSpec,
widthPadding, LayoutParams.WRAP_CONTENT);
didWrapMeasureWidth = true;
} else {
childWidthMeasureSpec = getChildMeasureSpec(parentWidthSpec,
widthPadding, width);
}
if (height == MATCH_CONSTRAINT || height == MATCH_PARENT) {
childHeightMeasureSpec = getChildMeasureSpec(parentHeightSpec,
heightPadding, LayoutParams.WRAP_CONTENT);
didWrapMeasureHeight = true;
} else {
childHeightMeasureSpec = getChildMeasureSpec(parentHeightSpec,
heightPadding, height);
}
child.measure(childWidthMeasureSpec, childHeightMeasureSpec);

width = child.getMeasuredWidth();
height = child.getMeasuredHeight();
}
widget.setWidth(width);
widget.setHeight(height);

而在 1.1.3 版本中。

    ConstraintLayout#internalMeasureChildren 1.1.3

if (doMeasure) {
final int childWidthMeasureSpec;
final int childHeightMeasureSpec;

if (width == MATCH_CONSTRAINT) {
childWidthMeasureSpec = getChildMeasureSpec(parentWidthSpec,
widthPadding, LayoutParams.WRAP_CONTENT);
didWrapMeasureWidth = true;
} else if (width == MATCH_PARENT) {
childWidthMeasureSpec = getChildMeasureSpec(parentWidthSpec,
widthPadding, LayoutParams.MATCH_PARENT);
} else {
if (width == WRAP_CONTENT) {
didWrapMeasureWidth = true;
}
childWidthMeasureSpec = getChildMeasureSpec(parentWidthSpec,
widthPadding, width);
}
if (height == MATCH_CONSTRAINT) {
childHeightMeasureSpec = getChildMeasureSpec(parentHeightSpec,
heightPadding, LayoutParams.WRAP_CONTENT);
didWrapMeasureHeight = true;
} else if (height == MATCH_PARENT) {
childHeightMeasureSpec = getChildMeasureSpec(parentHeightSpec,
heightPadding, LayoutParams.MATCH_PARENT);
} else {
if (height == WRAP_CONTENT) {
didWrapMeasureHeight = true;
}
childHeightMeasureSpec = getChildMeasureSpec(parentHeightSpec,
heightPadding, height);
}
child.measure(childWidthMeasureSpec, childHeightMeasureSpec);
if (mMetrics != null) {
mMetrics.measures++;
}

widget.setWidthWrapContent(width == WRAP_CONTENT);
widget.setHeightWrapContent(height == WRAP_CONTENT);
width = child.getMeasuredWidth();
height = child.getMeasuredHeight();
}

widget.setWidth(width);
widget.setHeight(height);

可以看到,由于ConstraintLayout#internalMeasureChildren 方法的实现不一致,在 1.0.2 中,即使宽度为 MATCH_PARENT,也会按照 WRAP_CONTENT 获取 MeasureSpec,然后进行测量;而在 1.1.3 中,会按 MATCH_PARENT 进行测量。

官方文档中也一再强调,不推荐使用 MATCH_PARENT,希望大家注意。

3.5

性能问题分析

首先让我们回顾一下整个布局过程,ConstraintLayout 会在 onMeasure 完成相关计算,首先它会更新层次结构(updateHierarchy),在 setChildrenConstraints 方法中将各个子 widget 添加到 ConstraintWidgetContainer,同时会根据布局中设置各个约束,将锚点与锚点之间联系(connect)起来。

建立约束完成后,就是整个求解过程(solveLinearSystem),分为使用优化器优化(optimize)和使用求解器求解。优化器处理过程中,会根据之前建立的连接,分析(analyze、update)各个结点间的依赖关系,最终形成依赖图。在解图(solveGraph)过程,从 Layout 的 left 和 right 开始,使用深度优先的方法依次处理(resolve)各个依赖的结点,有的依赖在优化过程后就可以得到 resolvedOffset,有的依赖则只有等式关系,需要进一步求解。

接下来会将子 view 添加到求解器(addChildrenToSolver),添加过程中会应用约束(applyConstraints、addRatio、addCenterPoint等),将之前求解的 resolvedOffset 作为赋值或等式添加进求解器中,求解器完成求解,最后从求解器更新解出的结果(updateFromSolver)。

有了结果则 left、top、right、bottom 都确认了,就可以完成布局。

通过布局过程的回顾,我们知道 ConstraintLayout 在 onMeasure 过程中有一套复杂的逻辑,所以在简单的布局中,ConstraintLayout 并没有明显的性能优势,但是随着布局的嵌套层次上升,其性能优势会更明显,因为传统布局的渲染耗时会随着布局层次呈指数级增长(如RelativeLayout 和使用了 layout_weight 的LinearLayout),而得益于 ConstraintLayout 的扁平化不会有该问题。

在京东 App 的实践过程中,我们统计了我京问答楼层的数据,因为本文主要分析整体的布局原理,因此我们在楼层的容器 ViewGroup 的onMeasure 和 onLayout 记录并统计时间,在改造前最深层次为4层,在使用ConstraintLayout 改造后最深层次为2层(还有优化空间),得出以下数据,平均可以减少10%的耗时:

640?wx_fmt=png

但是,如果是简单的、层次结构较少的布局,使用 ConstraintLayout 并不能明显提升渲染性能,以关注行为例,原本是1层的 RelativeLayout,如果使用ConstraintLayout改造,则渲染耗时反而上升了3.9毫秒:

640?wx_fmt=png

测试机型为 MI 10 Ultra,与之前根据源码分析得出的结论一致:

如果是简单的,层次不深的布局,如果考虑渲染性能,不需要使用 ConstraintLayout,传统布局已经很方便了;

如果布局复杂,层次结构较深,则可以尝试使用 ConstraintLayout,在保证性能优势的情况下,还可以充分利用它强大、便利的功能。

源码分析总结

经过以上的分析,我们了解了 ConstraintLayout 的整个布局过程,然后依次查看了 ConstraintLayout 的相关功能,学习了功能的实现原理,最后我们结合实际,用几个例子实践了之前对源码的分析。

至此,ConstraintLayout 的源码分析就结束了,希望大家在阅读文章后有所收获,如果大家有什么问题,或者文中有不准确的地方,欢迎大家一起交流。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK