如何简单地等待 iOS 中的任何布局?
How to, simply, wait for any layout in iOS?
开始之前请注意,这与后台处理无关。没有涉及背景的“计算”。
仅 UIKit.
view.addItemsA()
view.addItemsB()
view.addItemsC()
假设 6s iPhone
UIKit 构建每一个都需要一秒钟。
这将发生:
它们同时出现。重复一下,屏幕只是挂起 3 秒,而 UIKit 会做大量的工作。然后一下子全部出现。
但是假设我希望发生这种情况:
它们逐渐出现。屏幕只是挂起 1 秒钟,而 UIKit 会构建一个。它出现。它在构建下一个时再次挂起。它出现。等等。
(注意“一秒”只是一个简单的例子,为了清楚起见。请参阅本 post 的末尾以获得更完整的示例。)
iOS你是怎么做到的?
您可以尝试以下方法。 好像不行。
view.addItemsA()
view.setNeedsDisplay()
view.layoutIfNeeded()
view.addItemsB()
你可以试试这个:
view.addItemsA()
view.setNeedsDisplay()
view.layoutIfNeeded()_b()
delay(0.1) { self._b() }
}
func _b() {
view.addItemsB()
view.setNeedsDisplay()
view.layoutIfNeeded()
delay(0.1) { self._c() }...
请注意,如果值太小 - 这种方法简单明了,什么都不做。 UIKit 将继续工作。 (它还能做什么?)。如果值太大,没有意义。
注意目前(iOS10),如果我没记错的话:如果你用零延迟的技巧来尝试这个技巧,它充其量是不稳定的。 (如您所料。)
触发 运行 循环...
view.addItemsA()
view.setNeedsDisplay()
view.layoutIfNeeded()
RunLoop.current.run(mode: .defaultRunLoopMode, before: Date())
view.addItemsB()
view.setNeedsDisplay()
view.layoutIfNeeded()
合理。 但我们最近的实际测试表明,这在很多情况下似乎都行不通。
(即,Apple 的 UIKit 现在已经足够复杂,可以超越那个“技巧”来抹黑 UIKit 的工作。)
想法:在 UIKit 中,是否有一种方法可以在基本上绘制了您堆叠的所有视图时获得回调?还有其他解决方案吗?
一个解决方案似乎是 .. 将子视图 放入控制器 ,这样你就会得到一个“didAppear”回调,并跟踪那些. 这似乎很幼稚,但也许这是唯一的模式?无论如何它真的有用吗? (只有一个问题:我没有看到 didAppear 确保所有子视图都已绘制的任何保证。)
万一还是不清楚...
日常用例示例:
• 假设可能有 七个部分。
• 假设每一个通常需要 0.01 到 0.20 来构建 UIKit(取决于您显示的信息)。
• 如果您只是 “让整个过程一气呵成” 它通常是可以接受的或可以接受的(总时间,比如说 0.05 到 0.15)...但是。 ..
• 当“新屏幕出现”时,用户通常会出现乏味的停顿。 (.1 到 .5 或更差)。
• 而如果您按照我的要求进行操作,它将始终平滑地显示在屏幕上,一次一个块,每个块的时间尽可能短。
It is kind of a solution. But it's not engineering.
事实上,是的。通过添加延迟,您正在完全您所说的您想要做的事情:您允许运行循环完成并执行布局,并尽快重新进入主线程就这样完成了。事实上,那是 delay
的主要用途之一。 (您甚至可以使用零的 delay
。)
TLDR
强制挂起 UI 使用 CATransaction.flush()
更改到渲染服务器或使用 CADisplayLink
将工作拆分到多个帧(下面的示例代码)。
总结
Is there perhaps a way, in UIKit, to get a callback when it has drawn-up all the views you've stacked up?
没有
iOS 就像游戏渲染变化(无论你做了多少)每帧最多一次。在屏幕上呈现您的更改后,保证一段代码 运行s 的唯一方法是等待下一帧。
Is there another solution?
是的,iOS 可能每帧只渲染一次更改,但您的应用不是进行渲染的对象。 window 服务器进程是。
您的应用程序进行布局和渲染,然后将其对 layerTree 的更改提交给渲染服务器。它会在 运行 循环结束时自动执行此操作,或者您可以强制将未完成的事务发送到调用 CATransaction.flush()
.
的呈现服务器
然而,阻塞主线程通常是不好的(不仅仅是因为它阻塞了 UI 更新)。所以如果可以的话,你应该避免它。
可能的解决方案
这是您感兴趣的部分。
1:尽量在后台队列做,提高性能。
说真的,iPhone 7 是我家第三大 计算机 (不是 phone),仅次于我的游戏 PC 和 Macbook Pro。它比我家里的所有其他电脑都快。渲染您的应用程序不应该暂停 3 秒 UI。
2:刷新挂起的 CATransactions
编辑:正如 所指出的,您可以通过调用 CATransaction.flush()
强制 CoreAnimation 将未决更改发送到渲染服务器
addItems1()
CATransaction.flush()
addItems2()
CATransaction.flush()
addItems3()
这实际上不会在那里呈现更改,而是将待处理的 UI 更新发送到 window 服务器,确保它们包含在下一个屏幕更新中。
这会起作用,但在 Apple 文档中会附带这些警告。
However, you should attempt to avoid calling flush explicitly. By allowing flush to execute during the runloop...
...and transactions and animations that work from transaction to transaction will continue to function.
然而CATransaction
头文件包含了这句话,这似乎暗示,即使他们不喜欢它,这是官方支持的用法。
In some circumstances (i.e. no run-loop, or the run-loop is blocked) it may be necessary to use explicit transactions to get timely render tree updates.
Apple's Documentation - "Better documentation for +[CATransaction flush]".
3: dispatch_after()
只需将代码延迟到下一个 运行 循环。 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 每帧调用一次,并允许您确保每帧只有一个操作 运行s,保证屏幕能够在操作之间刷新。
DisplayQueue.sharedInstance.addItem {
addItems1()
}
DisplayQueue.sharedInstance.addItem {
addItems2()
}
DisplayQueue.sharedInstance.addItem {
addItems3()
}
需要这个助手 class 才能工作(或类似)。
// 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:直接调用运行循环
我不喜欢它,因为可能会出现无限循环。但是,我承认这不太可能。我也不确定这是否得到官方支持,或者 Apple 工程师会阅读这段代码并看起来很恐怖。
// 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 被发送到 window 服务器在 运行 循环结束时。
示例代码
Demonstration Xcode Project & Xcode Playground (Xcode 8.2, Swift 3)
我应该使用哪个选项?
我最喜欢解决方案 DispatchQueue.main.asyncAfter(deadline: .now() + 0.0)
和 CADisplayLink
。但是,DispatchQueue.main.asyncAfter
不保证它会在下一个 运行 循环中 运行,所以你可能不想相信它?
CATransaction.flush()
将强制您将 UI 更改推送到渲染服务器,这种用法似乎符合 Apple 对 class 的评论,但附带一些警告。
In some circumstances (i.e. no run-loop, or the run-loop is blocked) it may be necessary to use explicit transactions to get timely render tree updates.
详细说明
这个答案的其余部分是 UIKit 内部发生的事情的背景,并解释了为什么原始答案尝试使用 view.setNeedsDisplay()
而 view.layoutIfNeeded()
没有做任何事情。
UI套件布局和渲染概述
CADisplayLink is totally unrelated to UIKit and the runloop.
不完全是。 iOS 的 UI 是像 3D 游戏一样的 GPU 渲染。并尽量少做。所以很多事情,比如布局和渲染不会在某些东西发生变化时发生,而是在需要时发生。这就是为什么我们称“setNeedsLayout”而不是布局子视图。每一帧布局可能会改变多次。但是,iOS 将尝试每帧仅调用一次 layoutSubviews
,而不是可能已调用 10 次 setNeedsLayout
。
但是,CPU 上发生了很多事情(布局、-drawRect:
等...),所以它们如何组合在一起。
请注意,这一切都已简化并跳过了很多东西,例如 CALayer 实际上是显示在屏幕上的真实视图对象,而不是 UIView 等...
每个 UIView 都可以被认为是一个位图,一个 image/GPU 纹理。当屏幕为 rndered GPU 将视图层次结构合成到我们看到的结果帧中。它组合视图,将子视图纹理渲染到我们在屏幕上看到的最终渲染中(类似于游戏)。
这就是 iOS 拥有如此流畅且易于动画化的界面的原因。要在屏幕上动画视图,它不需要重新渲染任何东西。在下一帧中,纹理只是合成在屏幕上与之前略有不同的位置。它和它位于顶部的视图都不需要重新呈现它们的内容。
过去,一个常见的性能技巧是通过完全在 drawRect:
中呈现 table 个视图单元来减少视图层次结构中的视图数量。这个技巧是为了让早期 iOS 设备上的 GPU 堆肥步骤更快。然而,现代 iOS 设备上的 GPU 速度如此之快,现在不必再担心了。
LayoutSubviews 和 DrawRect
-setNeedsLayout
使视图当前布局无效并将其标记为需要布局。
-layoutIfNeeded
如果没有有效布局,将重新布局视图
-setNeedsDisplay
会将视图标记为需要重绘。我们之前说过,每个视图都被渲染成一个 texture/image 视图,可以由 GPU 移动和操作,而无需重新绘制。这将触发它重绘。绘图是通过在 CPU 上调用 -drawRect:
完成的,因此比依赖 GPU 慢,它可以完成大多数帧。
需要注意的重要一点是这些方法不能做什么。布局方法不做任何视觉上的事情。尽管如果视图 contentMode
设置为 redraw
,更改视图框架可能会使视图渲染无效(触发 -setNeedsDisplay
)。
You can try the following all day. It does not seem to work:
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 上是独立的无关 layers/textures。唯一的例外是 CALayer 将 shouldRasterize
设置为 true。在这种情况下,它为视图创建一个单独的纹理,它的子视图和渲染(在 GPU 上思考)视图和它的子视图到一个纹理中,有效地缓存它必须在每个帧中进行的合成。这具有不需要在每一帧中组合所有子视图的性能优势。但是,如果视图或其子视图频繁更改(如在动画期间),这将是性能损失,因为它会使缓存的纹理频繁失效,需要重新绘制它(类似于频繁调用 -setNeedsDisplay
)。
Now, any game engineer would just do this ...
view.addItemsA()
RunLoop.current.run(mode: .defaultRunLoopMode, before: Date())
view.addItemsB()
RunLoop.current.run(mode: .defaultRunLoopMode, before: Date())
view.addItemsC()
Now indeed, that seems to work.
但为什么它有效?
现在 -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 秒?
好吧,如果这段代码是 运行ning 作为按钮按下或类似的结果,它正在同步执行阻塞主线程(线程 UIKit 需要 UI 更改),因此阻塞了第 1 行的 运行 循环,即偶数处理部分。实际上,您正在使 运行loop 方法的第一行花费 3 秒到 return。
但是,我们已经确定布局要到第 3 行才会更新,各个视图要到第 4 行才会呈现,并且直到 [=376= 的最后一行才会实际显示在屏幕上。 ]循环方法,第 5 行。
手动抽取 运行 循环的原因是因为您基本上是在插入对 runloop()
方法的调用。由于从 运行 循环函数
中调用,您的方法是 运行ning
-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
都可能触发另一个调用,从而导致 运行away 递归。
结束
以下只是澄清一下。
关于这里发生的事情的额外信息
CADisplayLink 和 NSRunLoop
If I'm not mistaken KH it appears that fundamentally you believe "the run loop" (ie: this one: RunLoop.current) is CADisplayLink.
运行循环和 CADisplayLink 不是同一件事情。但是 CADisplayLink 附加到 运行 循环才能工作。
当我说 NSRunLoop 每次 tick 都会调用 CADisplayLink 时,我早些时候(在聊天中)略微说错了,但事实并非如此。根据我的理解,NSRunLoop 基本上是一个 while(1) 循环,它的工作是保持线程活动、处理事件等......为了避免失误,我将尝试在接下来的部分中广泛引用 Apple 自己的文档。
A run loop is very much like its name sounds. It is a loop your thread enters and uses to run event handlers in response to incoming events. Your code provides the control statements used to implement the actual loop portion of the run loop—in other words, your code provides the while
or for
loop that drives the run loop. Within your loop, you use a run loop object to "run” the event-processing code that receives events and calls the installed handlers.
Anatomy of a Run Loop - Threading Programming Guide - developer.apple.com
CADisplayLink
用NSRunLoop
需要加一但是不一样。引用CADisplayLink头文件:
“Unless paused, it will fire every vsync until removed.”
From: func add(to runloop: RunLoop, forMode mode: RunLoopMode)
来自 preferredFramesPerSecond
属性文档。
Default value is zero, which means the display link will fire at the native cadence of the display hardware.
...
For example, if the maximum refresh rate of the screen is 60 frames per second, that is also the highest frame rate the display link sets as the actual frame rate.
所以如果你想做任何定时屏幕刷新CADisplayLink
(默认设置)就是你想要使用的。
渲染服务器介绍
If you happen to block a thread, that has nothing to do with how UIKit works.
不完全是。我们只需要从主线程触摸 UIView 的原因是因为 UIKit 不是线程安全的,它在主线程上 运行s。如果你阻塞了主线程,你就阻塞了线程 UIKit 运行s on.
Whether UIKit works "like you say" {... "send a message to stop video frames. do all our work! send another message to start video again!"}
我不是这个意思
Or whether it works "like I say" {... ie, like normal programming "do as much as you can until the frames about to end - oh no it's ending! - wait until the next frame! do more..."}
这不是 UIKit 的工作方式,我看不出它在不从根本上改变其架构的情况下如何工作。监视帧结束是什么意思?
正如我在回答的“UIKit 布局和渲染概述”部分所讨论的那样 UIKit 尝试不预先做任何工作。 -setNeedsLayout
和 -setNeedsDisplay
每帧可以调用任意多次。它们只会使布局和视图渲染无效,如果该帧已经无效,则第二次调用什么都不做。这意味着如果 10 次更改都使视图的布局无效 UIKit 仍然只需要支付重新计算布局一次的成本(除非你在 -setNeedsLayout
调用之间使用了 -layoutIfNeeded
)。
-setNeedsDisplay
也是如此。尽管如前所述,这些都与屏幕上显示的内容无关。 layoutIfNeeded
更新视图框架,displayIfNeeded
更新视图渲染纹理,但这与屏幕上显示的内容无关。想象一下,每个 UIView 都有一个 UIImage 变量来表示它的后备存储(它实际上在 CALayer 或下面,而不是 UIImage。但这是一个例子)。重绘该视图只会更新 UIImage。但是 UI图像仍然只是数据,而不是屏幕上的图形,直到它被某种东西绘制到屏幕上。
那么 UIView 如何绘制到屏幕上?
之前我写了伪代码UIKit的主渲染运行循环。到目前为止,在我的回答中,我一直忽略了 UIKit 的重要部分,而不是你进程中的所有 运行s。数量惊人的 UIKit 与显示内容相关的内容实际上发生在渲染服务器进程中,而不是您的应用程序进程中。渲染server/window服务器是SpringBoard(主屏幕UI)直到iOS6(从那以后BackBoard和FrontBoard吸收了很多SpringBoards更核心的OS相关特性,让它更多地专注于成为主要操作系统 UI。主页 screen/lock screen/notification center/control center/app switcher/etc...).
UIKit 的主要渲染 运行loop 的伪代码可能更接近于此。再一次,请记住 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 个空应用程序模板,并将相同的代码粘贴到这两个模板中。两者都应该在屏幕中间的标签中显示当前时间。当我按下 freeze 时,它会调用 sleep(1)
并冻结应用程序。一切都停止了。但是iOS整体来说还是可以的。其他app、控制中心、通知中心等...均不受其影响
Whether UIKit works "like you say" {... "send a message to stop video frames. do all our work! send another message to start video again!"}
应用程序中没有 UIKit stop video frames
命令,因为您的应用程序根本无法控制屏幕。屏幕将使用 window 服务器提供的任何帧以 60FPS 的速度更新。 window 服务器将使用您的应用为其提供的最后已知位置、纹理和图层树以 60FPS 的速度为显示器合成一个新帧。
当您冻结应用程序中的主线程时,最后 运行 行(在您昂贵的 add lots of views
代码之后)被阻塞并且不会 运行。因此,即使有变化,window 服务器还没有发送它们,所以只是继续使用它为您的应用发送的最后一个状态。
动画是 UIKit 的另一部分,它 运行 在 window 服务器中处于进程外。如果在该示例应用程序的 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 秒,这意味着 none 这些更改会发送到 window 服务器,直到动画完成大部分时间。
这里只是为了确认 sleep()
行被注释掉的结果。
这应该可以解释发生了什么。这些更改就像您在问题中添加的 UIView。它们排队等待包含在下一次更新中,但是因为您通过一次发送这么多来阻塞主线程,所以您正在停止正在发送的消息,这将使它们包含在下一帧中。下一帧未被阻止,iOS 将生成一个新帧,显示它从 SpringBoard 和其他 iOS 应用程序收到的所有更新。但是因为您的应用仍在阻塞,它的主线程 iOS 尚未从您的应用收到任何更新,因此不会显示任何更改(除非它有更改,例如动画,已经在 window 服务器)。
所以总结一下
- UIKit 尝试做尽可能少的事情,以便批量更改布局和渲染。
- UIKit 运行s 在主线程上,阻塞主线程会阻止 UIKit 在该操作完成之前做任何事情。
- UI处理中的套件无法触摸显示器,它每帧向 window 服务器发送图层和更新
- 如果您阻塞主线程,则更改永远不会发送到 window 服务器,因此不会显示
使用NSNotification
达到所需的效果。
首先 - 将观察者注册到主视图并创建观察者处理程序。
然后,在单独的线程(例如后台)中初始化所有这些 A、B、C... 对象,例如 self performSelectorInBackground
然后 - post 来自子视图的通知和最后 - performSelectorOnMainThread
以所需的延迟添加子视图。
为了回答评论中的问题,假设您有一个显示在屏幕上的 UIViewController。这个对象 - 不是讨论的焦点,你可以决定把代码放在哪里,控制视图的外观。该代码用于 UIViewController 对象(因此,它是自身的)。 View - 一些 UIView 对象,被视为父视图。 ViewN - 子视图之一。以后可以缩放。
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(handleNotification:)
name:@"ViewNotification"
object:nil];
这注册了一个观察者 - 需要在线程之间进行通信。
ViewN * V1 = [[ViewN alloc] init];
这里 - 可以分配子视图 - 尚未显示。
- (void) handleNotification: (id) note {
ViewN * Vx = (ViewN*) [(NSNotification *) note.userInfo objectForKey: @"ViewArrived"];
[self.View performSelectorOnMainThread: @selector(addSubView) withObject: Vx waitUntilDone: FALSE];
}
此处理程序允许接收消息并将UIView
对象放置到父视图。看起来很奇怪,但重点是——你需要在主线程上执行 addSubview 方法才能生效。 performSelectorOnMainThread
允许在不阻塞应用程序执行的情况下开始在主线程上添加子视图。
现在 - 我们创建一个将子视图放置到屏幕的方法。
-(void) sendToScreen: (id) obj {
NSDictionary * mess = [NSDictionary dictionaryWithObjectsAndKeys: obj, @"ViewArrived",nil];
[[NSNotificationCenter defaultCenter] postNotificationName: @"ViewNotification" object: nil userInfo: mess];
}
此方法将post 来自任何线程的通知,发送一个名为 ViewArrived 的对象作为 NSDictionary 项。
最后 - 必须延迟 3 秒添加的视图:
-(void) initViews {
ViewN * V1 = [[ViewN alloc] init];
ViewN * V2 = [[ViewN alloc] init];
ViewN * V3 = [[ViewN alloc] init];
[self performSelector: @selector(sendToScreen:) withObject: V1 afterDelay: 3.0];
[self performSelector: @selector(sendToScreen:) withObject: V2 afterDelay: 6.0];
[self performSelector: @selector(sendToScreen:) withObject: V3 afterDelay: 9.0];
}
这不是唯一的解决方案。也可以通过统计NSArray
subviews
属性.
来控制父视图的子视图
在任何情况下,您都可以在需要时使用 运行 initViews
方法,甚至在后台线程中,它允许控制子视图的外观,performSelector
机制允许避免执行线程阻塞。
window 服务器对屏幕上显示的内容具有最终控制权。 iOS 仅在提交当前 CATransaction
时向 window 服务器发送更新。为了在需要时实现这一点,iOS 在主线程的 运行 循环中为 .beforeWaiting
activity 注册了一个 CFRunLoopObserver
。在处理一个事件后(可能通过调用您的代码),运行 循环在等待下一个事件到达之前调用观察者。观察者提交当前事务(如果有的话)。提交事务包括 运行 布局通道、显示通道(在其中调用您的 drawRect
方法),以及将更新后的布局和内容发送到 window 服务器。
如果需要,调用 layoutIfNeeded
执行布局,但不调用显示通道或向 window 服务器发送任何内容。如果您希望 iOS 将更新发送到 window 服务器,您必须提交当前事务。
一种方法是调用 CATransaction.flush()
。使用 CATransaction.flush()
的合理情况是当您想要在屏幕上放置一个新的 CALayer
并且您希望它立即有一个动画。新的 CALayer
在事务提交之前不会发送到 window 服务器,并且在它显示在屏幕上之前不能向其添加动画。因此,您将图层添加到图层层次结构,调用 CATransaction.flush()
,然后将动画添加到图层。
您可以使用CATransaction.flush
来获得您想要的效果。 我不推荐这个,但这是代码:
@IBOutlet var stackView: UIStackView!
@IBAction func buttonWasTapped(_ sender: Any) {
stackView.subviews.forEach { [=10=].removeFromSuperview() }
for _ in 0 ..< 3 {
addSlowSubviewToStack()
CATransaction.flush()
}
}
func addSlowSubviewToStack() {
let view = UIView()
// 300 milliseconds of “work”:
let endTime = CFAbsoluteTimeGetCurrent() + 0.3
while CFAbsoluteTimeGetCurrent() < endTime { }
view.translatesAutoresizingMaskIntoConstraints = false
view.heightAnchor.constraint(equalToConstant: 44).isActive = true
view.backgroundColor = .purple
view.layer.borderColor = UIColor.yellow.cgColor
view.layer.borderWidth = 4
stackView.addArrangedSubview(view)
}
结果如下:
上述解决方案的问题是它通过调用Thread.sleep
阻塞了主线程。如果你的主线程不响应事件,用户不仅会感到沮丧(因为你的应用程序没有响应她的触摸),而且最终 iOS 将决定应用程序挂起并杀死它。
更好的方法是简单地安排每个视图在您希望它出现时添加。你声称“这不是工程”,但你错了,你给出的理由毫无意义。 iOS 通常每 16⅔ 毫秒更新一次屏幕(除非您的应用需要比这更长的时间来处理事件)。只要你想要的延迟至少那么长,你可以在延迟后安排一个块 运行 来添加下一个视图。如果你想要小于16⅔毫秒的延迟,一般是做不到的。
所以这是添加子视图的更好的推荐方法:
@IBOutlet var betterButton: UIButton!
@IBAction func betterButtonWasTapped(_ sender: Any) {
betterButton.isEnabled = false
stackView.subviews.forEach { [=11=].removeFromSuperview() }
addViewsIfNeededWithoutBlocking()
}
private func addViewsIfNeededWithoutBlocking() {
guard stackView.arrangedSubviews.count < 3 else {
betterButton.isEnabled = true
return
}
self.addSubviewToStack()
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(300)) {
self.addViewsIfNeededWithoutBlocking()
}
}
func addSubviewToStack() {
let view = UIView()
view.translatesAutoresizingMaskIntoConstraints = false
view.heightAnchor.constraint(equalToConstant: 44).isActive = true
view.backgroundColor = .purple
view.layer.borderColor = UIColor.yellow.cgColor
view.layer.borderWidth = 4
stackView.addArrangedSubview(view)
}
这是(相同的)结果:
3 种可能在下面起作用的方法。第一个我可以让它工作如果子视图正在添加控制器以及如果它不直接在视图中 controller.The 第二个是自定义视图 :) 你似乎想知道 layoutSubviews 何时在视图上完成.由于 1000 个子视图顺序加上,这个连续的过程就是冻结显示的原因。根据您的情况,您可以在 viewDidLayoutSubviews() 完成时添加 childviewcontroller 视图和 post 通知,但我不知道这是否适合您的用例。我在添加的 viewcontroller 视图上测试了 1000 个订阅者并且它有效。在那种情况下,延迟 0 将完全按照您的意愿进行。这是一个工作示例。
import UIKit
class TrackingViewController: UIViewController {
var layoutCount = 0
override func viewDidLoad() {
super.viewDidLoad()
// Add a bunch of subviews
for _ in 0...1000{
let view = UIView(frame: self.view.bounds)
view.autoresizingMask = [.flexibleWidth,.flexibleHeight]
view.backgroundColor = UIColor.green
self.view.addSubview(view)
}
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
print("Called \(layoutCount)")
if layoutCount == 1{
//finished because first call was an emptyview
NotificationCenter.default.post(name: NSNotification.Name(rawValue: "kLayoutFinished"), object: nil)
}
layoutCount += 1
} }
然后在您要添加子视图的主视图控制器中,您可以执行此操作。
import UIKit
class ViewController: UIViewController {
var y :CGFloat = 0
var count = 0
override func viewDidLoad() {
super.viewDidLoad()
// Do any additional setup after loading the view, typically from a nib.
NotificationCenter.default.addObserver(self, selector: #selector(ViewController.finishedLayoutAddAnother), name: NSNotification.Name(rawValue: "kLayoutFinished"), object: nil)
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 4, execute: {
//add first view
self.finishedLayoutAddAnother()
})
}
deinit {
NotificationCenter.default.removeObserver(self, name: NSNotification.Name(rawValue: "kLayoutFinished"), object: nil)
}
func finishedLayoutAddAnother(){
print("We are finished with the layout of last addition and we are displaying")
addView()
}
func addView(){
// we keep adding views just to cause
print("Fired \(Date())")
if count < 100{
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 0.0, execute: {
// let test = TestSubView(frame: CGRect(x: self.view.bounds.midX - 50, y: y, width: 50, height: 20))
let trackerVC = TrackingViewController()
trackerVC.view.frame = CGRect(x: self.view.bounds.midX - 50, y: self.y, width: 50, height: 20)
trackerVC.view.backgroundColor = UIColor.red
self.view.addSubview(trackerVC.view)
trackerVC.didMove(toParentViewController: self)
self.y += 30
self.count += 1
})
}
}
}
或者还有一种更疯狂但可能更好的方法。创建您自己的视图,在某种意义上保持自己的时间并在不丢帧的情况下回调。这是未抛光的,但可以工作。
import UIKit
class CompletionView: UIView {
private var lastUpdate : TimeInterval = 0.0
private var checkTimer : Timer!
private var milliSecTimer : Timer!
var adding = false
private var action : (()->Void)?
//just for testing
private var y : CGFloat = 0
private var x : CGFloat = 0
//just for testing
var randomColors = [UIColor.purple,UIColor.gray,UIColor.green,UIColor.green]
init(frame: CGRect,targetAction:(()->Void)?) {
super.init(frame: frame)
action = targetAction
adding = true
for i in 0...999{
if y > bounds.height - bounds.height/100{
y -= bounds.height/100
}
let v = UIView(frame: CGRect(x: x, y: y, width: bounds.width/10, height: bounds.height/100))
x += bounds.width/10
if i % 9 == 0{
x = 0
y += bounds.height/100
}
v.backgroundColor = randomColors[Int(arc4random_uniform(4))]
self.addSubview(v)
}
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func milliSecCounting(){
lastUpdate += 0.001
}
func checkDate(){
//length of 1 frame
if lastUpdate >= 0.003{
checkTimer.invalidate()
checkTimer = nil
milliSecTimer.invalidate()
milliSecTimer = nil
print("notify \(lastUpdate)")
adding = false
if let _ = action{
self.action!()
}
}
}
override func layoutSubviews() {
super.layoutSubviews()
lastUpdate = 0.0
if checkTimer == nil && adding == true{
checkTimer = Timer.scheduledTimer(timeInterval: 0.01, target: self, selector: #selector(CompletionView.checkDate), userInfo: nil, repeats: true)
}
if milliSecTimer == nil && adding == true{
milliSecTimer = Timer.scheduledTimer(timeInterval: 0.001, target: self, selector: #selector(CompletionView.milliSecCounting), userInfo: nil, repeats: true)
}
}
}
import UIKit
class ViewController: UIViewController {
var y :CGFloat = 30
override func viewDidLoad() {
super.viewDidLoad()
// Wait 3 seconds to give the sim time
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 3, execute: {
[weak self] in
self?.addView()
})
}
var count = 0
func addView(){
print("starting")
if count < 20{
let completionView = CompletionView(frame: CGRect(x: 0, y: self.y, width: 100, height: 100), targetAction: {
[weak self] in
self?.count += 1
self?.addView()
print("finished")
})
self.y += 105
completionView.backgroundColor = UIColor.blue
self.view.addSubview(completionView)
}
}
}
或者最后,您可以在 viewDidAppear 中执行回调或通知,但似乎在回调中执行的任何代码都需要包含在 viewDidAppear 回调中及时执行。
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 0.0, execute: {
//code})
开始之前请注意,这与后台处理无关。没有涉及背景的“计算”。
仅 UIKit.
view.addItemsA()
view.addItemsB()
view.addItemsC()
假设 6s iPhone
UIKit 构建每一个都需要一秒钟。
这将发生:
它们同时出现。重复一下,屏幕只是挂起 3 秒,而 UIKit 会做大量的工作。然后一下子全部出现。
但是假设我希望发生这种情况:
它们逐渐出现。屏幕只是挂起 1 秒钟,而 UIKit 会构建一个。它出现。它在构建下一个时再次挂起。它出现。等等。
(注意“一秒”只是一个简单的例子,为了清楚起见。请参阅本 post 的末尾以获得更完整的示例。)
iOS你是怎么做到的?
您可以尝试以下方法。 好像不行。
view.addItemsA()
view.setNeedsDisplay()
view.layoutIfNeeded()
view.addItemsB()
你可以试试这个:
view.addItemsA()
view.setNeedsDisplay()
view.layoutIfNeeded()_b()
delay(0.1) { self._b() }
}
func _b() {
view.addItemsB()
view.setNeedsDisplay()
view.layoutIfNeeded()
delay(0.1) { self._c() }...
请注意,如果值太小 - 这种方法简单明了,什么都不做。 UIKit 将继续工作。 (它还能做什么?)。如果值太大,没有意义。
注意目前(iOS10),如果我没记错的话:如果你用零延迟的技巧来尝试这个技巧,它充其量是不稳定的。 (如您所料。)
触发 运行 循环...
view.addItemsA()
view.setNeedsDisplay()
view.layoutIfNeeded()
RunLoop.current.run(mode: .defaultRunLoopMode, before: Date())
view.addItemsB()
view.setNeedsDisplay()
view.layoutIfNeeded()
合理。 但我们最近的实际测试表明,这在很多情况下似乎都行不通。
(即,Apple 的 UIKit 现在已经足够复杂,可以超越那个“技巧”来抹黑 UIKit 的工作。)
想法:在 UIKit 中,是否有一种方法可以在基本上绘制了您堆叠的所有视图时获得回调?还有其他解决方案吗?
一个解决方案似乎是 .. 将子视图 放入控制器 ,这样你就会得到一个“didAppear”回调,并跟踪那些. 这似乎很幼稚,但也许这是唯一的模式?无论如何它真的有用吗? (只有一个问题:我没有看到 didAppear 确保所有子视图都已绘制的任何保证。)
万一还是不清楚...
日常用例示例:
• 假设可能有 七个部分。
• 假设每一个通常需要 0.01 到 0.20 来构建 UIKit(取决于您显示的信息)。
• 如果您只是 “让整个过程一气呵成” 它通常是可以接受的或可以接受的(总时间,比如说 0.05 到 0.15)...但是。 ..
• 当“新屏幕出现”时,用户通常会出现乏味的停顿。 (.1 到 .5 或更差)。
• 而如果您按照我的要求进行操作,它将始终平滑地显示在屏幕上,一次一个块,每个块的时间尽可能短。
It is kind of a solution. But it's not engineering.
事实上,是的。通过添加延迟,您正在完全您所说的您想要做的事情:您允许运行循环完成并执行布局,并尽快重新进入主线程就这样完成了。事实上,那是 delay
的主要用途之一。 (您甚至可以使用零的 delay
。)
TLDR
强制挂起 UI 使用 CATransaction.flush()
更改到渲染服务器或使用 CADisplayLink
将工作拆分到多个帧(下面的示例代码)。
总结
Is there perhaps a way, in UIKit, to get a callback when it has drawn-up all the views you've stacked up?
没有
iOS 就像游戏渲染变化(无论你做了多少)每帧最多一次。在屏幕上呈现您的更改后,保证一段代码 运行s 的唯一方法是等待下一帧。
Is there another solution?
是的,iOS 可能每帧只渲染一次更改,但您的应用不是进行渲染的对象。 window 服务器进程是。
您的应用程序进行布局和渲染,然后将其对 layerTree 的更改提交给渲染服务器。它会在 运行 循环结束时自动执行此操作,或者您可以强制将未完成的事务发送到调用 CATransaction.flush()
.
然而,阻塞主线程通常是不好的(不仅仅是因为它阻塞了 UI 更新)。所以如果可以的话,你应该避免它。
可能的解决方案
这是您感兴趣的部分。
1:尽量在后台队列做,提高性能。
说真的,iPhone 7 是我家第三大 计算机 (不是 phone),仅次于我的游戏 PC 和 Macbook Pro。它比我家里的所有其他电脑都快。渲染您的应用程序不应该暂停 3 秒 UI。
2:刷新挂起的 CATransactions
编辑:正如 CATransaction.flush()
addItems1()
CATransaction.flush()
addItems2()
CATransaction.flush()
addItems3()
这实际上不会在那里呈现更改,而是将待处理的 UI 更新发送到 window 服务器,确保它们包含在下一个屏幕更新中。
这会起作用,但在 Apple 文档中会附带这些警告。
However, you should attempt to avoid calling flush explicitly. By allowing flush to execute during the runloop... ...and transactions and animations that work from transaction to transaction will continue to function.
然而CATransaction
头文件包含了这句话,这似乎暗示,即使他们不喜欢它,这是官方支持的用法。
In some circumstances (i.e. no run-loop, or the run-loop is blocked) it may be necessary to use explicit transactions to get timely render tree updates.
Apple's Documentation - "Better documentation for +[CATransaction flush]".
3: dispatch_after()
只需将代码延迟到下一个 运行 循环。 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 每帧调用一次,并允许您确保每帧只有一个操作 运行s,保证屏幕能够在操作之间刷新。
DisplayQueue.sharedInstance.addItem {
addItems1()
}
DisplayQueue.sharedInstance.addItem {
addItems2()
}
DisplayQueue.sharedInstance.addItem {
addItems3()
}
需要这个助手 class 才能工作(或类似)。
// 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:直接调用运行循环
我不喜欢它,因为可能会出现无限循环。但是,我承认这不太可能。我也不确定这是否得到官方支持,或者 Apple 工程师会阅读这段代码并看起来很恐怖。
// 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 被发送到 window 服务器在 运行 循环结束时。
示例代码
Demonstration Xcode Project & Xcode Playground (Xcode 8.2, Swift 3)
我应该使用哪个选项?
我最喜欢解决方案 DispatchQueue.main.asyncAfter(deadline: .now() + 0.0)
和 CADisplayLink
。但是,DispatchQueue.main.asyncAfter
不保证它会在下一个 运行 循环中 运行,所以你可能不想相信它?
CATransaction.flush()
将强制您将 UI 更改推送到渲染服务器,这种用法似乎符合 Apple 对 class 的评论,但附带一些警告。
In some circumstances (i.e. no run-loop, or the run-loop is blocked) it may be necessary to use explicit transactions to get timely render tree updates.
详细说明
这个答案的其余部分是 UIKit 内部发生的事情的背景,并解释了为什么原始答案尝试使用 view.setNeedsDisplay()
而 view.layoutIfNeeded()
没有做任何事情。
UI套件布局和渲染概述
CADisplayLink is totally unrelated to UIKit and the runloop.
不完全是。 iOS 的 UI 是像 3D 游戏一样的 GPU 渲染。并尽量少做。所以很多事情,比如布局和渲染不会在某些东西发生变化时发生,而是在需要时发生。这就是为什么我们称“setNeedsLayout”而不是布局子视图。每一帧布局可能会改变多次。但是,iOS 将尝试每帧仅调用一次 layoutSubviews
,而不是可能已调用 10 次 setNeedsLayout
。
但是,CPU 上发生了很多事情(布局、-drawRect:
等...),所以它们如何组合在一起。
请注意,这一切都已简化并跳过了很多东西,例如 CALayer 实际上是显示在屏幕上的真实视图对象,而不是 UIView 等...
每个 UIView 都可以被认为是一个位图,一个 image/GPU 纹理。当屏幕为 rndered GPU 将视图层次结构合成到我们看到的结果帧中。它组合视图,将子视图纹理渲染到我们在屏幕上看到的最终渲染中(类似于游戏)。
这就是 iOS 拥有如此流畅且易于动画化的界面的原因。要在屏幕上动画视图,它不需要重新渲染任何东西。在下一帧中,纹理只是合成在屏幕上与之前略有不同的位置。它和它位于顶部的视图都不需要重新呈现它们的内容。
过去,一个常见的性能技巧是通过完全在 drawRect:
中呈现 table 个视图单元来减少视图层次结构中的视图数量。这个技巧是为了让早期 iOS 设备上的 GPU 堆肥步骤更快。然而,现代 iOS 设备上的 GPU 速度如此之快,现在不必再担心了。
LayoutSubviews 和 DrawRect
-setNeedsLayout
使视图当前布局无效并将其标记为需要布局。
-layoutIfNeeded
如果没有有效布局,将重新布局视图
-setNeedsDisplay
会将视图标记为需要重绘。我们之前说过,每个视图都被渲染成一个 texture/image 视图,可以由 GPU 移动和操作,而无需重新绘制。这将触发它重绘。绘图是通过在 CPU 上调用 -drawRect:
完成的,因此比依赖 GPU 慢,它可以完成大多数帧。
需要注意的重要一点是这些方法不能做什么。布局方法不做任何视觉上的事情。尽管如果视图 contentMode
设置为 redraw
,更改视图框架可能会使视图渲染无效(触发 -setNeedsDisplay
)。
You can try the following all day. It does not seem to work:
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 上是独立的无关 layers/textures。唯一的例外是 CALayer 将 shouldRasterize
设置为 true。在这种情况下,它为视图创建一个单独的纹理,它的子视图和渲染(在 GPU 上思考)视图和它的子视图到一个纹理中,有效地缓存它必须在每个帧中进行的合成。这具有不需要在每一帧中组合所有子视图的性能优势。但是,如果视图或其子视图频繁更改(如在动画期间),这将是性能损失,因为它会使缓存的纹理频繁失效,需要重新绘制它(类似于频繁调用 -setNeedsDisplay
)。
Now, any game engineer would just do this ...
view.addItemsA() RunLoop.current.run(mode: .defaultRunLoopMode, before: Date()) view.addItemsB() RunLoop.current.run(mode: .defaultRunLoopMode, before: Date()) view.addItemsC()
Now indeed, that seems to work.
但为什么它有效?
现在 -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 秒?
好吧,如果这段代码是 运行ning 作为按钮按下或类似的结果,它正在同步执行阻塞主线程(线程 UIKit 需要 UI 更改),因此阻塞了第 1 行的 运行 循环,即偶数处理部分。实际上,您正在使 运行loop 方法的第一行花费 3 秒到 return。
但是,我们已经确定布局要到第 3 行才会更新,各个视图要到第 4 行才会呈现,并且直到 [=376= 的最后一行才会实际显示在屏幕上。 ]循环方法,第 5 行。
手动抽取 运行 循环的原因是因为您基本上是在插入对 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
都可能触发另一个调用,从而导致 运行away 递归。
结束
以下只是澄清一下。
关于这里发生的事情的额外信息
CADisplayLink 和 NSRunLoop
If I'm not mistaken KH it appears that fundamentally you believe "the run loop" (ie: this one: RunLoop.current) is CADisplayLink.
运行循环和 CADisplayLink 不是同一件事情。但是 CADisplayLink 附加到 运行 循环才能工作。
当我说 NSRunLoop 每次 tick 都会调用 CADisplayLink 时,我早些时候(在聊天中)略微说错了,但事实并非如此。根据我的理解,NSRunLoop 基本上是一个 while(1) 循环,它的工作是保持线程活动、处理事件等......为了避免失误,我将尝试在接下来的部分中广泛引用 Apple 自己的文档。
A run loop is very much like its name sounds. It is a loop your thread enters and uses to run event handlers in response to incoming events. Your code provides the control statements used to implement the actual loop portion of the run loop—in other words, your code provides the
while
orfor
loop that drives the run loop. Within your loop, you use a run loop object to "run” the event-processing code that receives events and calls the installed handlers.
Anatomy of a Run Loop - Threading Programming Guide - developer.apple.com
CADisplayLink
用NSRunLoop
需要加一但是不一样。引用CADisplayLink头文件:
“Unless paused, it will fire every vsync until removed.”
From:func add(to runloop: RunLoop, forMode mode: RunLoopMode)
来自 preferredFramesPerSecond
属性文档。
Default value is zero, which means the display link will fire at the native cadence of the display hardware.
...
For example, if the maximum refresh rate of the screen is 60 frames per second, that is also the highest frame rate the display link sets as the actual frame rate.
所以如果你想做任何定时屏幕刷新CADisplayLink
(默认设置)就是你想要使用的。
渲染服务器介绍
If you happen to block a thread, that has nothing to do with how UIKit works.
不完全是。我们只需要从主线程触摸 UIView 的原因是因为 UIKit 不是线程安全的,它在主线程上 运行s。如果你阻塞了主线程,你就阻塞了线程 UIKit 运行s on.
Whether UIKit works "like you say" {... "send a message to stop video frames. do all our work! send another message to start video again!"}
我不是这个意思
Or whether it works "like I say" {... ie, like normal programming "do as much as you can until the frames about to end - oh no it's ending! - wait until the next frame! do more..."}
这不是 UIKit 的工作方式,我看不出它在不从根本上改变其架构的情况下如何工作。监视帧结束是什么意思?
正如我在回答的“UIKit 布局和渲染概述”部分所讨论的那样 UIKit 尝试不预先做任何工作。 -setNeedsLayout
和 -setNeedsDisplay
每帧可以调用任意多次。它们只会使布局和视图渲染无效,如果该帧已经无效,则第二次调用什么都不做。这意味着如果 10 次更改都使视图的布局无效 UIKit 仍然只需要支付重新计算布局一次的成本(除非你在 -setNeedsLayout
调用之间使用了 -layoutIfNeeded
)。
-setNeedsDisplay
也是如此。尽管如前所述,这些都与屏幕上显示的内容无关。 layoutIfNeeded
更新视图框架,displayIfNeeded
更新视图渲染纹理,但这与屏幕上显示的内容无关。想象一下,每个 UIView 都有一个 UIImage 变量来表示它的后备存储(它实际上在 CALayer 或下面,而不是 UIImage。但这是一个例子)。重绘该视图只会更新 UIImage。但是 UI图像仍然只是数据,而不是屏幕上的图形,直到它被某种东西绘制到屏幕上。
那么 UIView 如何绘制到屏幕上?
之前我写了伪代码UIKit的主渲染运行循环。到目前为止,在我的回答中,我一直忽略了 UIKit 的重要部分,而不是你进程中的所有 运行s。数量惊人的 UIKit 与显示内容相关的内容实际上发生在渲染服务器进程中,而不是您的应用程序进程中。渲染server/window服务器是SpringBoard(主屏幕UI)直到iOS6(从那以后BackBoard和FrontBoard吸收了很多SpringBoards更核心的OS相关特性,让它更多地专注于成为主要操作系统 UI。主页 screen/lock screen/notification center/control center/app switcher/etc...).
UIKit 的主要渲染 运行loop 的伪代码可能更接近于此。再一次,请记住 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 个空应用程序模板,并将相同的代码粘贴到这两个模板中。两者都应该在屏幕中间的标签中显示当前时间。当我按下 freeze 时,它会调用 sleep(1)
并冻结应用程序。一切都停止了。但是iOS整体来说还是可以的。其他app、控制中心、通知中心等...均不受其影响
Whether UIKit works "like you say" {... "send a message to stop video frames. do all our work! send another message to start video again!"}
应用程序中没有 UIKit stop video frames
命令,因为您的应用程序根本无法控制屏幕。屏幕将使用 window 服务器提供的任何帧以 60FPS 的速度更新。 window 服务器将使用您的应用为其提供的最后已知位置、纹理和图层树以 60FPS 的速度为显示器合成一个新帧。
当您冻结应用程序中的主线程时,最后 运行 行(在您昂贵的 add lots of views
代码之后)被阻塞并且不会 运行。因此,即使有变化,window 服务器还没有发送它们,所以只是继续使用它为您的应用发送的最后一个状态。
动画是 UIKit 的另一部分,它 运行 在 window 服务器中处于进程外。如果在该示例应用程序的 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 秒,这意味着 none 这些更改会发送到 window 服务器,直到动画完成大部分时间。
这里只是为了确认 sleep()
行被注释掉的结果。
这应该可以解释发生了什么。这些更改就像您在问题中添加的 UIView。它们排队等待包含在下一次更新中,但是因为您通过一次发送这么多来阻塞主线程,所以您正在停止正在发送的消息,这将使它们包含在下一帧中。下一帧未被阻止,iOS 将生成一个新帧,显示它从 SpringBoard 和其他 iOS 应用程序收到的所有更新。但是因为您的应用仍在阻塞,它的主线程 iOS 尚未从您的应用收到任何更新,因此不会显示任何更改(除非它有更改,例如动画,已经在 window 服务器)。
所以总结一下
- UIKit 尝试做尽可能少的事情,以便批量更改布局和渲染。
- UIKit 运行s 在主线程上,阻塞主线程会阻止 UIKit 在该操作完成之前做任何事情。
- UI处理中的套件无法触摸显示器,它每帧向 window 服务器发送图层和更新
- 如果您阻塞主线程,则更改永远不会发送到 window 服务器,因此不会显示
使用NSNotification
达到所需的效果。
首先 - 将观察者注册到主视图并创建观察者处理程序。
然后,在单独的线程(例如后台)中初始化所有这些 A、B、C... 对象,例如 self performSelectorInBackground
然后 - post 来自子视图的通知和最后 - performSelectorOnMainThread
以所需的延迟添加子视图。
为了回答评论中的问题,假设您有一个显示在屏幕上的 UIViewController。这个对象 - 不是讨论的焦点,你可以决定把代码放在哪里,控制视图的外观。该代码用于 UIViewController 对象(因此,它是自身的)。 View - 一些 UIView 对象,被视为父视图。 ViewN - 子视图之一。以后可以缩放。
[[NSNotificationCenter defaultCenter] addObserver:self
selector:@selector(handleNotification:)
name:@"ViewNotification"
object:nil];
这注册了一个观察者 - 需要在线程之间进行通信。 ViewN * V1 = [[ViewN alloc] init]; 这里 - 可以分配子视图 - 尚未显示。
- (void) handleNotification: (id) note {
ViewN * Vx = (ViewN*) [(NSNotification *) note.userInfo objectForKey: @"ViewArrived"];
[self.View performSelectorOnMainThread: @selector(addSubView) withObject: Vx waitUntilDone: FALSE];
}
此处理程序允许接收消息并将UIView
对象放置到父视图。看起来很奇怪,但重点是——你需要在主线程上执行 addSubview 方法才能生效。 performSelectorOnMainThread
允许在不阻塞应用程序执行的情况下开始在主线程上添加子视图。
现在 - 我们创建一个将子视图放置到屏幕的方法。
-(void) sendToScreen: (id) obj {
NSDictionary * mess = [NSDictionary dictionaryWithObjectsAndKeys: obj, @"ViewArrived",nil];
[[NSNotificationCenter defaultCenter] postNotificationName: @"ViewNotification" object: nil userInfo: mess];
}
此方法将post 来自任何线程的通知,发送一个名为 ViewArrived 的对象作为 NSDictionary 项。
最后 - 必须延迟 3 秒添加的视图:
-(void) initViews {
ViewN * V1 = [[ViewN alloc] init];
ViewN * V2 = [[ViewN alloc] init];
ViewN * V3 = [[ViewN alloc] init];
[self performSelector: @selector(sendToScreen:) withObject: V1 afterDelay: 3.0];
[self performSelector: @selector(sendToScreen:) withObject: V2 afterDelay: 6.0];
[self performSelector: @selector(sendToScreen:) withObject: V3 afterDelay: 9.0];
}
这不是唯一的解决方案。也可以通过统计NSArray
subviews
属性.
来控制父视图的子视图
在任何情况下,您都可以在需要时使用 运行 initViews
方法,甚至在后台线程中,它允许控制子视图的外观,performSelector
机制允许避免执行线程阻塞。
window 服务器对屏幕上显示的内容具有最终控制权。 iOS 仅在提交当前 CATransaction
时向 window 服务器发送更新。为了在需要时实现这一点,iOS 在主线程的 运行 循环中为 .beforeWaiting
activity 注册了一个 CFRunLoopObserver
。在处理一个事件后(可能通过调用您的代码),运行 循环在等待下一个事件到达之前调用观察者。观察者提交当前事务(如果有的话)。提交事务包括 运行 布局通道、显示通道(在其中调用您的 drawRect
方法),以及将更新后的布局和内容发送到 window 服务器。
如果需要,调用 layoutIfNeeded
执行布局,但不调用显示通道或向 window 服务器发送任何内容。如果您希望 iOS 将更新发送到 window 服务器,您必须提交当前事务。
一种方法是调用 CATransaction.flush()
。使用 CATransaction.flush()
的合理情况是当您想要在屏幕上放置一个新的 CALayer
并且您希望它立即有一个动画。新的 CALayer
在事务提交之前不会发送到 window 服务器,并且在它显示在屏幕上之前不能向其添加动画。因此,您将图层添加到图层层次结构,调用 CATransaction.flush()
,然后将动画添加到图层。
您可以使用CATransaction.flush
来获得您想要的效果。 我不推荐这个,但这是代码:
@IBOutlet var stackView: UIStackView!
@IBAction func buttonWasTapped(_ sender: Any) {
stackView.subviews.forEach { [=10=].removeFromSuperview() }
for _ in 0 ..< 3 {
addSlowSubviewToStack()
CATransaction.flush()
}
}
func addSlowSubviewToStack() {
let view = UIView()
// 300 milliseconds of “work”:
let endTime = CFAbsoluteTimeGetCurrent() + 0.3
while CFAbsoluteTimeGetCurrent() < endTime { }
view.translatesAutoresizingMaskIntoConstraints = false
view.heightAnchor.constraint(equalToConstant: 44).isActive = true
view.backgroundColor = .purple
view.layer.borderColor = UIColor.yellow.cgColor
view.layer.borderWidth = 4
stackView.addArrangedSubview(view)
}
结果如下:
上述解决方案的问题是它通过调用Thread.sleep
阻塞了主线程。如果你的主线程不响应事件,用户不仅会感到沮丧(因为你的应用程序没有响应她的触摸),而且最终 iOS 将决定应用程序挂起并杀死它。
更好的方法是简单地安排每个视图在您希望它出现时添加。你声称“这不是工程”,但你错了,你给出的理由毫无意义。 iOS 通常每 16⅔ 毫秒更新一次屏幕(除非您的应用需要比这更长的时间来处理事件)。只要你想要的延迟至少那么长,你可以在延迟后安排一个块 运行 来添加下一个视图。如果你想要小于16⅔毫秒的延迟,一般是做不到的。
所以这是添加子视图的更好的推荐方法:
@IBOutlet var betterButton: UIButton!
@IBAction func betterButtonWasTapped(_ sender: Any) {
betterButton.isEnabled = false
stackView.subviews.forEach { [=11=].removeFromSuperview() }
addViewsIfNeededWithoutBlocking()
}
private func addViewsIfNeededWithoutBlocking() {
guard stackView.arrangedSubviews.count < 3 else {
betterButton.isEnabled = true
return
}
self.addSubviewToStack()
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(300)) {
self.addViewsIfNeededWithoutBlocking()
}
}
func addSubviewToStack() {
let view = UIView()
view.translatesAutoresizingMaskIntoConstraints = false
view.heightAnchor.constraint(equalToConstant: 44).isActive = true
view.backgroundColor = .purple
view.layer.borderColor = UIColor.yellow.cgColor
view.layer.borderWidth = 4
stackView.addArrangedSubview(view)
}
这是(相同的)结果:
3 种可能在下面起作用的方法。第一个我可以让它工作如果子视图正在添加控制器以及如果它不直接在视图中 controller.The 第二个是自定义视图 :) 你似乎想知道 layoutSubviews 何时在视图上完成.由于 1000 个子视图顺序加上,这个连续的过程就是冻结显示的原因。根据您的情况,您可以在 viewDidLayoutSubviews() 完成时添加 childviewcontroller 视图和 post 通知,但我不知道这是否适合您的用例。我在添加的 viewcontroller 视图上测试了 1000 个订阅者并且它有效。在那种情况下,延迟 0 将完全按照您的意愿进行。这是一个工作示例。
import UIKit
class TrackingViewController: UIViewController {
var layoutCount = 0
override func viewDidLoad() {
super.viewDidLoad()
// Add a bunch of subviews
for _ in 0...1000{
let view = UIView(frame: self.view.bounds)
view.autoresizingMask = [.flexibleWidth,.flexibleHeight]
view.backgroundColor = UIColor.green
self.view.addSubview(view)
}
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
print("Called \(layoutCount)")
if layoutCount == 1{
//finished because first call was an emptyview
NotificationCenter.default.post(name: NSNotification.Name(rawValue: "kLayoutFinished"), object: nil)
}
layoutCount += 1
} }
然后在您要添加子视图的主视图控制器中,您可以执行此操作。
import UIKit
class ViewController: UIViewController {
var y :CGFloat = 0
var count = 0
override func viewDidLoad() {
super.viewDidLoad()
// Do any additional setup after loading the view, typically from a nib.
NotificationCenter.default.addObserver(self, selector: #selector(ViewController.finishedLayoutAddAnother), name: NSNotification.Name(rawValue: "kLayoutFinished"), object: nil)
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 4, execute: {
//add first view
self.finishedLayoutAddAnother()
})
}
deinit {
NotificationCenter.default.removeObserver(self, name: NSNotification.Name(rawValue: "kLayoutFinished"), object: nil)
}
func finishedLayoutAddAnother(){
print("We are finished with the layout of last addition and we are displaying")
addView()
}
func addView(){
// we keep adding views just to cause
print("Fired \(Date())")
if count < 100{
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 0.0, execute: {
// let test = TestSubView(frame: CGRect(x: self.view.bounds.midX - 50, y: y, width: 50, height: 20))
let trackerVC = TrackingViewController()
trackerVC.view.frame = CGRect(x: self.view.bounds.midX - 50, y: self.y, width: 50, height: 20)
trackerVC.view.backgroundColor = UIColor.red
self.view.addSubview(trackerVC.view)
trackerVC.didMove(toParentViewController: self)
self.y += 30
self.count += 1
})
}
}
}
或者还有一种更疯狂但可能更好的方法。创建您自己的视图,在某种意义上保持自己的时间并在不丢帧的情况下回调。这是未抛光的,但可以工作。
import UIKit
class CompletionView: UIView {
private var lastUpdate : TimeInterval = 0.0
private var checkTimer : Timer!
private var milliSecTimer : Timer!
var adding = false
private var action : (()->Void)?
//just for testing
private var y : CGFloat = 0
private var x : CGFloat = 0
//just for testing
var randomColors = [UIColor.purple,UIColor.gray,UIColor.green,UIColor.green]
init(frame: CGRect,targetAction:(()->Void)?) {
super.init(frame: frame)
action = targetAction
adding = true
for i in 0...999{
if y > bounds.height - bounds.height/100{
y -= bounds.height/100
}
let v = UIView(frame: CGRect(x: x, y: y, width: bounds.width/10, height: bounds.height/100))
x += bounds.width/10
if i % 9 == 0{
x = 0
y += bounds.height/100
}
v.backgroundColor = randomColors[Int(arc4random_uniform(4))]
self.addSubview(v)
}
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func milliSecCounting(){
lastUpdate += 0.001
}
func checkDate(){
//length of 1 frame
if lastUpdate >= 0.003{
checkTimer.invalidate()
checkTimer = nil
milliSecTimer.invalidate()
milliSecTimer = nil
print("notify \(lastUpdate)")
adding = false
if let _ = action{
self.action!()
}
}
}
override func layoutSubviews() {
super.layoutSubviews()
lastUpdate = 0.0
if checkTimer == nil && adding == true{
checkTimer = Timer.scheduledTimer(timeInterval: 0.01, target: self, selector: #selector(CompletionView.checkDate), userInfo: nil, repeats: true)
}
if milliSecTimer == nil && adding == true{
milliSecTimer = Timer.scheduledTimer(timeInterval: 0.001, target: self, selector: #selector(CompletionView.milliSecCounting), userInfo: nil, repeats: true)
}
}
}
import UIKit
class ViewController: UIViewController {
var y :CGFloat = 30
override func viewDidLoad() {
super.viewDidLoad()
// Wait 3 seconds to give the sim time
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 3, execute: {
[weak self] in
self?.addView()
})
}
var count = 0
func addView(){
print("starting")
if count < 20{
let completionView = CompletionView(frame: CGRect(x: 0, y: self.y, width: 100, height: 100), targetAction: {
[weak self] in
self?.count += 1
self?.addView()
print("finished")
})
self.y += 105
completionView.backgroundColor = UIColor.blue
self.view.addSubview(completionView)
}
}
}
或者最后,您可以在 viewDidAppear 中执行回调或通知,但似乎在回调中执行的任何代码都需要包含在 viewDidAppear 回调中及时执行。
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 0.0, execute: {
//code})