TLDR
使用(下面的示例代码)将待处理的 UI 更改强制到渲染服务器上CATransaction.flush()
或将工作拆分到多个帧。CADisplayLink
概括
在 UIKit 中是否有一种方法可以在绘制完您堆叠的所有视图时获得回调?
不
iOS 就像一个游戏渲染变化(无论你做了多少),每帧最多一次。在您的更改呈现在屏幕上之后,保证一段代码运行的唯一方法是等待下一帧。
还有其他解决方案吗?
是的,iOS 可能每帧只能渲染一次更改,但您的应用不是该渲染的内容。窗口服务器进程是。
您的应用程序执行其布局和渲染,然后将其对其 layerTree 的更改提交给渲染服务器。它会在运行循环结束时自动执行此操作,或者您可以强制将未完成的事务发送到正在调用的渲染服务器CATransaction.flush()
。
然而,阻塞主线程通常是不好的(不仅仅是因为它阻塞了 UI 更新)。所以如果可以的话,你应该避免它。
可能的解决方案
这是您感兴趣的部分。
1:尽可能在后台队列上做尽可能多的事情并提高性能。
说真的,iPhone 7 是我家第三强大的计算机(不是手机),仅次于我的游戏 PC 和 Macbook Pro。它比我家中的所有其他计算机都快。渲染您的应用程序 UI 不应需要 3 秒的暂停时间。
2:刷新挂起的 CATransactions编辑:正如rob mayoff
所指出的,您可以通过调用强制 CoreAnimation 将挂起的更改发送到渲染服务器CATransaction.flush()
addItems1()
CATransaction.flush()
addItems2()
CATransaction.flush()
addItems3()
这实际上不会在此处呈现更改,而是将待处理的 UI 更新发送到窗口服务器,确保它们包含在下一个屏幕更新中。
这将起作用,但在 Apples 文档中带有这些警告。
但是,您应该尽量避免显式调用 flush。通过允许在运行循环期间执行刷新... ...并且从一个事务到另一个事务的事务和动画将继续起作用。
然而,CATransaction
头文件包含这个引用,这似乎暗示,即使他们不喜欢它,这是官方支持的用法。
在某些情况下(即没有运行循环,或者运行循环被阻塞),可能需要使用显式事务来获得及时的渲染树更新。
Apple 的文档- “更好的 +[CATransaction flush] 文档”。
3:dispatch_after()
只需将代码延迟到下一个runloop。dispatch_async(main_queue)
不会工作,但你可以dispatch_after()
毫不拖延地使用。
addItems1()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.0) {
addItems2()
DispatchQueue.main.asyncAfter(deadline: .now() + 0.0) {
addItems3()
}
}
您在回答中提到这不再适合您。但是,它在我包含在此答案中的测试 Swift Playground 和示例 iOS 应用程序中运行良好。
4:使用CADisplayLink
CADisplayLink 每帧调用一次,并允许您确保每帧只运行一个操作,保证屏幕将能够在操作之间刷新。
DisplayQueue.sharedInstance.addItem {
addItems1()
}
DisplayQueue.sharedInstance.addItem {
addItems2()
}
DisplayQueue.sharedInstance.addItem {
addItems3()
}
需要这个助手类来工作(或类似的)。
// A queue of item that you want to run one per frame (to allow the display to update in between)
class DisplayQueue {
static let sharedInstance = DisplayQueue()
init() {
displayLink = CADisplayLink(target: self, selector: #selector(displayLinkTick))
displayLink.add(to: RunLoop.current, forMode: RunLoopMode.commonModes)
}
private var displayLink:CADisplayLink!
@objc func displayLinkTick(){
if let _ = itemQueue.first {
itemQueue.remove(at: 0)() // Remove it from the queue and run it
// Stop the display link if it's not needed
displayLink.isPaused = (itemQueue.count == 0)
}
}
private var itemQueue:[()->()] = []
func addItem(block:@escaping ()->()) {
displayLink.isPaused = false // It's needed again
itemQueue.append(block) // Add the closure to the queue
}
}
5:直接调用runloop。
我不喜欢它,因为可能会出现无限循环。但是,我承认这不太可能。我也不确定这是否得到官方支持,或者苹果工程师会阅读这段代码并看起来很害怕。
// Runloop (seems to work ok, might lead to infitie recursion if used too frequently in the codebase)
addItems1()
RunLoop.current.run(mode: .defaultRunLoopMode, before: Date())
addItems2()
RunLoop.current.run(mode: .defaultRunLoopMode, before: Date())
addItems3()
这应该可以工作,除非(在响应运行循环事件时)您执行其他操作来阻止运行循环调用完成,因为 CATransaction 在运行循环结束时被发送到窗口服务器。
示例代码
演示 Xcode 项目和 Xcode Playground(Xcode 8.2、Swift 3)
我应该使用哪个选项?
我喜欢解决方案DispatchQueue.main.asyncAfter(deadline: .now() + 0.0)
和CADisplayLink
最好的解决方案。但是,DispatchQueue.main.asyncAfter
不能保证它会在下一个 runloop 滴答声中运行,所以您可能不想信任它?
CATransaction.flush()
将强制您将 UI 更改推送到渲染服务器,这种用法似乎符合 Apple 对该课程的评论,但附带了一些警告。
在某些情况下(即没有运行循环,或者运行循环被阻塞),可能需要使用显式事务来获得及时的渲染树更新。
详细说明
这个答案的其余部分是关于 UIKit 内部发生的事情的背景,并解释了为什么原始答案试图使用view.setNeedsDisplay()
并且view.layoutIfNeeded()
没有做任何事情。
UIKit 布局和渲染概述
CADisplayLink 与 UIKit 和 runloop 完全无关。
不完全的。iOS 的 UI 像 3D 游戏一样采用 GPU 渲染。并尽量少做。所以很多事情,比如布局和渲染,不会在某些事情发生变化时发生,而是在需要时发生。这就是为什么我们称“setNeedsLayout”而不是布局子视图的原因。每一帧的布局可能会更改多次。但是,iOS 将尝试layoutSubviews
每帧只调用一次,而不是setNeedsLayout
可能已经调用了 10 次。
但是,CPU 上发生了很多事情(布局、-drawRect:
等等),所以它们是如何组合在一起的。
请注意,这一切都被简化了,并且跳过了很多东西,比如 CALayer 实际上是在屏幕上显示的真实视图对象,而不是 UIView 等等......
每个 UIView 都可以被认为是一个位图,一个图像/GPU 纹理。当屏幕被渲染时,GPU 将视图层次结构合成到我们看到的结果帧中。它组合视图,将先前视图顶部的子视图纹理渲染到我们在屏幕上看到的完成渲染中(类似于游戏)。
这就是让 iOS 拥有如此流畅且易于动画的界面的原因。要在屏幕上为视图设置动画,它不必重新渲染任何内容。在查看纹理的下一帧中,只是在屏幕上与之前稍有不同的位置合成。无论是它还是它最重要的视图都不需要重新呈现它们的内容。
过去,一个常见的性能技巧是通过将表格视图单元完全呈现在drawRect:
. 这个技巧是为了让早期 iOS 设备上的 GPU 堆肥步骤更快。然而,GPU 在现代 iOS 设备上的速度如此之快,现在这不再是太多担心了。
LayoutSubviews 和 DrawRect
-setNeedsLayout
使视图当前布局无效并将其标记为需要布局。
-layoutIfNeeded
如果视图没有有效的布局,将重新布局视图
-setNeedsDisplay
将视图标记为需要重绘。我们之前说过,每个视图都被渲染成视图的纹理/图像,可以由 GPU 移动和操作,而无需重绘。这将触发它重绘。绘图是通过调用-drawRect:
CPU 完成的,因此比依赖 GPU 慢,它可以完成大多数帧。
需要注意的重要一点是这些方法没有做什么。布局方法不做任何视觉上的事情。尽管如果将视图contentMode
设置为redraw
,更改视图框架可能会使视图渲染(触发器-setNeedsDisplay
)无效。
您可以整天尝试以下内容。它似乎不起作用:
view.addItemsA()
view.setNeedsDisplay()
view.layoutIfNeeded()
view.addItemsB()
view.setNeedsDisplay()
view.layoutIfNeeded()
view.addItemsC()
view.setNeedsDisplay()
view.layoutIfNeeded()
从我们了解到的情况来看,答案应该很明显,为什么现在这不起作用。
view.layoutIfNeeded()
除了重新计算其子视图的框架之外什么都不做。
view.setNeedsDisplay()
只需在 UIKit 扫描视图层次结构更新视图纹理以发送到 GPU 时将视图标记为需要重绘。但是,不会影响您尝试添加的子视图。
在您的示例view.addItemsA()
中添加了 100 个子视图。这些是 GPU 上独立的不相关层/纹理,直到 GPU 将它们合成到下一个帧缓冲区中。唯一的例外是 CALayershouldRasterize
设置为 true。在这种情况下,它为视图创建一个单独的纹理,它的子视图和渲染(在 GPU 上考虑)视图和它的子视图到单个纹理中,有效地缓存它必须执行每一帧的合成。这具有不需要每帧组合所有子视图的性能优势。但是,如果视图或其子视图频繁更改(例如在动画期间),则会降低性能,因为它会使缓存的纹理经常失效,需要重新绘制(类似于频繁调用-setNeedsDisplay
)。
现在,任何游戏工程师都会这样做......
view.addItemsA()
RunLoop.current.run(mode: .defaultRunLoopMode, before: Date())
view.addItemsB()
RunLoop.current.run(mode: .defaultRunLoopMode, before: Date())
view.addItemsC()
现在确实,这似乎奏效了。
但为什么它会起作用?
现在不要触发重新布局或重绘-setNeedsLayout
,-setNeedsDisplay
而是将视图标记为需要它。当 UIKit 准备好渲染下一帧时,它会触发带有无效纹理或布局的视图来重绘或重新布局。一切准备就绪后,它会发送通知 GPU 合成并显示新帧。
所以 UIKit 中的主运行循环可能看起来像这样。
-(void)runloop
{
//... do touch handling and other events, etc...
self.windows.recursivelyCheckLayout() // effectively call layoutIfNeeded on everything
self.windows.recursivelyDisplay() // call -drawRect: on things that need it
GPU.recompositeFrame() // render all the layers into the frame buffer for this frame and displays it on screen
}
所以回到你的原始代码。
view.addItemsA() // Takes 1 second
view.addItemsB() // Takes 1 second
view.addItemsC() // Takes 1 second
那么为什么所有 3 种更改在 3 秒后同时显示,而不是一次显示 1 秒一次呢?
好吧,如果这段代码是由于按下按钮或类似的结果而运行的,它正在同步执行阻塞主线程(线程 UIKit 需要对 UI 进行更改)并因此阻塞第 1 行的运行循环,甚至加工部分。实际上,您使 runloop 方法的第一行需要 3 秒才能返回。
然而,我们已经确定布局直到第 3 行才会更新,各个视图直到第 4 行才会呈现,并且直到 runloop 方法的最后一行,第 5 行才会真正出现在屏幕上。
手动抽取 runloop 的原因是因为您基本上是在插入对该runloop()
方法的调用。由于从 runloop 函数中被调用,您的方法正在运行
-runloop()
- events, touch handling, etc...
- addLotsOfViewsPressed():
-addItems1() // blocks for 1 second
-runloop()
| - events and touch handling
| - layout invalid views
| - redraw invalid views
| - tell GPU to composite and display a new frame
-addItem2() // blocks for 1 second
-runloop()
| - events // hopefully nothing massive like addLotsOfViewsPressed()
| - layout
| - drawing
| - GPU render new frame
-addItems3() // blocks for 1 second
- relayout invalid views
- redraw invalid views
- GPU render new frame
这将起作用,只要它不经常使用,因为这是使用递归。如果经常使用它,每次调用都-runloop
可能触发另一个导致失控的递归。
结束
在这一点之下只是澄清。
关于这里发生的事情的额外信息
CADisplayLink 和 NSRunLoop
如果我没记错的话,KH 似乎从根本上说您相信“运行循环”(即:这个:RunLoop.current)是 CADisplayLink。
runloop 和 CADisplayLink 不是一回事。但是 CADisplayLink 被附加到一个运行循环才能工作。
当我说 NSRunLoop 每次滴答调用 CADisplayLink 时,我稍早(在聊天中)稍微说错了,但事实并非如此。据我了解,NSRunLoop 基本上是一个 while(1) 循环,其工作是保持线程处于活动状态、处理事件等......
运行循环非常像它的名字听起来。这是您的线程进入并用于运行事件处理程序以响应传入事件的循环。您的代码提供了用于实现运行循环的实际循环部分的控制语句——换句话说,您的代码提供了驱动运行循环的while
orfor
循环。在循环中,您使用运行循环对象来“运行”接收事件并调用已安装处理程序的事件处理代码。
运行循环剖析 - 线程编程指南 - developer.apple.com
CADisplayLink
用途NSRunLoop
和需要添加到一个但不同。引用 CADisplayLink 头文件:
“除非暂停,否则它将触发每个 vsync 直到被移除。”<br> 来自:func add(to runloop: RunLoop, forMode mode: RunLoopMode)
并来自preferredFramesPerSecond
属性文档。
默认值为零,这意味着显示链接将以显示硬件的本机节奏触发。
...
例如,如果屏幕的最大刷新率为每秒 60 帧,这也是显示链接设置为实际帧率的最高帧率。
因此,如果您想做任何定时刷新屏幕CADisplayLink
(使用默认设置)的事情,您就可以使用它。
介绍渲染服务器
如果你碰巧阻塞了一个线程,那与 UIKit 的工作方式无关。
不完全的。我们只需要从主线程触摸 UIView 的原因是 UIKit 不是线程安全的,它在主线程上运行。如果你阻塞了主线程,你就阻塞了 UIKit 运行的线程。
UIKit 是否“像你说的那样工作”{...“发送消息以停止视频帧。完成我们所有的工作!发送另一条消息以重新开始视频!”}
这不是我要说的。
或者它是否“像我说的那样”工作{......即,像正常编程一样“尽你所能直到帧即将结束 - 哦不,它正在结束! - 等到下一帧!做更多......” }
这不是 UIKit 的工作方式,如果不从根本上改变它的架构,我看不出它是怎么做到的。监视帧结束是什么意思?
正如我的答案的“UIKit 布局和渲染概述”部分中所讨论的那样,UIKit 试图预先不做任何工作。-setNeedsLayout
并且-setNeedsDisplay
可以根据需要在每帧调用多次。它们只会使布局和视图渲染无效,如果该帧已经无效,那么第二次调用什么也不做。这意味着如果 10 次更改都使视图的布局无效,UIKit 仍然只需要支付重新计算布局一次的成本(除非您-layoutIfNeeded
在两次-setNeedsLayout
调用之间使用)。
也是如此-setNeedsDisplay
。尽管如前所述,这些都与屏幕上显示的内容无关。layoutIfNeeded
更新视图框架并displayIfNeeded
更新视图渲染纹理,但这与屏幕上显示的内容无关。想象一下每个 UIView 都有一个 UIImage 变量来表示它的后备存储(它实际上在 CALayer 或下面,并且不是 UIImage。但这是一个插图)。重绘该视图只会更新 UIImage。但是 UIImage 仍然只是数据,而不是屏幕上的图形,直到它被某些东西绘制到屏幕上。
那么 UIView 是如何绘制在屏幕上的呢?
之前我写了伪代码 UIKit 的主渲染 runloop。到目前为止,在我的回答中,我一直忽略 UIKit 的重要部分,并非所有这些都在您的进程中运行。与显示事物相关的大量 UIKit 内容实际上发生在渲染服务器进程中,而不是您的应用程序进程中。渲染服务器/窗口服务器在 iOS 6 之前是 SpringBoard(主屏幕 UI)(从那时起,BackBoard 和 FrontBoard 吸收了很多 SpringBoard 更核心的 OS 相关功能,使其更专注于作为主要的操作系统 UI。屏幕/锁定屏幕/通知中心/控制中心/应用程序切换器/等...)。
UIKit 的主渲染运行循环的伪代码可能更接近于此。再一次,记住 UIKit 的架构被设计成尽可能少地做这些工作,所以它每帧只会做一次这些事情(不像网络调用或其他任何主运行循环也可能管理的东西)。
-(void)runloop
{
//... do touch handling and other events, etc...
UIWindow.allWindows.layoutIfNeeded() // effectively call layoutIfNeeded on everything
UIWindow.allWindows.recursivelyDisplay() // call -drawRect: on things that need to be rerendered
// Sends all the changes to the render server process to actually make these changes appear on screen
// CATransaction.flush() which means:
CoreAnimation.commit_layers_animations_to_WindowServer()
}
这是有道理的,单个 iOS 应用程序冻结不应该能够冻结整个设备。事实上,我们可以在 iPad 上同时运行 2 个应用程序来演示这一点。当我们使一个冻结时,另一个不受影响。

这是我创建的 2 个空应用程序模板,并将相同的代码粘贴到两者中。两者都应该在屏幕中间的标签中显示当前时间。当我按下冻结它调用sleep(1)
并冻结应用程序。一切都停止了。但是iOS作为一个整体很好。其他app、控制中心、通知中心等等……都不受它的影响。
UIKit 是否“像你说的那样工作”{...“发送消息以停止视频帧。完成我们所有的工作!发送另一条消息以重新开始视频!”}
在应用程序中没有 UIKitstop video frames
命令,因为您的应用程序根本无法控制屏幕。屏幕将使用窗口服务器提供的任何帧以 60FPS 的速度更新。窗口服务器将使用您的应用程序提供给它的最后已知位置、纹理和图层树,以 60FPS 合成一个新的显示帧。
当您冻结应用程序中的主线程时CoreAnimation.commitLayersAnimationsToWindowServer()
,最后运行的行(在您昂贵的add lots of views
代码之后)被阻止并且不会运行。因此,即使有更改,窗口服务器也尚未发送它们,因此只是继续使用它为您的应用程序发送的最后一个状态。
动画是 UIKit 的另一部分,在窗口服务器中用尽了进程。如果sleep(1)
在该示例应用程序之前,我们首先启动 UIView 动画,我们将看到它启动,然后标签将冻结并停止更新(因为sleep()
已运行)。但是,即使应用程序主线程被冻结,动画也会继续进行。
func freezePressed() {
var newFrame = animationView.frame
newFrame.origin.y = 600
UIView.animate(withDuration: 3, animations: { [weak self] in
self?.animationView.frame = newFrame
})
// Wait for the animation to have a chance to start, then try to freeze it
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
NSLog("Before freeze");
sleep(2) // block the main thread for 2 seconds
NSLog("After freeze");
}
}
这是结果:

事实上,我们可以做得更好。
如果我们将freezePressed()
方法更改为此。
func freezePressed() {
var newFrame = animationView.frame
newFrame.origin.y = 600
UIView.animate(withDuration: 4, animations: { [weak self] in
self?.animationView.frame = newFrame
})
// Wait for the animation to have a chance to start, then try to freeze it
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in
// Do a lot of UI changes, these should completely change the view, cancel its animation and move it somewhere else
self?.animationView.backgroundColor = .red
self?.animationView.layer.removeAllAnimations()
newFrame.origin.y = 0
newFrame.origin.x = 200
self?.animationView.frame = newFrame
sleep(2) // block the main thread for 2 seconds, this will prevent any of the above changes from actually taking place
}
}
现在没有sleep(2)
调用,动画将运行 0.2 秒,然后它将被取消,并且视图将移动到屏幕的不同部分以不同的颜色。但是,睡眠调用会阻塞主线程 2 秒,这意味着这些更改都不会发送到窗口服务器,直到动画的大部分时间。

只是为了确认这里是sleep()
注释掉的行的结果。

这应该有望解释发生了什么。这些更改就像您在问题中添加的 UIView。它们排队等待包含在下一次更新中,但是因为您通过一次发送这么多来阻塞主线程,所以您正在停止发送消息,这将使它们包含在下一帧中。下一帧没有被阻止,iOS 将生成一个新帧,显示它从 SpringBoard 和其他 iOS 应用程序接收到的所有更新。但是因为你的应用程序仍然阻塞它的主线程,iOS 没有从你的应用程序收到任何更新,所以不会显示任何变化(除非它有变化,比如动画,已经在窗口服务器上排队)。
所以总结一下
- UIKit 试图做的尽可能少,以便批量更改布局和渲染一次。
- UIKit 在主线程上运行,阻塞主线程会阻止 UIKit 在该操作完成之前执行任何操作。
- 进程中的 UIKit 无法触摸显示屏,它每帧都会向窗口服务器发送图层和更新
- 如果您阻塞主线程,则更改永远不会发送到窗口服务器,因此不会显示