31

在开始之前请注意,这与后台处理无关。没有涉及背景的“计算”。

只有 UIKit

view.addItemsA()
view.addItemsB()
view.addItemsC()

假设在 6s iPhone 上

UIKit 的每一个都需要一秒钟的时间来构建。

这将发生:

一大步

它们同时出现。重复一遍,在 UIKit 做大量工作时,屏幕只是挂起 3 秒。然后它们都同时出现。

但是,假设我希望这种情况发生:

在此处输入图像描述

它们逐渐出现。当 UIKit 构建一个屏幕时,屏幕只会挂起 1 秒。它出现。它在构建下一个时再次挂起。它出现。等等。

(注意“一秒”只是一个简单的例子,为了清楚起见。见这篇文章的结尾更完整的例子。)

你如何在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 确保所有子视图都已绘制。)


万一这还不清楚...

日常用例示例:

• 假设可能有七个部分

• 假设每个 UIKit 通常需要0.01 到 0.20来构建(取决于您显示的信息)。

• 如果你只是“让整个事情一气呵成”,通常是可以接受的(总时间,比如 0.05 到 0.15)......但是......

• 当“新屏幕出现”时,用户通常会有一个乏味的停顿。(0.1 到 0.5 或更差)。

• 如果您按照我的要求进行操作,它将始终平滑地显示在屏幕上,一次一个块,每个块的时间最短。

4

5 回答 5

23

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) 循环,其工作是保持线程处于活动状态、处理事件等......

运行循环非常像它的名字听起来。这是您的线程进入并用于运行事件处理程序以响应传入事件的循环。您的代码提供了用于实现运行循环的实际循环部分的控制语句——换句话说,您的代码提供了驱动运行循环的whileorfor循环。在循环中,您使用运行循环对象来“运行”接收事件并调用已安装处理程序的事件处理代码。
运行循环剖析 - 线程编程指南 - 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 无法触摸显示屏,它每帧都会向窗口服务器发送图层和更新
  • 如果您阻塞主线程,则更改永远不会发送到窗口服务器,因此不会显示
于 2017-02-23T16:36:38.870 回答
13

窗口服务器最终控制屏幕上显示的内容。CATransactioniOS 仅在当前提交时向窗口服务器发送更新。为了在需要时实现这一点,iOS在主线程的运行循环中CFRunLoopObserver为活动注册了一个。.beforeWaiting在处理了一个事件(可能是通过调用你的代码)之后,运行循环在等待下一个事件到达之前调用观察者。观察者提交当前事务,如果有的话。提交事务包括运行布局传递、显示传递(在其中drawRect调用您的方法)以及将更新的布局和内容发送到窗口服务器。

如果需要,调用layoutIfNeeded会执行布局,但不会调用显示通道或向窗口服务器发送任何内容。如果您希望 iOS 向窗口服务器发送更新,您必须提交当前事务。

一种方法是调用CATransaction.flush(). 一个合理的使用案例CATransaction.flush()是当你想CALayer在屏幕上放一个新的并且你希望它立即有一个动画。在事务提交之前,新的CALayer不会被发送到窗口服务器,并且在它出现在屏幕上之前你不能向它添加动画。因此,您将图层添加到图层层次结构中,调用CATransaction.flush(),然后将动画添加到图层。

你可以用它CATransaction.flush来获得你想要的效果。我不推荐这个,但这里是代码:

@IBOutlet var stackView: UIStackView!

@IBAction func buttonWasTapped(_ sender: Any) {
    stackView.subviews.forEach { $0.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)
}

结果如下:

CATransaction.flush 演示

上述解决方案的问题是它通过调用阻塞主线程Thread.sleep。如果您的主线程没有响应事件,不仅用户会感到沮丧(因为您的应用程序没有响应她的触摸),而且最终 iOS 将决定该应用程序挂起并终止它。

更好的方法是在您希望每个视图出现时安排添加每个视图。你声称“这不是工程”,但你错了,你给出的理由毫无意义。iOS 通常每 16⅔ 毫秒更新一次屏幕(除非您的应用程序需要更长的时间来处理事件)。只要您想要的延迟至少那么长,您就可以安排一个块在延迟之后运行以添加下一个视图。如果你想要小于 16⅔ 毫秒的延迟,一般情况下你是做不到的。

所以这是添加子视图的更好的推荐方法:

@IBOutlet var betterButton: UIButton!

@IBAction func betterButtonWasTapped(_ sender: Any) {
    betterButton.isEnabled = false
    stackView.subviews.forEach { $0.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)
}

这是(相同的)结果:

DispatchQueue asyncAfter 演示

于 2017-02-26T18:35:14.967 回答
2

这是一种解决方案。但这不是工程。

实际上,是的。通过添加延迟,您正在做所说的您想做的事情:您允许运行循环完成并执行布局,并在完成后立即重新进入主线程。事实上,这是我的主要用途之一delay。(您甚至可以使用delay零。)

于 2017-02-23T15:45:40.287 回答
1

3种方法可能在下面起作用。如果子视图不直接在视图控制器中添加控制器,则第一个我可以使它工作。第二个是自定义视图:) 看来您想知道 layoutSubviews 何时在我的视图上完成。这个连续的过程是冻结显示的原因,因为 1000 个子视图加上顺序。根据您的情况,您可以添加 childviewcontroller 视图并在 viewDidLayoutSubviews() 完成时发布通知,但我不知道这是否适合您的用例。我在添加的 viewcontroller 视图上测试了 1000 个 subs 并且它工作正常。在这种情况下,延迟 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
            })
        }
    }
}

或者有一个更疯狂的方式,可能是更好的方式。创建自己的视图,在某种意义上保持自己的时间,并在不丢帧的情况下回调。这是未经修饰的,但会起作用。

完成 gif

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})
于 2017-02-26T19:56:36.750 回答
0

用来NSNotification达到需要的效果。

首先 - 向主视图注册一个观察者并创建观察者处理程序。

然后,在单独的线程(例如背景)中初始化所有这些 A、B、C... 对象,例如,self performSelectorInBackground

然后 - 从子视图和最后一个发布通知 -performSelectorOnMainThread以所需的延迟添加子视图。
要回答评论中的问题,假设您有一个 UIViewController 显示在屏幕上。这个对象 - 不是讨论点,您可以决定将代码放在哪里,控制视图外观。该代码用于 UIViewController 对象(因此,它是 self)。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];
}

此方法将从任何线程发布通知,将对象作为名为 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机制允许避免执行线程阻塞。

于 2017-02-26T02:13:39.570 回答