如何,简单地说,等待任何布局在iOS?

k3fezbri  于 2023-02-10  发布在  iOS
关注(0)|答案(5)|浏览(129)

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

    • 仅限UIKit**。
view.addItemsA()
view.addItemsB()
view.addItemsC()

比如说在6s的iPhone上

UIKit构建每个文件需要一秒钟。

这将发生:

它们会同时出现。重复一遍,UIKit执行大量工作时,屏幕会暂停3秒钟。然后它们会同时出现。

但假设我希望这样:

它们会逐渐出现。UIKit创建一个时,屏幕会暂停1秒钟。然后屏幕会出现。创建下一个时,屏幕会再次暂停。然后屏幕会出现。依此类推。

(Note"一秒钟"只是一个简单的例子,为了清晰起见。更完整的例子请看这篇文章的结尾。)
在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: RunLoop.Mode.default, before: Date())

view.addItemsB()
view.setNeedsDisplay()
view.layoutIfNeeded()

合理。但我们最近的实际测试表明,这在许多情况下似乎不起作用。
(ie,苹果的UIKit现在已经足够成熟,足以让UIKit的工作超越这个"把戏"。)
思考:在UIKit中,有没有可能有一种方法,当它基本上绘制了所有你堆叠起来的视图时,得到一个回调?有没有其他的解决方案?
一个解决方案似乎是..将子视图*放在控制器*中,这样您就可以获得"didAppear"回调,并跟踪它们。这似乎很幼稚,但也许这是唯一的模式?无论如何,它真的会工作吗?(只有一个问题:我看不出didAppear可以保证所有子视图都已绘制。)
∮如果你还不明白∮
日常使用案例示例:
·假设可能有
7个部分

·假设每个UIKit通常需要0.01到0.20来构建(取决于您显示的信息)。
·如果你只是**"让整件事一次完成",它通常是可以接受的(总时间,比如0.05~0.15)......但是......
·当"新屏幕出现"时,用户通常会有一段冗长的停顿(
.1到.5或更糟**)。
·然而,如果你按照我的要求去做,它总是会平滑地出现在屏幕上,一次一个区块,每个区块的时间都尽可能短。

xtupzzrd

xtupzzrd1#

TLDR

使用CATransaction.flush()将挂起的UI更改强制到呈现服务器上,或使用CADisplayLink将工作拆分到多个帧(下面的示例代码)。

总结

在UIKit中,有没有一种方法可以在绘制完您堆叠的所有视图后获得回调?
没有
iOS就像一个游戏一样,每帧最多渲染一次更改(无论你做了多少)。确保在屏幕上渲染更改后运行一段代码的唯一方法是等待下一帧。
有别的解决办法吗?
是的,iOS可能每帧只渲染一次更改,但您的应用并不负责渲染,而是窗口服务器进程负责。
您的应用执行布局和渲染,然后将其对layerTree的更改提交给渲染服务器。它将在runloop结束时自动执行此操作,或者您可以通过调用CATransaction.flush()强制将未完成的事务发送给渲染服务器。
然而,阻塞主线程通常是不好的(不仅仅是因为它阻塞了UI更新),所以如果可以的话,你应该避免它。

可能的解决方案

这是你感兴趣的部分。

    • 1:尽可能多地处理后台队列并提高性能。**

说真的,iPhone7是我家里第三强大的电脑(不是手机),仅次于我的游戏PC和Macbook Pro。它比我家里的其他电脑都快。渲染应用程序UI不应该暂停3秒。

    • 2:刷新挂起的CA事务**

编辑:正如rob mayoff所指出的,您可以通过调用CATransaction.flush()强制CoreAnimation将挂起的更改发送到渲染服务器

addItems1()
CATransaction.flush()
addItems2()
CATransaction.flush()
addItems3()

这实际上不会在那里呈现更改,而是将挂起的UI更新发送到窗口服务器,以确保它们包含在下一次屏幕更新中。
这将工作,但附带这些警告,在苹果的文档。
但是,你应该尽量避免显式调用flush。通过允许flush在runloop期间执行......事务和动画在事务之间工作将继续工作。
但是CATransaction头文件包含了这个引号,这似乎暗示了,即使他们不喜欢它,这也是官方支持的用法。
在某些情况下(例如,没有运行循环,或运行循环被阻塞),可能需要使用显式事务来获得及时的渲染树更新。
一对一对-一对二对。

    • 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 (seems to work ok, might lead to infitie recursion if used too frequently in the codebase)
addItems1()
RunLoop.current.run(mode: .default, before: Date())
addItems2()
RunLoop.current.run(mode: .default, before: Date())
addItems3()

除非(在响应runloop事件时)您执行了其他操作以阻止runloop调用完成,否则此操作应该有效,因为CATransaction将在runloop结束时发送到窗口服务器。

示例代码

Demonstration Xcode Project & Xcode Playground (Xcode 8.2, Swift 3)

    • 我应该使用哪个选项?**

我最喜欢DispatchQueue.main.asyncAfter(deadline: .now() + 0.0)CADisplayLink,但是DispatchQueue.main.asyncAfter不能保证它在下一个runloop tick上运行,所以你可能不想信任它?
CATransaction.flush()将强制您的UI更改推送到呈现服务器,这种用法似乎符合Apple对该类的评论,但附带了一些警告。
在某些情况下(例如,没有运行循环,或运行循环被阻塞),可能需要使用显式事务来获得及时的渲染树更新。

详细说明

这个答案的其余部分是关于UIKit内部发生的事情的背景,并解释了为什么最初的答案尝试使用view.setNeedsDisplay()view.layoutIfNeeded()没有做任何事情。

UIKit布局和渲染概述

CADisplayLink与UIKit和运行循环完全无关。
不完全是。iOS的用户界面是GPU渲染的,就像3D游戏一样。而且尽可能少做一些事情。所以很多事情,比如布局和渲染,并不是在需要的时候发生的。这就是为什么我们称之为"setNeedsLayout",而不是布局子视图。每一帧布局可能会改变多次。但是,iOS会尝试每一帧只调用layoutSubviews一次。而不是setNeedsLayout可能被调用的10次。
然而,CPU上发生了很多事情(布局、-drawRect:等),那么这一切是如何结合在一起的呢?

  • 请注意,这都是简化的,并跳过了许多内容,如CALayer实际上是屏幕上显示的真实视图对象,而不是UIView等... *

每个UIView都可以看作是一个位图,一个图像/GPU纹理。当屏幕渲染时,GPU将视图层次结构合成为我们看到的结果帧。它合成视图,将先前视图顶部的子视图纹理渲染为我们在屏幕上看到的最终渲染(类似于游戏)。
这就是为什么iOS能拥有如此流畅、轻松的动画界面。要在屏幕上为一个视图制作动画,它不需要重新渲染任何东西。在下一帧,该视图的纹理只是在屏幕上与之前略有不同的位置合成。无论是它,还是它所在的视图,都不需要重新渲染它们的内容。
在过去,一个常见的性能技巧是通过完全在drawRect:中渲染表视图单元格来减少视图层次结构中的视图数量。这个技巧是为了使早期iOS设备上的GPU合成步骤更快。然而,GPU在现代iOS设备上的速度非常快,现在这不再是非常担心的问题。

布局子视图和绘制矩形

-setNeedsLayout使视图当前布局无效,并将其标记为需要布局。
如果视图没有有效的布局,-layoutIfNeeded将重新布局视图
-setNeedsDisplay将标记需要重绘的视图。我们前面说过,每个视图都被渲染为视图的纹理/图像,GPU可以在不需要重绘的情况下移动和操作视图。这将触发它重绘。绘图是通过在CPU上调用-drawRect:完成的,因此比依赖GPU要慢,GPU可以处理大多数帧。
需要注意的是这些方法没有做什么。layout方法不做任何可视化的事情。尽管如果视图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将它们合成到下一个帧缓冲区中。唯一的例外是CALayer将shouldRasterize设置为true。在这种情况下,它会为视图及其子视图创建单独的纹理并进行渲染(在GPU上)视图及其子视图合并到一个纹理中,有效地缓存了每帧所需的合成。这具有无需每帧合成所有子视图的性能优势。然而,如果视图或其子视图频繁地改变(如在动画期间),则这将是性能损失,因为这将使高速缓存的纹理无效,从而频繁地要求其被重绘(类似于频繁地调用X1 M30 N1 X)。
现在,任何游戏工程师都会这么做...

view.addItemsA()
RunLoop.current.run(mode: .default, before: Date())

view.addItemsB()
RunLoop.current.run(mode: .default, 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行的run循环,即偶数处理部分。实际上,您正在使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似乎从根本上你相信“运行循环”(即:这一个:运行循环。当前)是CADisplayLink。
runloop和CADisplayLink不是一回事,但是CADisplayLink连接到runloop才能工作。

我之前说的NSRunLoop在每一个节拍都调用CADisplayLink的时候说错了,它没有。据我所知,NSRunLoop基本上是一个while(1)循环,它的工作是保持线程活动,处理事件等...为了避免失误,我将尝试广泛引用苹果自己的文档来解释下面的内容。
run循环与其名称听起来非常相似。它是线程进入并用于运行事件处理程序以响应传入事件的循环。代码提供用于实现run循环的实际循环部分的控制语句-换句话说,代码提供驱动run循环的whilefor循环。在循环中,使用runloop对象来“运行”接收事件并调用已安装的处理程序的事件处理代码。
Anatomy of a Run Loop - Threading Programming Guide - developer.apple.com
CADisplayLink使用NSRunLoop,需要添加到一个不同的文件中。引用CADisplayLink头文件:
除非暂停,否则它将激发每个垂直同步,直到删除为止。
出发地:func add(to runloop: RunLoop, forMode mode: RunLoopMode)
以及preferredFramesPerSecond属性文档中的内容。
默认值为零,表示显示链接将以显示硬件的本机节奏触发。
...
例如,如果屏幕的最大刷新率是每秒60帧,则这也是显示链接设置为实际帧速率的最高帧速率。
因此,如果你想做任何事情定时屏幕刷新CADisplayLink(与默认设置)是你想使用的。

介绍渲染服务器

如果您碰巧阻塞了一个线程,这与UIKit的工作方式无关。
不完全是。我们被要求只从主线程接触UIView的原因是因为UIKit不是线程安全的,它在主线程上运行。如果你阻塞了主线程,你已经阻塞了线程UIKit运行。
UIKit是否工作“就像你说的”{...“发送一条消息停止视频帧。做我们所有的工作!发送另一条消息重新开始视频!"}
我不是这个意思.
或者它是否工作“就像我说的”{......也就是说,像正常的编程“尽你所能,直到帧即将结束-哦,不,它的结束!-等到下一帧!做更多......"}
这不是UIKit的工作方式,如果不从根本上改变它的架构,我不知道它怎么能做到。它是如何观看帧结束的?
正如我的答案中的“UIKit布局和渲染概述”部分所讨论的那样,UIKit不尝试做任何前期工作。-setNeedsLayout-setNeedsDisplay可以在每帧中调用多次。它们只会使布局和视图渲染无效,如果已经使该帧无效,则第二调用不做任何事情。这意味着如果10次更改都使视图布局无效,UIKit仍然只需要支付一次重新计算布局的成本(除非您在-setNeedsLayout调用之间使用了-layoutIfNeeded)。
-setNeedsDisplay也是如此。尽管如前所述,这两个变量都与屏幕上显示的内容无关。layoutIfNeeded更新视图帧,displayIfNeeded更新视图渲染纹理,但这与屏幕上显示的内容无关。假设每个UIView都有一个UIImage变量,表示其后备存储(它实际上在CALayer中,或者更低,并且不是一个UIImage。但是这是一个插图)。重绘该视图只是更新UIImage。但是UIImage仍然只是数据,而不是屏幕上的图形,直到它被某个东西绘制到屏幕上。

那么如何在屏幕上绘制UIView呢?

早些时候我写了伪代码UIKit的主渲染运行循环,到目前为止,我的回答忽略了UIKit的一个重要部分,并不是所有的UIKit都在你的进程中运行。2与显示内容相关的UIKit的惊人数量实际上发生在呈现服务器进程中,而不是你的应用程序进程中。3呈现服务器/窗口服务器是SpringBoard(主屏幕UI),直到iOS 6(从那时起,BackBoard和FrontBoard吸收了很多SpringBoards更核心的操作系统相关特性,让它更专注于成为主要的操作系统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上演示这一点。当我们冻结一个应用程序时,另一个应用程序不受影响。

这是我创建并粘贴了相同代码的两个空应用程序模板。两个模板都应该在屏幕中间的标签中显示当前时间。当我按下freeze时,它调用sleep(1)并冻结应用程序。一切都停止了。但iOS作为一个整体是好的。其他应用程序,控制中心,通知中心等...都不受影响。

UIKit是否工作"就像你说的"{..."发送一条消息停止视频帧。做我们所有的工作!发送另一条消息重新开始视频!"}
应用中没有UIKit stop 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秒,然后它将被取消,视图将被移动到屏幕的另一个部分,颜色不同。但是,sleep调用会阻塞主线程2秒,这意味着这些更改都不会发送到窗口服务器,直到动画的大部分时间。

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

这应该可以解释发生了什么。这些变化就像你在问题中添加的UIView一样。它们排队等待包含在下一次更新中,但是因为你一次发送这么多信息阻塞了主线程,所以你停止了发送消息,这将使它们包含在下一帧中。下一帧没有被阻塞,iOS将生成一个新框架,显示从SpringBoard和其他iOS应用收到的所有更新。但由于您的应用仍在阻止其主线程,iOS尚未从您的应用收到任何更新,因此不会显示任何更改(除非它有变化,如动画,已经排队在窗口服务器上)。

    • 总结一下**
  • UIKit尝试做尽可能少的事情,以便批量更改布局和渲染一次完成。
  • UIKit在主线程上运行,阻塞主线程将阻止UIKit在该操作完成之前执行任何操作。
  • 进程中的UIKit不能接触显示器,它每帧都向窗口服务器发送图层和更新
  • 如果阻塞主线程,则更改永远不会发送到窗口服务器,因此不会显示
ilmyapht

ilmyapht2#

窗口服务器对屏幕上显示的内容具有最终控制权。iOS仅在提交当前CATransaction时才向窗口服务器发送更新。为了在需要时执行此操作,iOS在主线程的运行循环中为.beforeWaiting活动注册CFRunLoopObserver。处理事件后(大概是通过调用你的代码),运行循环在等待下一个事件到达之前调用观察器。观察器提交当前事务(如果有)。提交事务包括运行布局通道,显示过程(在其中调用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)
}

结果是这样的:

上述解决方案的问题在于,它通过调用Thread.sleep来阻塞主线程。如果主线程不响应事件,用户不仅会感到沮丧(因为应用不响应她的触摸),而且最终iOS会判定应用挂起并杀死它。
更好的办法是,简单地安排好每个视图的添加时间。你声称"这不是工程设计",但你错了,你给出的理由毫无意义。iOS通常每16毫秒更新一次屏幕(除非你的应用处理事件的时间比这个长)。只要你想要的延迟至少有那么长,你可以只安排一个程序块在延迟后运行以添加下一个视图。2如果你希望延迟小于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)
}

下面是(相同的)结果:

rsaldnfx

rsaldnfx3#

这是一种解决方案,但不是工程学。
事实上,是的,通过增加延迟,你正在做你说你想做的事情:您允许运行循环完成并执行布局,并在完成后立即重新进入主线程。实际上,这是我对delay的一个主要用途。(您甚至可以使用为零的delay。)

ugmeyewa

ugmeyewa4#

下面有3种方法可以使用。第一种方法是如果一个子视图添加了控制器,如果它不是直接在视图控制器中,我也可以使用它。第二种方法是自定义视图:)在我看来,你似乎想知道layoutSubviews在视图上什么时候完成。这个连续的过程是什么冻结了显示,因为1000个子视图按顺序增加。根据你的情况,你可以添加一个childviewcontroller视图,并在viewDidLayoutSubviews()完成时发布一个通知,但我不知道这是否适合你的用例。我在添加的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中执行回调或通知,但似乎也需要 Package 在回调上执行的任何代码,以便从viewDidAppear回调中及时执行。

DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 0.0, execute: {
  //code})
2vuwiymt

2vuwiymt5#

使用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];
}

此方法将发布来自任何线程的通知,并发送一个名为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];
}

这不是唯一的解决方案,也可以通过计算NSArraysubviews属性来控制父视图的子视图。
在任何情况下,您都可以随时运行initViews方法,甚至在后台线程中运行,它允许控制子视图外观,performSelector机制允许避免执行线程阻塞。

相关问题