大话 iOS Layout
在iOS的开发中,我们绝大部分的时间都是在跟UI打交道,例如UI怎么布局,UI怎么刷新,以及对复杂UI的优化,使我们的APP更加流畅。
对于UI的布局,xcode提供了可视化的布局方式:xib、storyboard,这是非常便捷的布局方式,所见即所得,门槛也非常低,但占用的资源相对代码来说更多,而且在多人协作开发的过程中,处理xml格式的文件冲突是非常困难的,所以很多团队都不推荐使用这类方式的布局,适合需求相对简单的团队、需要快速迭代的项目。
纯代码方式的布局是我们必修课,苹果有提供 Frame
和 Auto Layout
两种方式的布局。Auto Layout
是苹果为我们提供的一整套布局引擎(Layout Engine
),这套引擎会将视图、约束、优先级、大小通过计算转化成对应的 frame,而当约束改变的时候,会再次触发该系统重新计算。Auto Layout
本质就是一个线性方程解析Engine。基于Auto Layout
,不再需要像frame时代一样,关注视图的尺寸、位置相关参数,转而关注视图之间的关系,描述一个表示视图间布局关系的约束集合,由Engine解析出最终数值。
在混合开发的布局中,同样都会有一个虚拟DOM
机制,当布局发生改变的时候,框架会将修改提交到虚拟DOM
,虚拟DOM
先进行计算,计算出各个节点新的相对位置,最后提交到真实DOM
,以完成渲染,当多个修改同时被提交的时候,框架也会对这些修改做一个合并,避免每次修改都要刷新。这种机制跟iOS中的run loop的渲染机制非常类似。Layout Engine
计算出视图的frame,等到下一次run loop到来的时候,将结果提交到渲染层以完成渲染,同样,也会对一些修改进行“合并”,直到下一次运行循环到来时,才将结果渲染出来。
这篇文章主要讲解在布局的过程中,视图分别在Auto Layout
以及Frame
的方式下,如何完成刷新。
Main run loop of an iOS app
主运行循环是iOS应用中用来处理所有的用户输入和触发合适的响应事件。iOS应用的所有用户交互都会被添加到一个事件队列(event queue)
里面。UIApplication object
会将所有的事件从这个队列中取出,然后分发到应用中的其他对象。它通过解释用户的输入事件并在application’s core objects
中调用相似的处理,以执行运行循环。这些处理事件可以被开发者重写。一旦这这些方法调用结束,程序就会回到运行循环,然后开始更新周期(update cycle)
。更新周期(update cycle)
负责视图的布局和重绘,下一节会做详细介绍。
https://developer.apple.com/library/content/documentation/General/Conceptual/Devpedia-CocoaApp/MainEventLoop.html
Update cycle
更新周期(update cycle)
开始的时间点:应用程序执行完所有事件处理后,控制权返回main run loop
。在这个时间点后,系统开始布局、显示和约束。如果在系统正在执行事件处理时需要更改某个视图,系统会将这个视图标记为需要重绘
。在下一个更新周期,系统将会执行这个视图的所有修改。为了让用户交互和布局更新之间的延迟不被用户察觉到。iOS应用程序以60fps
的帧率刷新界面,也就是每一个更新周期的时间是1/60秒
。由于更新周期存在一定的时间间隔,所以我们在布局界面的过程中,会遇到某些视图并不是我们想要实现的效果,拿到的其实是上一次运行循环的效果。(这里YY一下,大家可能都会在业务代码中遇到过这种问题,某个视图布局不对,我们加个0.5的延迟,然后就正确了,或者是异步添加到主队列,界面布局也正常了。这些都是取巧的操作,刷新相关的问题仍然存在,可能在你的这个界面不会出问题,但有可能会影响到别人的、或者其他的界面。)所以说,“出来混,迟早都是要还的”,问题也迟早都是要解决的。接下来将会介绍如何准确的知道视图的布局、绘制、约束触发的时间点,如何正确的去刷新视图。
60fps 的 1/60秒
这1/60秒
是CPU+GPU
整个计算+绘制的时间间隔,如果在这个时间段内,并没有完成显示数据的准备,那iOS应用将显示上一帧画面,这个就是所谓的掉帧。CPU中有大量的逻辑控制单元,而GPU中有大量的数据计算单元,所以GPU的计算效率远高于GPU。为了提高效率,我们可以尽量将计算逻辑交给GPU。关于具体GPU的绘制流程相关的文章,可以参考OpenGL专题
下图可以看出来,在 main run loop 结束后开始 更新周期(update cycle)
Layout
视图的布局指的它在屏幕上的位置和大小。每一个视图都有一个frame
,用于定义它在父视图坐标系中的位置和大小。UIView会提供一些方法以通知视图的布局发生改变,同样也提供一系列方法供开发者重写,用来处理视图布局完成之后的操作。
layoutSubviews()
这个方法用来处理一个视图和它所有的子视图的重新布局(位置、大小),它提供了当前视图和所有子视图的frame。
这个方法的开销是昂贵的,因为它作用于所有的子视图,并逐级调用所有子视图的
layoutSubviews()方法
。系统在重新计算frame的时候会调用这个方法,所以当我们需要设置特定的frame的时候,可以重写这个方法。
永远不要直接调用这个方法来刷新frame。在运行循环期间,我们可以通过调用其他方法来触发这个方法,这样造成的开销会小的多。
当UIView的
layoutSubviews()
调用完成之后,就会调用它的ViewController的viewDidLayoutSubviews()
方法,而layoutSubviews()
方法是视图布局更新之后的唯一可靠方法。所以对于依赖视图frame相关的逻辑代码应该放在viewDidLayoutSubviews()
方法中,而不是viewDidLoad
或viewDidAppear
中,这样就可以避免使用陈旧的布局信息。
layoutSubviews 是在系统重新计算frame之前调用,还是在重新计算frame之后调用。(初步估计是计算之后)
Automatic refresh triggers
有很多事件会自动标记一个视图已经更改了布局,以便于layoutSubviews()
方法在下一次执行的时候调用,而不是由开发者手动去做这些事。
一些自动通知系统布局已经更改的方法有:
- 调整视图的大小
- 添加一个子视图
- 用户滑动UIScrollView(UIScrollView和它的父视图将会调用
layoutSubviews()
) - 用户旋转设备
- 更新视图的约束
这些方法都会告诉系统需要重新计算视图的位置,而且最终也会自动调用到layoutSubviews()
方法。除此之外,有方法可以直接触发layoutSubviews()
的调用。
setNeedsLayout()
-
setNeedsLayout()
是触发layoutSubviews()
造成开销最小的方法。它会直接告诉系统,view 的布局需要重新计算。setNeedsLayout()
会立即执行并返回,而且在返回之前,是不会去更新 view。 - 当系统逐级调用
layoutSubviews()
之后,view 会在下一个更新周期(update cycle)
更新。尽管在setNeedsLayout()
与视图的重绘和布局之间有一定的时间间隔,但这个时间间隔不会长到影响到用户交互。
setNeedsLayout()
是在什么时候 return
layoutIfNeeded()
- 执行
layoutIfNeeded()
之后,如果 view 需要更新布局,系统会立刻调用layoutSubviews()
方法去更新,而不是将layoutSubviews()
方法加入队列,等待下一次更新周期(update cycle)
再去调用; - 当我们在调用
setNeedsLayout()
或者是其他自动触发刷新的事件之后,执行layoutIfNeeded()
方法,可以立即触发layoutSubviews()
方法。 - 如果一个 view 不需要更新布局,执行
layoutIfNeeded()
方法也不会触发layoutSubviews()
方法。例如,当我们连续执行两次layoutIfNeeded()
方法,第二次执行将不会触发layoutSubviews()
方法。
使用 layoutIfNeeded()
方法,子视图的布局和重绘将立即发生,并且在该方法返回之前就可以完成(除非视图正在做动画)。如果需要依赖一个新的视图布局,并且不想等视图的更新到下一个 更新周期(update cycle)
才完成,使用 layoutIfNeeded()
方法时非常有用的。除了这种场景,一般使用 setNeedsLayout()
方法等到下一个 更新周期(update cycle)
去更新视图就可以了,这样可以保证在一次 run loop 里面只更新视图一次。
在使用约束动画
的时候,这个方法是非常有用的。一般操作是,在动画开始前调用一次 layoutIfNeeded()
方法,以保证在动画之前布局的更新都已经完成。配置完我们的动画后,在 animation block
里面,再调一次 layoutIfNeeded()
方法,就可以更新到新的状态。
Display
视图的展示包含的属性不涉及视图及子视图的 size
和 position
,例如:颜色、文本、图片和 Core Graphic drawing
。显示通道包含于触发更新的布局通道类似的方法,它们都是当系统检测到有变更时,由系统调用,而且我们也都能手动的去触发刷新。
draw(_:)
UIView 的 draw
(Objective-C里面是drawRect
)方法,作用于视图的内容
,就像 layoutSubviews()
作用于视图的 size
和 position
。但是,这个方法不会触发子视图的 draw
(Objective-C里面是drawRect
)方法。这个方法也不能由开发者直接调用,我们应该在 run loop 期间,调用可以触发 draw
方法的其他方法来触发draw
方法。
setNeedsDisplay()
setNeedsDisplay()
方法等同于 setNeedsLayout()
方法。它会设置一个内部的标记
来标记这个视图的内容需要更新,但它在视图重绘之前返回。然后,在下一个 更新周期(update cycle)
系统会遍历所有有这个标记
的视图,然后调用它们的 draw
方法。如果只需要在下一个更新周期(update cycle)
重绘视图的部分内容,可以调用setNeedsDisplay()
方法,并通过rect
属性来设置我们需要重绘的部分。
大多数情况下,想要在下一个更新周期(update cycle)
更新一个视图上的UI组件,通过自动设置内部的内容更新
标记而不是手动调用setNeedsDisplay()
方法。但是,如果一个视图(aView
)并不是直接绑定到UI组件上的,但是我们又希望每次更新的时候都可以重绘这个视图,我们可以通过观察视图(aView
)属性的setter方法(KVO),来调用setNeedsDisplay()
方法以触发适当的视图更新。
当需要执行自定义绘制时,可以重写draw
方法。下面可以通过一个例子来理解。
- 在
numberOfPoints
的didSet
方法中调用setNeedsDisplay()
方法,可以触发draw
方法。 - 通过重写
draw
方法,以达到在不同情况下,绘制不同的样式的效果。
class MyView: UIView {
var numberOfPoints = 0 {
didSet {
setNeedsDisplay()
}
}
override func draw(_ rect: CGRect) {
switch numberOfPoints {
case 0:
return
case 1:
drawPoint(rect)
case 2:
drawLine(rect)
case 3:
drawTriangle(rect)
case 4:
drawRectangle(rect)
case 5:
drawPentagon(rect)
default:
drawEllipse(rect)
}
}
}
不像
layoutIfNeeded
可以立刻触发layoutSubviews
那样,没有方法可以直接触发一个视图的内容更新。视图内容的更新必须等到下一个更新周期
去重绘视图。
Constraints
在自动布局中,视图的布局和重绘需要三个步骤:
-
更新约束
:系统会计算并设置视图上所有必须的约束。 -
布局阶段
:layout engine
计算视图的frame,并将它们布局。 -
显示过程
:结束更新循环并重绘视图内容,如果有必要,会调用draw
方法。
updateConstraints()
-
updateConstraints
在自动布局中的作用就像layoutSubviews
在frame布局、以及draw
在内容重绘中的作用一样。 -
updateConstraints
方法只能被重写,不能被直接调用。 -
updateConstraints
方法中一般只实现那些需要改变的约束,对于不需要改变的约束,我们尽可能的别写在里面。 -
Static constraints
也应该在接口构造器、视图的初始化方法、或viewDidLoad()
方法中实现,而不是放在updateConstraints
方法中实现。
有以下一些方式可以自动触发约束的更新
。在视图内部设置一个update constraints
的标志,该标志会在下一个update cycle
中,触发updateConstraints
方法的调用。
- 激活或停用约束;
- 更改约束的优先级、常量值;
- 移除约束;
除了自动触发约束的更新之外,同样也有以下方法可以手动触发约束的更新。
setNeedsUpdateConstraints()
调用setNeedsUpdateConstraints
方法可以保证在下一个更新周期进行约束的更新。它触发updateConstraints
方法的方式是通过标记视图的某个约束已经更新。这个方法的工作方式跟setNeedsLayout
和setNeedsDisplay
类似。
updateConstrainsIfNeeded()
这个方法等同于layoutIfNeeded
,但是在自动布局中,它会检查constraint update
标记(这个标记可以被自动设置、也可以通过setNeedsUpdateConstraints
、invalidateInstinsicContentSize
方法手动设置)。如果它确定约束需要更新,就会立即触发updateConstraints
方法,而不是等到 run loop 结束。
invalidateInstinsicContentSize()
自动布局中某些视图拥有intrinsicContentSize
属性,这是视图根据它的内容
得到的自然尺寸。一个视图的intrinsicContentSize
通常由所包含的元素的约束决定
,但也可以通过重载提供自定义行为。调用invalidateIntrinsicContentSize()
会设置一个标记表示这个视图的intrinsicContentSize已经过期
,需要在下一个布局阶段重新计算。
How it all connects
布局、显示和约束都遵循着相似的模式,例如:他们更新的方式以及如何在 run loop 的不同时间点上强制更新。任一组件都有一个实际去更新的方法(layoutSubviews
, draw
, 和updateConstraints
),这些方法可以通过重写来手动操作视图,但任何情况下都不要显式调用。这个方法只在 run loop 的末端会被调用,如果视图被标记了告诉系统该视图需要被更新的标记
话。有一些操作会自动设置这个标记
,也有一些方法允许显式地设置它。对于布局和约束相关的更新,如果等不到在 run loop
结束才更新的话(例如:其他行为依赖于新布局),也有方法可以让你立即更新,并保证 update layout
能被正确标记。下面的表格列出了任意组件会怎样更新及其对应方法。
Layout | Display | Constraints | 方法意图 |
---|---|---|---|
layoutSubviews | draw | updateConstraints | 执行更新的方法,可以被重 |
写,但不能被调用 | |||
setNeedsLayout | setNeedDisplay | setNeedsUpdateConstaints invalidateInstrinsicContentSize |
显示的标记视图需要在下一个更新循环更新 |
layoutIfNeeded | -- | updateConstraintsIfNeeded | 立刻更新被标记的视图 |
添加视图 重设size 设置frame(需要改变bounds) 滑动ScrollView 旋转设备 |
发生在视图的bounds内部的改变 | 激活、停用约束 修改约束的优先级和常量值 移除约束 |
隐式触发视图更新的事件 |
下图总结了更新周期(update cycle)
和事件循环(event loop)
之间的交互,并且指示了上面这些方法在周期中下一步指向的位置。你可以现实的调用layoutIfNeeded
和updateConstraintsIfNeeded
在run loop的任何地方,但需要注意的是,这两个方式是有潜在开销的。如果update constrints
、update layout
、needs display
标记被设置,在 run loop 的结尾处的更新周期
就会更新约束、布局、显示内容。一但这些更新全部完成,run loop就会重新开始。
参考:Demystifying iOS Layout