前言
请说一下Android里面有哪几种布局?它们分别是哪些?
这样的问题大家一定在自己的面试经历当中被问到过,可能还不止一次,当然这样的问题也很简单,基本都是送分题,遇到了都挺高兴,都能答的上,但是自从2019年的谷歌IO大会亮相了Compose,人们第一次认识到在Android里面也可以使用声明式ui的形式来开发我们的页面,到最近几年,Compose技术日趋成熟化,甚至也支持跨平台技术,国内部分大厂也开始逐渐在项目当中启用Compose,作为Android开发,我们需要意识到,Compose马上就要从一个加分技能变成一个必备技能了,可能某一天,各个厂子招聘Android开发的时候,Compose也将成为一个重要指标,所以开头的那个问题有可能到了将来就会变成
请说一下Compose里面有哪几种布局?它们分别是哪些?
面试官如果忽然问出这样一个问题,你会不会将即将脱口而出的"Linear...."又吞了回去?又或者你说出来了几个,面试官就会先给你一个谜之微笑,然后围绕着你说出来的布局又提出了几个更深入的问题,直到你也给他一个谜之微笑。所以为了避免这一个尴尬的情况发生,我们有必要去学习掌握一些关于Compose方面的知识,而这篇文章就先从布局开始,总共涉及到八个话题,分别是:
- 问题一:Compose中都有哪些布局?
- 问题二:给Column设置了verticalArrangement或者horizontalAlignment属性,但是没有生效,可能是什么原因导致的?
- 问题三:如果在一个Row布局里面,父布局跟子视图同时设置了垂直方向的对齐方式,是会报错?还是会以哪一个为准?
- 问题四:Arrangement都有哪几种对齐方式
- 问题五:为什么说Column是垂直线性布局,而Row是水平线性布局
- 问题六:Surface为什么不能像Box那样使用Modifier.background设置背景颜色?
- 问题七:除了使用滚动组件,还有什么办法可以让一个页面上下滑动
- 问题八:什么是固有特性测量?它是干什么用的?
问题一:Compose中都有哪些布局?
Compose里面的布局主要有
- Column:垂直方向线性布局
- Row:水平方向线性布局
- Box:帧布局
- ConstraintLayout:约束布局
- Scaffold:脚手架布局
问题二:给Column设置了verticalArrangement或者horizontalAlignment属性,但是没有生效,可能是什么原因导致的?
Column是Compose里面的垂直布局,相当于纵向方向的LinearLayout,使用它可以实现子视图垂直方向线性布局,我们这里写个例子
我们看到这里实现了两个文案垂直方向的布局,位置在左侧靠上位置,这个设置我们可以从Column的构造函数中一眼就能发现,我们看下
我们看到verticalArrangement参数是纵向布局,默认是Arrangement.Top靠上位置,另一个horizontalAlignment参数表示横向布局,默认是Alignment.Start靠左位置,那既然是表示位置的参数,那么我们在刚刚的例子里面也加一下,让它可以垂直居中,代码如下
可以看到虽然加了垂直方向的参数,但是如效果图所示并没有将视图垂直居中显示,那垂直的不行我们试试水平的呢?
的确是水平居中的了,但是跟我们想象中的不太一样,是第二个文本相对于第一个文本水平居中,也就是说Column里面的方向参数是针对于它的子视图来排版的,相当于我们LinearLayout的android:gravity属性,我们也可以从Column的参数说明中知道这一点
注释上也说了,这俩参数是作用于layout的children上的,所以我们刚刚设置垂直参数的时候看似没有效果,其实已经生效了,只是Column的大小是自适应于两个Text的大小,所以没有看出来,我们可以给Column设置个高度,就变得明显了
所以如果你给Column或者Row设置了verticalArrangement或者horizontalAlignment参数后没有生效,可以看下是不是忘记设置宽高了
问题三:如果在一个Row布局里面,父布局跟子视图同时设置了垂直方向的对齐方式,是会报错?还是会以哪一个为准?
我们这有段示例代码,有一个Row的布局,在里面放了两个按钮,在Row里面设置的是垂直居中与水平居中,现在如果我给AA这个Button也设置个对齐方式,比如靠下对齐,那么按钮AA的真实情况会怎么样呢?我们改下代码
运行后的结果如下所示
所以这个问题的总结是:如果当父布局与它的子视图同时设置了对齐方式,那么会优先按照子视图的对齐方式来,子视图的对齐方式优先级较高。
问题四:Arrangement都有哪几种对齐方式
Arrangement.Start
整体水平靠左
Arrangement.Center
整体居中
Arrangement.End
整体水平靠右
Arrangement.SpaceBetween
两侧各放置一个,剩余的均匀分布在剩余空间
Arrangement.SpaceArround
所有视图左右两侧空出相等间距
Arrangement.SpaceEvenly
所有视图均匀分布所有空间
问题五:为什么说Column是垂直线性布局,而Row是水平线性布局
面试的时候大家都会有这么一个感觉,平时越是简单寻常的东西,越会拿出来问,导致一些平时不容易去关注的东西,忽然被问到的时候,就一脸黑人问号?就比如我们常用的Column和Row,大家都明白一个是垂直线性布局,一个是水平线性布局,但是凭什么Column是垂直的而不是线性的,Row里面的子视图又为什么不能竖着排,这些大家在平时的开发过程中有没有想过呢?我们就从分析它们的源码的角度看看,这两种布局是如何管理自己的子视图的,首先第一步我们都会从两者的构造函数追踪进去,会发现Column与Row都会先去生成自己的测量策略MeasurePolicy,然后再把测量策略作为一个参数传递给Layout
那什么是测量策略呢?如果说Layout是真正去做测量,布局子视图工作的核心部分,那么测量策略就是告诉Layout如何去做这件事情,这个在Layout的注释中有说明
Layout is the main core component for layout. It can be used to measure and position zero or more layout children. The measurement, layout and intrinsic measurement behaviours of this layout will be defined by the measurePolicy instance. See MeasurePolicy for more details.
所以不管是Column还是Row,它们各自的测量策略才是决定它们如何去布局的关键,所以我们进一步去columnMeasurePolicy与rowMeasurePolicy里面去看下它们的实现方式
我们看到不管是rowMeasurePolicy函数还是columnMeasurePolicy函数,它们都会共同走到同一个函数也就是rowColumnMeasurePolicy,从这一点上来看,Column与Row生成测量策略是在同一个地方处理的,那如何区分是垂直还是水平布局呢?我们先看下rowColumnMeasurePolicy函数的参数
orientation
第一个参数是个LayoutOrientation对象,这个参数表示布局的方向,大家一目了然,在上面rowMeasurePolicy中传的是LayoutOrientation.Horizontal,在columnMeasurePolicy中传的是LayoutOrientation.Vertical,这相当于已经给两个布局做了区分,而且在rowColumnMeasurePolicy函数里面,这个orientation也正是作为一个分界线,通过判断它来选择不同的测量方式,我们后面会讲到。
arrangement
用来放置子视图,是个(Int, IntArray, LayoutDirection, Density, IntArray) -> Unit类型的高阶函数,在这个高阶函数里面都会通过density.arrange,并且在每一种对齐方式中重写该函数,然后进行不同的计算来放置子视图,density.arrange函数里面的参数都是在rowColumnMeasurePolicy里面计算出来的,分别代表的意义是
- totalSize:Int类型,表示剩余可用分配给子视图的空间大小
- sizes:是个IntArray类型,放置所有子视图大小的数组
- layoutDirection:Row布局里面才会用到,表示从左到右或者从右到左不同方向放置子视图的方式也不同
- outPositions:同样也是一个IntArray类型的数组,区别于
sizes,outPositions里面放的都是每一个子视图起点位置的坐标,第一个子视图为0,第二个的位置就是加上第一个子视图的size,以此类推
arrangementSpacing
这个很容易懂,子视图之间的间距,通过Arrangement.spacedBy设置进去,默认为0.
crossAxisAlignment
纵轴的对齐方式,什么是纵轴?无论是Column还是Row,我们把子视图的线性方向称为主轴,比如Column的主轴方向就是垂直方向,与此相对的,另一个方向称为纵轴,纵轴多数体现出一个布局的大小,比如Column的纵轴上只给它设置1dp的大小,那么它相当于就是一条分割线,我们在rowMeasurePolicy与columnMeasurePolicy中可以知道,Column的默认纵轴的对齐方式是Alignment.Start,Row的默认纵轴对齐方式是Alignment.Top
crossAxisSize
纵轴的尺寸模式,是个SizeMode的枚举类型,正如刚刚介绍纵轴的对齐方式说到的,纵轴体现出一个布局的大小,而这个SizeMode的尺寸模式就是设定这个大小的模式,它有两个值
- SizeMode.Wrap:默认值,自适应于子视图的大小
- SizeMode.Expand:纵轴方向上占满父布局的剩余空间
知道rowColumnMeasurePolicy函数每一个参数的含义,我们再去看这个函数里面的代码实现就方便多了,这个函数的入口就是确定了视图主轴与纵轴的大小计算方式,这里每一个Placeable都保存着一个子视图的测量结果
这边已经开始通过orientation来区分了,很容易理解,水平方向的主轴大小就是这个Placeble的width,而垂直方向主轴的大小就是这个Placeable的height,再往下看,代码直接就返回了一个MeasurePolicy的对象,在MeasurePolicy的MeasureScope.measure进行了各种坐标的测量计算,最终得出的就是刚刚说到的Density.arrange里面的参数
通过MeasureScope的layout函数返回一个MeasureResult的对象,其中layout函数就是用来设置子视图的大小,alignment lines,以及具体的放置子视图的逻辑,layout函数的代码实现如下所示
其中决定子视图的位置是通过placementBlock这个lambda表达式来设置,回到MeasureScope.measure调用layout函数的地方,我们看到在placementBlock这个lambda表达式里面就执行了我们刚刚说到的arrangement函数,也就相当于执行了Density.arrange,而Density.arrange函数具体是做什么事情的呢?是去给mainAxisPositions这个存放主轴的坐标数组赋值的,因为这个数组初始值就是个0的IntArray数组
而赋值的地方就依赖于主轴方向上的对齐方式,比如Column的默认主轴对齐方式为Arrangement.Top,那么在Arrangement.Top里面就是这么赋值的
size就是每个子视图主轴上大小的数组,循环遍历后将每一次累加后的值赋给outPosition的对应下标中,那么最终outPosition里面就是每一个子视图开始布局的起始主轴坐标组成的数组,那么主轴的赋值完了,接下去是纵轴,纵轴简单多了,因为不用依赖于对齐方式,它的赋值操作就在arrangement函数下面
这里面首先对子视图测量结果的数组placeables进行了一次遍历,在遍历过程中就确定了纵轴坐标crossAxis,到了这里,一个子视图的主轴,纵轴坐标已经确定好了,接下去就是放置子视图了,代码中在确定好了纵轴坐标以后,也是直接走到了placeable的place函数里面,在这个函数里面就是将计算好主纵轴坐标的子视图放置到父布局的坐标系里面。
代码分析到了这里,我们已经知道了为什么Column是垂直布局,Row是水平布局的了,就是通过定义各自的orientation以及对齐方式之后,计算每个子视图的主纵轴坐标,最后如果orientation是Horizontal的,那么主轴坐标是沿着父坐标系的x轴方向,否则,就是沿着父坐标系y轴方向
问题六:Surface为什么不能像Box那样使用Modifier.background设置背景颜色?
我们知道Box与Surface都是帧布局,但是如果认为既然都是帧布局,那么使用起来也一定相同的话,那么可能会造成一些不明所以的问题,比如下面这个例子
我们看到上方左图中有段代码,一个Column布局里面放着一个Box布局和一个Surface布局,并且分别对这俩布局设置相同的尺寸以及背景颜色,但是从上方右图中可以发现,设置的属性在Box里面都生效了,但是在Surface里面却没有生效,究竟是背景色没有生效还是设置的尺寸没有生效呢?我们更改下Column的背景色再看看
原来是给Surface设置Modifier.background的时候无效了,为什么呢?我们进到Surface里面去找找原因
首先看到的是Surface的一些参数,从上面的注释中我们可以知道这些参数是干什么用的,有可以设置形状样式的,有可以设置阴影的,也有可以设置边框的,其中我们需要看的是color跟contentColor两个参数,contentColor简单来说就是给Surface的子视图设置背景颜色的,如果没有值,就默认取参数color的值,而color默认值是MaterialTheme.colors.surface,这个是啥?我们先不看,只需要暂时知道这个是color的默认值就好,继续走到Surface里面看它的实现原理
终于找到原因了,在第一个绿框子里面首先对background进行了判断并赋值,如果color被赋值了,就优先取color,否则背景色就是默认值MaterialTheme.colors.surface,然后在第二个绿框子里面,直接将backgroundColor赋值给了Modifier.background属性,所以在上层怎么设置Modifier.background也没用,因为Surface早已经默认将color作为Modifier.background的取值。然后我们看下为什么MaterialTheme.colors.surface这个默认值是白色,MaterialTheme.colors里面是Compose库自己定义的一些主题颜色,分别适用于各种场景,而surface就是用来设置比如卡片,菜单这样子组件的背景颜色
而给surface赋值的地方就在函数lightColors里面
我们看到surface的默认值就是设置为白色,相对的在另一个函数darkColors里面也对surface做了赋值,这两个函数分别都是白天模式与黑夜模式下走的函数,而我们的调试环境就是白天模式,所以surface最终的色值就是白色,也就是我们在例子中呈现出来的样子,现在我们修改下上面的例子,将Surface的背景颜色使用color去设置,Surface的背景色立马就出来了
问题七:除了使用滚动组件,还有什么办法可以让一个页面上下滑动
可以使用线性布局的修饰符自带的scroll函数来实现页面上下滑动,下面我们就用这种方式来实现一个简单的可滑动的页面。
这种做法就相当于传统View里面的ScrollView控件,然后在ColumnScope里面累加子视图就可以了,我们这里写个循环函数,函数里面生成我们的子视图
generateItem里面是一个Surface布局,在布局里面我们使用ConstraintLayout构造一个左边头像,右边是说明文的布局,代码如下
运行一下效果图如下所示
不过这种方式不推荐使用,毕竟一下子生成那么多视图对内存开销还是比较大的,容易内存泄漏
问题八:什么是固有特性测量?它是干什么用的?
Compose里面视图的绘制流程跟传统View的绘制流程是一样的,也是三步,分别是
- 组合:执行Compose函数题,并生成LayoutNode
- 布局:对于每一个LayoutNode进行宽高测量并完成位置摆放
- 绘制:将所有LayoutNode绘制到屏幕上
其中在布局这一步中,每一个LayoutNode都会根据自己的父LayoutNode的约束进行自我测量,约束里面包括允许的最大最小宽高,子LayoutNode在正式测量的时候,最大最小宽高是不能超出父LayoutNode给予的约束的,有点绕,其实很常见,举个例子方便理解一点
比如这里有两个TextField,输入内容的时候如果内容长度超过TextField的宽度了,就会换行,TextField高度就会增大,代码如下
这个时候,有个需求是这么要求的,在两个TextField中间放一根分割线,希望分割线的高度永远与两个TextField中最大的一个保持一致,并且随着高度变化而变化,那么首先就可以排除将分割线的高度设置为固定的值,不然没法随着TextField高度变化而改变,那么换一个思路思考,与最高的TextField高度一致,也就是占满父布局的高度,那么我们可以使用Modifier.fillMaxHeight()操作符来占满父布局高度,修改后的代码如下
但这样做我们在预览效果中是这样的
怎么会这样呢?很明显,因为对于Divider来讲,它的父布局给它的最大高度约束就是整个屏幕的高度,所以当Divider设置Modifier.fillMaxHeight()以后,它的高就直接被设置成了屏幕的高度,这个时候我们就需要使用固有特性测量来解决这个问题,那什么是固有特性测量呢,它就是给我们提供了预先测量所有子LayoutNode确定自身宽高的能力,并在正式测量中对子LayoutNode的测量产生影响,那如何使用呢,只需要为父布局的高度设置固有特性测量即可,因为我们Modifier.height不仅可以传固定高度,还可以传一个IntrinsicSize的枚举值
所以我们在上述代码中加入这个固有特性测量的设置,就变成了
加了这个IntrinsicSize.Min是什么意思呢?意思就是让父布局也就是Row根据子视图的信息进行一次计算从而确定个最小高度值,这样当Divider设置Modifier.fillMaxHeight()的时候,高度永远是那个计算出来的最小高度值,但也不是说设置了IntrinsicSize参数就一定能使用固有特性测量,还必须满足一个条件就是父布局已经适配了固有特性测量,如何适配呢?还记得我们在问题五中提到的MeasurePolicy吗?这个测量策略除了实现它的measure函数之外,还实现了其他四个函数
只有实现了这四个函数,才可以使用固定特性测量,目前组件库里面的组件基本都满足了这个条件,但是在自定义视图的时候,我们要记住在重写了MeasurePolicy的measure函数以后,也不要忘记重写这四个函数,不然是无法使用固有特性测量的,它们代表的意义如下:
- IntrinsicMeasureScope.minIntrinsicWidth:表示布局可以承受的最小宽度,上层调用
Modifier.width(IntrinsicSize.Min)来使用这个设置。 - IntrinsicMeasureScope.minIntrinsicHeight:表示布局可以承受的最小高度,上层调用
Modifier.height(IntrinsicSize.Min)来使用这个设置。 - IntrinsicMeasureScope.maxIntrinsicWidth:表示布局宽度增长的上限,上层调用
Modifier.width(IntrinsicSize.Max)来使用这个设置。 - IntrinsicMeasureScope.maxIntrinsicHeight:表示布局高度增长的上限,上层调用
Modifier.height(IntrinsicSize.Max)来使用这个设置。
现在我们看下加了固有特性测量的效果
这一下子分割线就变得“有智商”了是不,这就是固有特性测量带来的好处。
总结
写这个文章之前其实有些知识点我也不清楚,比如固有特性测量,本来也没打算写,也是看到了MeasurePolicy有这些重写函数以后,再去看源码,翻资料才知道这是个啥东西,其实对于我们这些用惯了传统Android视图体系的人来讲,在刚接触Compose或者在使用Compose做日常开发的时候,肯定多多少少有些地方会不太适应,但只要静下心来多看看源码,尝试着多问几个“为什么”,这套新的ui体系也是可以做到真的熟练精通的。