Discover more content...

Discover more content...

Enter some keywords in the search box above, we will do our best to offer you relevant results.

Results

We're sorry!

Sorry about that!

We couldn't find any results for your search. Please try again with another keywords.

WWDC2016 Session 笔记 - Xcode 8 Auto Layout 新特性

目录

  • 1.Incrementally Adopting Auto Layout
  • 2.Design and Runtime Constraints
  • 3.NSGridView
  • 4.Layout Feedback Loop Debugging

一.Incrementally Adopting Auto Layout

Incrementally Adopting Auto Layout是什么意思呢?在我们IB里面布局我们的View的时候,我们并不需要一次性就添加好所有的constraints。我们可以一步步的增加constraints,简化我们的步骤,而且能让我们的设置起来更加灵活。

再谈新特性之前,先介绍一下这个特性对应的背景来源。

有这样一种场景,试想,我们把一个view放在父view上,这个时候并没有设置constraints,当我们运行完程序,就会出现下图的样子。

看上去一切都还正常。但是一旦当我们把设备旋转90°以后,就会出现下图的样子。

这个时候可以发现,这个View的长,宽,以及top和left的边距都没有发生变化。这时我们并没有设置constraints,这是怎么做到的呢?

在程序的编译期,Auto Layout的引擎会自动隐式的给View加上一些constraints约束,以保证View的大小不会发生变化。这个例子中,View被加上了top,left,width,height这4个约束。

如果我们需要更加动态的resize的行为,就需要我们在IB里面自定义约束了。现在问题就来了,有没有更好的方式来做这件事情?最好是能有一种不用约束的方法,也能达到简单的resize的效果。

现在这个问题有了解决办法。在Xcode8中,我们可以给View指定autoresizing masks,而不用去设置constraints。这就意味着我们可以不用约束,我们也能做到简单的resize的效果。

在Autolayout时代之前,可能会有人认出这种UI方式。这是一种Springs & Struts的UI。我们可以设定边缘约束(注:这里的约束并不是指的是Autolayout里面的constraints,是autoresizing masks里面的规则),无论View的长宽如何变化,这些View都会跟随着设置了约束的view一起变化。

上述的例子中,Xcode 8 中在没有加如何constraint就可以做到旋转屏幕之后,View的边距并没有发生变化。这是怎么做到的呢?事实上,Xcode 8的做法是先取出autoresizing masks,然后把它转换成对应的constraints,这个转换的时机发生在Runtime期间。生成对应的constraints是发生在运行时,而不是编译时的原因是可以给我们开发者更加便利的方式为View添加更加细致的约束。

在View上,我们可以设置translatesAutoresizingMaskIntoConstraints属性。

translatesAutoresizingMaskIntoConstraints == true

假设如果View已经在Interface Builder里面加过constraints,“Show the Size inspector”面板依旧会和以前一样。点击View,查看给它加的所有的constraints,这个时候Autoresizing masks就被忽略了,而且translatesAutoresizingMask的属性也会变成false。如下图,我们这个时候在“Show the Size inspector”面板上面就已经看不到AutoresizingMask的设置面板了。

上图就是在Autolayout时代之前,我们一直使用的是autoresizing masks,但是Autolayout时代来临之后,一旦勾选上了这个Autolayout,之前的AutoresizingMask也就失效了。

回到我们最原始的问题上来,Xcode 8 现在针对View可以支持增量的适用Autolayout。这就意味着我们可以从AutoresizingMask开始,先做简单的resize的工作,然后如果有更加复杂的需求,我们再加上适当的约束constraints来进行适配。简而概之,Xcode 8 Autolayout ≈ AutoresizingMask + Autolayout 。

接下来用一个demo的例子来说明一下Xcode 8 Autolayout新特性。
在说例子之前我们先来说一下Xcode 8在storyboard上新增了哪些功能。如下图,我们可以看到,在最下方新增加了一栏,可以切换不同的屏幕大小,可以看出,iPhone现在已经分化成6种屏幕大小需要我们适配了,从大到小,依次是:iPad pro 12.9, iPad 9.7 , iPhone 6s Plus/iPhone 6 Plus , iPhone 6s/iPhone 6, iPhone SE/iPhone5s/iPhone5, iPhone4s/iPhone4。下面还可以选择横竖屏,和不用屏幕百分比的适应性。

回到例子,我们现在对页面上这些view来做简单的AutoresizingMask。右边的那个预览界面是可以看到我们加上这些Mask之后的效果。

先是粉色的父View,我们给它加上如下的AutoresizingMask。

给"雨天"的imageView加上如下AutoresizingMask

给"阴天"的imageView加上如下的AutoresizingMask

最后给我们的中间的Label加上AutoresizingMask

这个时候我们旋转一下屏幕,一切正常,View的排版都如我们所愿。

这个时候我们再选择一下,3:2分屏,这个时候就出现了不对的情况了。Label的Width被挤压了。

原因是因为Autoresizing masks并不会向Autolayout一样,会考虑View的content,所以这里被挤压了。

想fix这个Label,我们可以很容易的添加一个constraints来修复。不过这里我们来谈谈另外一种做法。

进入到Attributes Inspector面板,找到Autoshrink属性,把“fixed font size”切换成“minimum font size”

这个时候就fix上述的问题了。

此时就算是回到landscape,分屏的情况下,已经可以显示正常。

接着我们再来处理一下中间的温度的Label。这个时候我们有比较复杂的需求。这个时候我们就需要用到constraint了。

这个时候我们按时control键,然后拖到父View上,释放,会弹出菜单。我们再按住shift,这样我们可以一次性选择多个constraints。

我们一次性选择“Center Horizontally in Container” 和 “Center Vertically in Container”。注意这个时候右边还是AutoresizingMask的面板,因为这个时候Label还没有任何的constraint。当我们点击“Add Constraints”的时候,就给Label加上了约束,右边的面板也变成了constraints面板了。

我们再给这个Label继续加2个constraints。“Horizontal Spacing”和“Baseline”。

同样的,从Label拖拽到“太阳”的那个imageView上,再添加“Horizontal Spacing”和“Baseline”约束。

这个时候我们更新一下frame。如下图所示,选择“Update Frames”,这个时候所有的frame就都完成了。

这个时候我们更新一下中间温度的Label的字体大小,这时候计算变大,由于我们的constraints都是正确的,两边的View也会随着Label字体变大而变大。

Xocde 8在这个时候就变得更加智能了,会立即自动更新frame。

我们在继续给晴天的上海加上一个背景图。添加一个imageView,然后大小铺满整个父View,把mode 选择成“Aspect Fill”

接下面一般的做法就是在这个imageView上面添加constraints,来使这个View和父View大小一样。但是这种简单的resize的行为在Xocde 8里面就不需要再添加Constraint了,这里我们改用Autoresizing masks来实现。给imageView添加一下这些mask。

我们把imageView放到背景去。这时,我们所有的界面就布局完成了。

测试一下横屏的效果

甚至分屏的一样可以完成任务!

Demo的Github地址,这个demo没啥难的,就是看看效果。

这就是Xcode 8 的Incrementally Adopting Auto Layout,Autoresizing masks + Auto Layout Constraint 一起协同工作!

二.Design and Runtime Constraints

在我们开发过程中有这样一种情况,View的constraints会依据你所加载的数据来添加的。所以在app运行之前,我们是无法知道所有的constraints的。

这里有3种方法可以对应以上的情况。

1.Placeholder Constraints

假设现在我们需要把一张图片放在View的垂直和水平的中间,并且距离左边的边缘有一个leading margin。并且还需要保持其长宽的比例。而这种图片的最终样子,我们并不知道。只有到运行时,我们才能知道这样图片的样子。

为了能在Interface Builder看到我们的图片,我们要先预估一下图片的长宽比例。假设我们估计为4:3。这时候就给图片加上constraints,并且勾上“place order constraint”,这个约束会在build time的时候被移除。

当我们在运行时拿到图片之后,这个是时候我们再给它加上适当的约束和长宽比例即可。

2.Intrinsic Content Size

还是类似上面那种场景,我们有时候会自定义一些UIView或者NSView,这些View里面的content是动态的。Interface Builder并不会运行我们的代码,所以不到app运行的时候我们并不知道里面的大小。我们可以给它设置一个内在的content的大小。

Setting a design time intrinsic content size only affects a view while editing in Interface Builder.The view will not have this intrinsic content size at runtime.

注意一下上面的说明intrinsic content size仅仅相当于是在布局的时候一个placeholder,在运行时这个size就没有了。所以如果开发过程中真的需要用到这个内在的content的大小,那么我们需要overriding的content size


override var intrinsicContentSize: CGSize
3.Turn Off Ambiguity Per View

这个是Xcode 8的一个新特性。当上述2种方法都无法解决我们的需求的时候。这个时候就需要用到这种方法了。Xcode 8给了我们可以在constraints产生歧义的时候,可以动态调整警告级别的能力。

在这个场景中,我们仅仅只知道我们需要把这个imageView放在水平位置的中央,但是imageView的大小和它的水平位置我们并不知道。如果我们仅仅只加上了这一个约束的话,Interface Builder就会报红,因为IB这时候根据我们给的constraints,并不能唯一确定当前的view的位置。

如果我们在之后的运行时,拿到图片的完整信息之后,我们自己知道该如何去加constraints,我们知道该如何去排版保证imageView能唯一确定位置的时候,这时我们可以关掉IB的红色警告。找到“Ambiguous”,这里是警告的级别,我们这里选择“Never Verify”,这时就没有红色的警告和错误提醒了。但是选择这一项的前提是,我们能保证之后运行时我们可以加上足够的constraints保证view的位置信息完整。

以上3种方法就是我们在运行时给view增加constraints的解决办法。

三.NSGridView

这是macOS给我们带来的一个新的layout容器。

有时候我们为了维护constraints的正确性是件比较麻烦的事情,比如即使我们就是一组简单的checkboxes,维护constraints也不容易。这个时候我们会选择用stack view来让我们开发更容易一些。

下图是macOS的app常见到的一组checkboxes。

这时候我们选用NS/UIStackView来实现,因为它有以下的优点,它可以排列一组items,重要的是它可以处理好content size并且可以控制好每个item之间的spacing。

但是stack view依旧有一些场景无法很顺手的处理。例如下图的场景。

这时依旧可以用stack view来实现,但是它不能帮我们根据content完成行和列的对齐。

这就是为什么要引入新的NSGridView的原因。

使用NSGridView,我们可以很容易的做到content在X轴和Y轴上的对齐。仅仅只需要我们把content放进预先定义好的网格中即可,NSGridView会帮我们管理好接下来对齐的一切事情。

我们来看看下面的例子。

NSGridView有2个子类,NSGridRow 和 NSGridColumn,它们俩会自动的管理好content的大小。当然我们可以在需要的时候指定size的大小,padding和spacing的大小。我们也可以动态的隐藏一些rows行和colunms列。

NSGridCell的工作就是管理每个cell里面content view的layout。如果某个cell的内容超出cell的边界,cell会合并起来,就像普通的电子表格app的做法一样。

我们来构建一个简单的界面。设计图如下:

我们并不需要去关心网格的sizing,我们只用关心每一行每一列究竟有多少个content需要被显示出来。

let empty = NSGridCell.emptyContentView
let gridView = NSGridView(views: [
 [brailleTranslationLabel, brailleTranslationPopup], 
 [empty, showContractedCheckbox], 
 [empty, showEightDotCheckbox], 
 [statusCellsLabel, showGeneralDisplayCB],
 [empty, textStyleCB], 
 [showAlertCB] 
])

用上述代码运行出来的界面是这样的:

虽然我们调用构造函数没错,但是出来的界面和设计的明显有一些差距。最明显的问题就是UI被拉开了,有很多空白的地方。

产生问题的原因就在于,网格被约束到了window的边缘。我们的意图应该是window来匹配我们的网格大小,但是现在出现的问题变成了,网格被拉伸了,去匹配window的大小了。

我们解决这个问题的办法就是去改变 grid view内容的hugging的优先级。尽管页面上的constraints已经具有了高优先级,但是我们现在仍可以继续提高优先级,来让constraints推动content,使其远离window的边缘。我们提高一些优先级:

gridView.setContentHuggingPriority(600, for: .horizontal)
gridView.setContentHuggingPriority(600, for: .vertical)

我们会发现,window里面的content更加聚合了,中间的大段空白消失了。

我们再来解决一下window中间的空白,左边的label和右边的content距离太远。根据设计,我们应该让label居右排列。这件事很容易,只要我们调整一下cell的位置信息即可完成。排列的位置信息会影响到cell,行,列,网格视图。

如果没有指定cell的placement这个属性值,那么行列就会根据gridview的placement属性值来确定。这个规则可以使我们在一处设定好placement,瞬间可以改变大量的cell的布局。

//first column needs to be right-justified:
gridView.column(at: 0).xPlacement = .trailing

我们找到gridView的第一列,改变它的xPlacement属性值,这样一列的cell都会变成居右排列。

居右之后,我们又会出现新的问题,baseline不对齐了。

行的对齐和列的对齐原理一样的,同理,我们只需要设置一处,将会影响整个网格视图。

// all cells use firstBaseline alignment
gridView.rowAlignment = .firstBaseline

设置完成之后,整个网格视图就对齐了。

接下来我们再来改变一下pop-up button的边距。

let row = gridView.cell(for: brailleTranslationPopup)!.row!
row.topPadding = 5
row.bottomPadding = 5

这里取第一行的做法也可以和之前取第一列的做法一样,直接取下标0的row即可。这里换一种更好的做法来做。在gridView里面找到包含pop-up button的cell,根据cell找到对应的row行。这种方式比直接去下标index的好处在于,日后如果有人在index 0的位置又增加了一行,那么代码就出错了,而我们这里的代码一直都不会出错,因为保证是取出了包含pop-up button的cell。所以代码里面尽量不要写死固定的index,这样以后维护起来比较困难。

同理,我们也给“status cells”也一起加上Padding

ridView.cell(for:statusCellsLabel)!.row!.topPadding = 6

这里需要对比一下padding 和 spacing的区别。

padding是针对每个行或者每个列之间的间距,我们可以增加padding来改变两两之间的间距。
spacing是针对整个gridview来说的,改变了它,将会影响整个网格视图的布局。

再来看看我们的设计图:

如果没有padding那么就是下图的样子:

如果没有spacing那么就会出现下图的样子:

如果spacing和padding都没有的话,那就都挤在一起了:

最后我们来处理一下最下面那一行包含checkbox的cell

这里就需要用到之前提到了,合并2个cell了。

// Special treatment for centered checkbox:
let cell = gridView.cell(for: showAlertCB)!
cell.row!.topPadding = 4
cell.row!.mergeCells(in: NSMakeRange(0, 2))

这里我们直接指出了,合并前2个cell。

执行完代码之后,就会是这个样子。

最后一行的cell就会横跨2个cell的位置。虽然占了2个cell的位置,但是它依旧还继承着第一列的居右的排列规则。

现在我们的需求是既不希望它居右,也不希望它居左。
checkbox其实是支持排列在2个列之间的,但是由于这相邻的2个列的宽度并不相等,所以gridview不知道该怎么排列了。这时就需要我们手动来改变布局了。

这里可能有人会想,直接把

cell.xPlacement = .none

把cell的xPlacement直接变成none,这样做会一下子打乱整个gridview的constraints布局,我们不能这样做。我们需要再继续给cell加上额外的constraints来维护整个gridview的constraints的平衡。

cell.xPlacement = .none
let centering = showAlertCB.centerXAnchor.constraint(equalTo: textStyleCB.leadingAnchor)
cell.customPlacementConstraints = [centering]

我们只需要在给出checkbox在x轴方面的锚点即可。这时候checkbox就会排列成我们想要的样子了。

至此,我们就完成了需求。总结一下,NSGridView是一个新的控件,能很好的帮助我们进行网格似布局。它能很快很方便的把我们需要展示的content排列整齐。之后我们仅仅只需要调整一下padding和spacing这些信息即可。

四.Layout Feedback Loop Debugging

有时候我们设置好了constraint之后,没有报任何错误,但是有些情况当我们运行起来的时候就有一堆constraint冲突在debug窗口里面,严重的还会使app直接崩溃。崩溃的情况就是遇到了layout feedback loop。

遇到这种情况,往往是发生在“过渡期”,开始或者结束的时候。如果说你点击了一个button,button相应了你的点击,但是之后button不弹起,一直保持着被按下的状态。

然后会观察到CPU使用率爆表,内存倍增,然后app就崩溃了,与此同时返回了一大堆的layout的栈回溯信息。

发生这个情况的原因是某个view的layout被一直执行,一直执行,陷入了死循环中。Runloop就不会停下,CPU的使用率会一直处于峰值。所有的消息都会被收集到自动释放的对象中去,消息一直发送,就会一直收集。所以内存也会倍增。

导致这个原因之一,是setNeedsLayout这个方法。

当其中一个view调用完setNeedsLayout之后,会传递到父视图继续调用setNeedsLayout,父视图的setNeedsLayout可能又会调用到其他视图的layout信息。如果我们能在这相互之后调用找到调用者,也就是那个view调用了这个方法,那我们就可以分析清楚这些setNeedsLayout从哪里来,到哪里去,就能找到死循环的地方了。

这些信息确实很难收集,这也是为何苹果要为我们专门开发这样一个工具,方便我们来调试,查找问题的原因。

开启这个工具的开关在“Arguments”选项里面。如下图。

-UIViewLayoutFeedbackLoopDebuggingThreshold 100 // 50...1000
-NSViewLayoutFeedbackLoopDebuggingThreshold 100 // 50...1000
// Logs to com.apple.UIKit:LayoutLoop or com.apple.AppKit:LayoutLoop

UIView是在iOS里面使用的,NSView是在macOS里面使用的。一旦我们开启了这个开关,那么layout feedback loop debugger就会开始记录每一个调用了setNeedsLayout的信息。

这里我给它设置了阀值是100。

如果发现在一个Runloop中,layout在一个view上面调用的次数超过了阀值,这里设置的是100,也就是说次数超过100,这个死循环还会在跑一小段,因为这个时候要给debugger一个记录信息的时间。当记录完成之后,就会立即抛出异常。并且信息会显示在logs中。log会被记录在com.apple.UIKit:LayoutLoop(iOS)/com.apple.AppKit:LayoutLoop(macOS)中

我们也可以打全局的异常断点exception break point。
在调试窗口也可以用LLDB命令po出一些调试信息。

接下来看2个实用的例子。

1.Upstream Geometry Change

这里有这么多个view,层级如上图。

现在右子串上面10个子view在一次的层级变化中,被移除了。

那么最上层圈起来的3个view都会被影响。于是这3个view的bounds就发生了变化。于是就会隐式的调用setNeedsLayout,来获取新的bounds的信息。(这里经过@kuailejim @冬瓜争做全栈瓜 和大神们实验,setNeedsLayout是需要我们开发者手动调用的,系统并不会在bounds改变的时候隐式调用setNeedsLayout方法)。当前view的bounds改变,但是如果父view没有layout完成,那么父view也会继续收到setNeedsLayout消息。这个消息就会一直被往上传递,直到传到最顶层的view,顶层的view layout完成之后,将会重置下面关联的view的bounds,调用layoutSubview()方法。这时候,死循环就产生了。

这3个view就是上面3个view,下面的view需要setNeedsLayout,需要获取最新的bounds信息,中间蓝色的view也同样需要setNeedsLayout,于是又会让上层的view调用setNeedsLayout()方法,这个时候死循环就产生了。上下各有2个环,共同的view就是中间蓝色的view。环内的view都在相互的请求setNeedsLayout(),并且在自己layout完成以后又会去重置关联的view的bounds。这就形成了triggers layout。

大家对这里产生2个环产生了极大的好奇,热烈讨论这里会产生环的情况。目前可以想到会产生环的场景是这样子的:在上面的3颗子树,当某种场景下,突然删掉了右边的子树,假设用户的屏幕现在是全屏,由于一下子突然删掉了一堆view,那么原来那里就会变成空白,这个时候开发者想要把其他的view平铺到屏幕上。这个时候就需要改变上面父view的bounds,最下面的view会代码里面手动调用上面蓝色的view,setNeedsLayout()方法,并且把蓝色view的bounds设置成全屏,由于蓝色view的bounds改变,这个时候开发者代码里面又手动调用了蓝色view的父view,去执行setNeedsLayout()方法。top view代码里面又写了bounds = origRect,这时候就触发了蓝色view的layout,更新bounds。这样就产生了循环。同理下面也会形成循环。这样就产生了2个死循环了。这些总结需要感谢@kuailejim @冬瓜争做全栈瓜 给出的指点。

这里是我们用工具收集到的log,第一行就是top-level view,接下来的就是递归的过程。往下看,我们会看见一些数字,这些数字就是view接到layout的次数,并且这些数字是有序的。一次死循环中这些数字就是循环时候的顺序。当然一个循环中,每个view可以是起点也可以是终点。这里我们默认把top view设置成起点。这样就可以向我们展示出死循环中一共牵扯进来了多少个view。

从log上看,上面有3个view,下面有10个view,加起来也不等于23,这是为什么呢?我们继续往下看log,来看看“Views receiving layout in order”这里面记录了些什么吧。

这里我们可以很明显的看到,view接收到layout的顺序,一共正好23个。也可以看出,在一起循环中,一个view接收到layout的次数不止一次。

如上图所标示的,有2段在循环,有10个view接收到layout之后,再是2个view,紧接着又是10个view,再是1个view。

回到最初我们使用这个工具的用途上来,最初我们使用这个工具是用来查看 top-level view 接收到setNeedsLayout消息到底从哪里来。继续往下查找,找到调用的栈信息那里。

从上往下看,前几行肯定都是UIViewLayoutFeedbackLoopDebugging的信息。往下看,看到第6行,可以看到DropShadowView接受到了信息,准备setBounds。回看之前的层级信息,我们会发现DropShadowView是TransitionView的子view。

引起DropShadowView触发setBounds的唯一途径是,它的父view,TransitionView触发了setNeedsLayout()方法。因为这个时候TransitionView还没有layout。

回到“geometry change records”,这个时候我们可以看到选中的这3行信息在一遍遍的循环。看第2行和第3行,我们可以看到是来自于TransitionView的layout。这时是合理的。再看第一行,会发现这个时候有一个TransitionView的子view调用了viewLayoutSubviews。

这个时候我们就定位到了bug的根源了,只要想方设法在layout的时候,不要改变superview的bounds即可以去掉这个死循环。

2.Ambiguous Layout From Constraints

在我们设置constraints约束的时候,常常会产生一些歧义的constraints。歧义的constraints通常不可怕,我们只需要稍稍做些调整,然后update all frame即可。

但是有如下的场景会导出形成环:

当你的view在旋转之后,constraints也随之变化,然后有些view在旋转之后的constraint就会相互冲突。因次有些constraint就形成了环。

这个问题在没有这个debugger工具的时候,思考起来很烧脑,没有任何头绪,这也是为什么log把top-level view放在第一行的原因,给我们暗示,从这里开始找bug的原因。

在log,我们会看到好多的“Ambiguous Layout”。注意:tAMIC是Translates Auto Resizing Mask into Constraints的缩写。

我们来看看详细的log。看log之前,我们应该知道,constraint虽然冲突很多,但是可能引起冲突的constraint只有一个,也就是说当我们更正了其中一个constraint,很可能所有的冲突都解决了。

如上图log所示,在minX这里我们设置了2个带有冲突性的constraint,一个是-60,一个是-120。我们可以一个个的检查约束,但是这个列表很长,检查起来也比较麻烦。

那我们画图来分析一下这个问题。

如图,label有leading和trailing padding,label是container的子view,container是action的子父,action是representation的子view。container和action view之间有一个居中的centering constraint。action view在representation view上有一个autoresizing mask constraints。

然后每个representation view之间是alignment对齐的。自此看来,这些view并没有足够的constraints能让这些view都能确定位置信息。比如在X轴上,这一串view是可以存在在任何的位置,所以产生了歧义的constraint。

解决上面的歧义的

-UIViewLayoutFeedbackLoopDebuggingThreshold 100 // 50...1000
-NSViewLayoutFeedbackLoopDebuggingThreshold 100 // 50...1000
 //Logs to com.apple.UIKit:LayoutLoop or com.apple.AppKit:LayoutLoop

用debugger就可以解决上述的问题。

总结

这个Xcode 8 给我们的Autolayout融合了之前Autoresizing masks的用法,使两个合并在一起使用,这样不同场景我们可以有更多的选择,可以更加灵活的处理布局的问题。还允许我们能手动调节constraints警告优先级别。

针对macOS的布局问题,又给我们带来了新的控件NSGridView

最后给我们带来的新的调试Layout Feedback Loop Debugging的工具,能让我们平时调试起来比较头疼的问题,有了工具可以有据可循,迅速定位问题,查找问题。

最后,请大家多多指教。