注册

UIKit -大话 iOS Layout

大话 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)负责视图的布局和重绘


126c8f95dbc63911c1d7da579da56b75.png

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)

123137e9d5362d6f02315cebac88dc1e.png

Layout

视图的布局指的它在屏幕上的位置和大小。每一个视图都有一个frame,用于定义它在父视图坐标系中的位置和大小。UIView会提供一些方法以通知视图的布局发生改变,同样也提供一系列方法供开发者重写,用来处理视图布局完成之后的操作。

layoutSubviews()

  • 这个方法用来处理一个视图和它所有的子视图的重新布局(位置、大小),它提供了当前视图和所有子视图的frame。

  • 这个方法的开销是昂贵的,因为它作用于所有的子视图,并逐级调用所有子视图的layoutSubviews()方法

  • 系统在重新计算frame的时候会调用这个方法,所以当我们需要设置特定的frame的时候,可以重写这个方法。

  • 永远不要直接调用这个方法来刷新frame。在运行循环期间,我们可以通过调用其他方法来触发这个方法,这样造成的开销会小的多。

  • 当UIView的layoutSubviews()调用完成之后,就会调用它的ViewController的viewDidLayoutSubviews()方法,而layoutSubviews()方法是视图布局更新之后的唯一可靠方法。所以对于依赖视图frame相关的逻辑代码应该放在viewDidLayoutSubviews()方法中,而不是viewDidLoadviewDidAppear中,这样就可以避免使用陈旧的布局信息。

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方法。下面可以通过一个例子来理解。

  • numberOfPointsdidSet方法中调用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

在自动布局中,视图的布局和重绘需要三个步骤:

  1. 更新约束:系统会计算并设置视图上所有必须的约束。
  2. 布局阶段layout engine计算视图的frame,并将它们布局。
  3. 显示过程:结束更新循环并重绘视图内容,如果有必要,会调用draw方法。

updateConstraints()

  • updateConstraints在自动布局中的作用就像layoutSubviews在frame布局、以及draw在内容重绘中的作用一样。
  • updateConstraints方法只能被重写,不能被直接调用。
  • updateConstraints方法中一般只实现那些需要改变的约束,对于不需要改变的约束,我们尽可能的别写在里面。
  • Static constraints也应该在接口构造器、视图的初始化方法、或viewDidLoad()方法中实现,而不是放在updateConstraints方法中实现。

有以下一些方式可以自动触发约束的更新。在视图内部设置一个update constraints的标志,该标志会在下一个update cycle中,触发updateConstraints方法的调用。

  • 激活或停用约束;
  • 更改约束的优先级、常量值;
  • 移除约束;

除了自动触发约束的更新之外,同样也有以下方法可以手动触发约束的更新。

setNeedsUpdateConstraints()

调用setNeedsUpdateConstraints方法可以保证在下一个更新周期进行约束的更新。它触发updateConstraints方法的方式是通过标记视图的某个约束已经更新。这个方法的工作方式跟setNeedsLayoutsetNeedsDisplay类似。

updateConstrainsIfNeeded()

这个方法等同于layoutIfNeeded,但是在自动布局中,它会检查constraint update标记(这个标记可以被自动设置、也可以通过setNeedsUpdateConstraintsinvalidateInstinsicContentSize方法手动设置)。如果它确定约束需要更新,就会立即触发updateConstraints方法,而不是等到 run loop 结束。

invalidateInstinsicContentSize()

自动布局中某些视图拥有intrinsicContentSize属性,这是视图根据它的内容得到的自然尺寸。一个视图的intrinsicContentSize通常由所包含的元素的约束决定,但也可以通过重载提供自定义行为。调用invalidateIntrinsicContentSize()会设置一个标记表示这个视图的intrinsicContentSize已经过期,需要在下一个布局阶段重新计算。

How it all connects

布局、显示和约束都遵循着相似的模式,例如:他们更新的方式以及如何在 run loop 的不同时间点上强制更新。任一组件都有一个实际去更新的方法(layoutSubviewsdraw, 和updateConstraints),这些方法可以通过重写来手动操作视图,但任何情况下都不要显式调用。这个方法只在 run loop 的末端会被调用,如果视图被标记了告诉系统该视图需要被更新的标记话。有一些操作会自动设置这个标记,也有一些方法允许显式地设置它。对于布局和约束相关的更新,如果等不到在 run loop 结束才更新的话(例如:其他行为依赖于新布局),也有方法可以让你立即更新,并保证 update layout能被正确标记。下面的表格列出了任意组件会怎样更新及其对应方法。

LayoutDisplayConstraints方法意图
layoutSubviewsdrawupdateConstraints执行更新的方法,可以被重
写,但不能被调用
setNeedsLayoutsetNeedDisplaysetNeedsUpdateConstaints
invalidateInstrinsicContentSize
显示的标记视图需要在下一个更新循环更新
layoutIfNeeded--updateConstraintsIfNeeded立刻更新被标记的视图
添加视图
重设size
设置frame(需要改变bounds)
滑动ScrollView
旋转设备
发生在视图的bounds内部的改变激活、停用约束
修改约束的优先级和常量值
移除约束
隐式触发视图更新的事件

下图总结了更新周期(update cycle)事件循环(event loop)之间的交互,并且指示了上面这些方法在周期中下一步指向的位置。你可以现实的调用layoutIfNeededupdateConstraintsIfNeeded在run loop的任何地方,但需要注意的是,这两个方式是有潜在开销的。如果update constrintsupdate layoutneeds display标记被设置,在 run loop 的结尾处的更新周期就会更新约束、布局、显示内容。一但这些更新全部完成,run loop就会重新开始。


078089e8badaa4e769ee27100ef5ad1c.png



作者:修_远
链接:https://www.jianshu.com/p/98dec55a06c8

0 个评论

要回复文章请先登录注册