揭秘 iOS 开发布局

作者: 梦想编程家小枫 | 来源:发表于2018-04-09 22:36 被阅读231次

    在你刚开始开发 iOS 应用时,最难避免或者是调试的就是和布局相关的问题。通常这种问题发生的原因就是对于 view 何时真正更新的错误理解。想理解 view 在何时是如何更新的,需要对 iOS RunLoop 和相关的UIView方法有深刻的理解。这篇文章会介绍这些关联,希望能帮你澄清如何用UIView的方法来获得正确的行为。

    有一句话叫做三人行必有我师,小编有一个iOS技术交流群659170228。不管你是小白还是大牛欢迎加入

    一个 iOS 应用的主 RunLoop


    一个 iOS 应用的主 RunLoop 负责处理所有的用户输入事件并触发相应的响应。所有的用户交互都会被加入到一个事件队列中。下图中的Applicationobject 会从队列中取出事件并将它们分发到应用中的其他对象上。本质上它会解释这些来自用户的输入事件,然后调用在应用中的 Core objects 相应的处理代码,而这些代码再调用开发者写的代码。当这些方法调用返回后,控制流回到主 RunLoop 上,然后开始update cycle(更新周期)。Update cycle 负责布局并且重新渲染视图们(接下来会讲到)。下面的图片展示了应用是如何和设备交互并且处理用户输入的。

    https://developer.apple.com/library/content/documentation/General/Conceptual/Devpedia-CocoaApp/MainEventLoop.html 

    Update Cycle


    Update cycle 是当应用完成了你的所有事件处理代码后控制流回到主 RunLoop 时的那个时间点。正是在这个时间点上系统开始更新布局、显示和设置约束。如果你在处理事件的代码中请求修改了一个 view,那么系统就会把这个 view 标记为需要重画(redraw)。在接下来的 Update cycle 中,系统就会执行这些 view 上的更改。用户交互和布局更新间的延迟几乎不会被用户察觉到。iOS 应用一般以 60 fps 的速度展示动画,就是说每个更新周期只需要 1/60 秒。这个更新的过程很快,所以用户在和应用交互时感觉不到 UI 中的更新延迟。但是由于在处理事件和对应 view 重画间存在着一个间隔,RunLoop 中的某时刻的 view 更新可能不是你想要的那样。如果你的代码中的某些计算依赖于当下的 view 内容或者是布局,那么就有在过时 view 信息上操作的风险。理解 RunLoop、update cycle 和UIView中具体的方法可以帮助避免或者可以调试这类问题。下面的图展示出了 update cycle 发生在 RunLoop 的尾部。

    布局


    一个视图的布局指的是它在屏幕上的的大小和位置。每个 view 都有一个 frame 属性,用来表示在父 view 坐标系中的位置和具体的大小。UIView给你提供了用来通知系统某个 view 布局发生变化的方法,也提供了在 view 布局重新计算后调用的可重写的方法。

    layoutSubviews()

    这个UIView方法处理对视图(view)及其所有子视图(subview)的重新定位和大小调整。它负责给出当前 view 和每个子 view 的位置和大小。这个方法很昂贵,因为它会在每个子视图上起作用并且调用它们相应的layoutSubviews方法。系统会在任何它需要重新计算视图的 frame 的时候调用这个方法,所以你应该在需要更新 frame 来重新定位或更改大小时重载它。然而你不应该在代码中显式调用这个方法。相反,有许多可以在 run loop 的不同时间点触发layoutSubviews调用的机制,这些触发机制比直接调用layoutSubviews的资源消耗要小得多。

    当layoutSubviews完成后,在 view 的所有者 view controller 上,会触发viewDidLayoutSubviews调用。因为viewDidLayoutSubviews是 view 布局更新后会被唯一可靠调用的方法,所以你应该把所有依赖于布局或者大小的代码放在viewDidLayoutSubviews中,而不是放在viewDidLoad或者viewDidAppear中。这是避免使用过时的布局或者位置变量的唯一方法。

    自动刷新触发器

    有许多事件会自动给视图打上 “update layout”标记,因此layoutSubviews会在下一个周期中被调用,而不需要开发者手动操作。这些自动通知系统 view 的布局发生变化的方式有:

    修改 view 的大小

    新增子 view

    用户在UIScrollView上滚动(layoutSubviews会在UIScrollView和它的父 view 上被调用)

    用户旋转设备

    更新视图的 constraints

    这些方式都会告知系统 view 的位置需要被重新计算,继而会自动转化为一个最终的layoutSubviews调用。当然,也有直接触发layoutSubviews的方法。

    setNeedsLayout()

    触发layoutSubviews调用的最省资源的方法就是在你的视图上调用setNeedsLaylout方法。调用这个方法代表向系统表示视图的布局需要重新计算。setNeedsLaylout方法会立刻执行并返回,但在返回前不会真正更新视图。视图会在下一个 update cycle 中更新,就在系统调用视图们的layoutSubviews以及他们的所有子视图的layoutSubviews方法的时候。即使从setNeedsLayout返回后到视图被重新绘制并布局之间有一段任意时间的间隔,但是这个延迟不会对用户造成影响,因为永远不会长到对界面造成卡顿。

    layoutIfNeeded()

    layoutIfNeeded是另一个会让UIView触发layoutSubviews的方法。 当视图需要更新的时候,与setNeedsLayout()会让视图在下一周期调用layoutSubviews更新视图不同,layoutIfNeeded会立即调用layoutSubviews方法。但是如果你调用了layoutIfNeeded之后,并且没有任何操作向系统表明需要刷新视图,那么就不会调用layoutsubview。如果你在同一个 run loop 内调用两次layoutIfNeeded,并且两次之间没有更新视图,第二个调用同样不会触发layoutSubviews方法。

    使用layoutIfNeeded,则布局和重绘会立即发生并在函数返回之前完成(除非有正在运行中的动画)。这个方法在你需要依赖新布局,无法等到下一次 update cycle 的时候会比setNeedsLayout有用。除非是这种情况,否则你更应该使用setNeedsLayout,这样在每次 run loop 中都只会更新一次布局。

    当对希望通过修改 constraint 进行动画时,这个方法特别有用。你需要在 animation block 之前调用layoutIfNeeded,以确保在动画开始之前传播所有的布局更新。在 animation block 中设置新 constrait 后,需要再次调用layoutIfNeeded来动画到新的状态。

    显示

    一个视图的显示包含了颜色、文本、图片和 Core Graphics 绘制等视图属性,不包括其本身和子视图的大小和位置。和布局的方法类似,显示也有触发更新的方法,它们由系统在检测到更新时被自动调用,或者我们可以手动调用直接刷新。

    draw(_:)

    UIView的draw方法(本文使用 Swift,对应 Objective-C 的drawRect)对视图内容显示的操作,类似于视图布局的layoutSubviews,但是不同于layoutSubviews,draw方法不会触发后续对视图的子视图方法的调用。同样,和layoutSubviews一样,你不应该直接调用draw方法,而应该通过调用触发方法,让系统在 run loop 中的不同结点自动调用。

    setNeedsDisplay()

    这个方法类似于布局中的setNeedsLayout。它会给有内容更新的视图设置一个内部的标记,但在视图重绘之前就会返回。然后在下一个 update cycle 中,系统会遍历所有已标标记的视图,并调用它们的draw方法。如果你只想在下次更新时重绘部分视图,你可以调用setNeedsDisplay(_:),并把需要重绘的矩形部分传进去(setNeedsDisplayInRectin OC)。大部分时候,在视图中更新任何 UI 组件都会把相应的视图标记为“dirty”,通过设置视图“内部更新标记”,在下一次 update cycle 中就会重绘,而不需要显式的setNeedsDisplay调用。然而如果你有一个属性没有绑定到 UI 组件,但需要在每次更新时重绘视图,你可以定义他的didSet属性,并且调用setNeedsDisplay来触发视图合适的更新。

    有时候设置一个属性要求自定义绘制,这种情况下你需要重写draw方法。在下面的例子中,设置numberOfPoints会触发系统系统根据具体点数绘制视图。在这个例子中,你需要在draw方法中实现自定义绘制,并在numberOfPoints的 property observer 里调用setNeedsDisplay。

    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这样可以触发立即更新的方法。通常情况下等到下一个更新周期再重新绘制视图也无所谓。

    约束

    自动布局包含三步来布局和重绘视图。第一步是更新约束,系统会计算并给视图设置所有要求的约束。第二步是布局阶段,布局引擎计算视图和子视图的 frame 并且将它们布局。最后一步完成这一循环的是显示阶段,重绘视图的内容,如实现了draw方法则调用draw。

    updateConstraints()

    这个方法用来在自动布局中动态改变视图约束。和布局中的layoutSubviews()方法或者显示中的draw方法类似,updateConstraints()只应该被重载,绝不要在代码中显式地调用。通常你只应该在updateConstraints方法中实现必须要更新的约束。静态的约束应该在 interface builder、视图的初始化方法或者viewDidLoad()方法中指定。

    通常情况下,设置或者解除约束、更改约束的优先级或者常量值,或者从视图层级中移除一个视图时都会设置一个内部的标记 “update constarints”,这个标记会在下一个更新周期中触发调用updateConstrains。当然,也有手动给视图打上“update constarints” 标记的方法,如下。

    setNeedsUpdateConstraints()

    调用setNeedsUpdateConstraints()会保证在下一次更新周期中更新约束。它通过标记“update constraints”来触发updateConstraints()。这个方法和setNeedsDisplay()以及setNeedsLayout()方法的工作机制类似。

    updateConstraintsIfNeeded()

    对于使用自动布局的视图来说,这个方法与layoutIfNeeded等价。它会检查 “update constraints”标记(可以被setNeedsUpdateConstraints或者invalidateInstrinsicContentSize方法自动设置)。如果它认为这些约束需要被更新,它会立即触发updateConstraints(),而不会等到 run loop 的末尾。

    invalidateIntrinsicContentSize()

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

    它们是如何连接起来的

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

    下面的流程图总结了 update cycle 和 event loop 之间的交互,并指出了上文提到的方法在 run loop 运行期间的位置。你可以在run loop中的任意一点显式地调用 layoutIfNeeded 或者 updateConstraintsIfNeeded,需要记住,这开销会很大。在循环的末端是 update cycle,如果视图被设置了特定的 “update constraints”,“update layout” 或者 “needs display” 标记,在这节点会更新约束、布局以及展示。一旦这些更新结束,runloop 会重新启动。

    这是我的一个iOS技术交流群659170228,不管你是小白还是大牛欢迎加入一起交流共同进步 ,

    作者:金西西

    著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

    相关文章

      网友评论

        本文标题:揭秘 iOS 开发布局

        本文链接:https://www.haomeiwen.com/subject/qyhwhftx.html