web-dev-qa-db-ja.com

iOSでレイアウトを待つ方法は?

始める前に、これはバックグラウンド処理とは関係がないことに注意してください。背景となる「計算」はありません。

UIKitのみ。

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

6秒のiPhoneとしましょう

これらのそれぞれがUIKitが構築するのに1秒かかります。

これが起こります:

one big step

それらはすべて表示されますAT 1回。繰り返しますが、UIKitが大量の作業を行っている間、画面は3秒間ハングします。その後、すべてが一度に表示されます。

しかし、これを実現させたいとしましょう:

enter image description here

彼らは漸進的に表示されます。 UIKitがビルドしている間、画面は1秒間ハングします。現れる。次のものをビルドしている間、再びハングします。現れる。等々。

(「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で、基本的に、積み重ねたすべてのビューが描画されたときにコールバックを取得する方法はありますか?別の解決策はありますか?

1つの解決策は..サブビューをコントローラに置くと、「didAppear」が表示されますコールバックし、それらを追跡します。 それは乳児のように見えますが、おそらくそれが唯一のパターンですか?とにかくそれは本当にうまくいくでしょうか? (ただ1つの問題:didAppearがすべてのサブビューが描画されることを保証する保証はありません。)


これがまだはっきりしない場合...

日常的な使用例:

•おそらく7つのセクションがあるとしましょう。

•UIKitが構築するために、それぞれが通常0.01から0.20を使用するとします(表示する情報に応じて)。

「すべてを1回でやる」場合は、多くの場合、問題がないか、許容範囲です(合計時間、たとえば0.05〜0.15)。 。 だが ...

•「新しい画面が表示される」ため、ユーザーが退屈することがよくあります。 (。1から.5以下)。

•私が求めていることを行うと、画面上では常に1つのチャンクでスムーズになり、チャンクごとに可能な最小時間を確保します。

29
Fattie

ウィンドウサーバーは、画面に表示されるものを最終的に制御します。 iOSは、現在のCATransactionがコミットされたときにのみ、更新をウィンドウサーバーに送信します。必要なときにこれを実行するために、iOSはメインスレッドの実行ループで_.beforeWaiting_アクティビティのCFRunLoopObserverを登録します。 (おそらくコードを呼び出すことによって)イベントを処理した後、実行ループは、次のイベントの到着を待つ前にオブザーバーを呼び出します。オブザーバは、現在のトランザクションがあれば、それをコミットします。トランザクションのコミットには、レイアウトパス、表示パス(drawRectメソッドが呼び出される)の実行、および更新されたレイアウトとコンテンツのウィンドウサーバーへの送信が含まれます。

layoutIfNeededを呼び出すと、必要に応じてレイアウトが実行されますが、ディスプレイパスが呼び出されたり、ウィンドウサーバーに何かが送信されたりすることはありません。 iOSから更新をウィンドウサーバーに送信する場合は、現在のトランザクションをコミットする必要があります。

これを行う1つの方法は、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 demo

上記のソリューションの問題は、_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 demo

10
rob mayoff

TLDR

CATransaction.flush()を使用して保留中のUIの変更をレンダリングサーバーに強制するか、CADisplayLinkを使用して作業を複数のフレームに分割します(以下のサンプルコード)。


概要

おそらく、UIKitで、スタックしたすべてのビューを作成したときにコールバックを取得する方法はありますか?

番号

iOSは、ゲームレンダリングの変更のように(いくつ変更しても)、フレームごとに最大で1回動作します。変更が画面に表示された後にコードの実行を保証する唯一の方法は、次のフレームを待つことです。

別の解決策はありますか?

はい、iOSではフレームごとに1回だけ変更をレンダリングできますが、アプリはそのレンダリングを実行しません。ウィンドウサーバープロセスです。
アプリはレイアウトとレンダリングを行い、layerTreeへの変更をレンダリングサーバーにコミットします。これは、実行ループの最後にこれを自動的に行います。または、未処理のトランザクションをCATransaction.flush()を呼び出してレンダリングサーバーに送信するように強制できます。

ただし、メインスレッドをブロックすることは一般的に悪いことです(UIの更新をブロックするだけではありません)。だからできればそれを避けるべきです。

可能な解決策

これはあなたが興味を持っている部分です。

1:バックグラウンドキューでできるだけ多くの処理を行い、パフォーマンスを向上させます。
まじめに、iPhone 7は私の家では3番目に強力なコンピューター(電話ではない)であり、私のゲーム用PCとMacbook Proでしか打ち負かされていません。私の家にある他のどのコンピュータよりも高速です。アプリのUIをレンダリングするのに3秒の休止時間は必要ありません。

2:保留中のCATransactionsをフラッシュします
編集: rob mayoff で指摘されているように、CATransaction.flush()を呼び出して、CoreAnimationに強制的に変更をレンダリングサーバーに送信させることができます。

_addItems1()
CATransaction.flush()
addItems2()
CATransaction.flush()
addItems3()
_

これは実際には変更をすぐにはレンダリングしませんが、保留中のUI更新をウィンドウサーバーに送信し、次の画面更新に確実に含まれるようにします。
これは機能しますが、Appleのドキュメントにこれらの警告が付属しています。

ただし、フラッシュを明示的に呼び出さないようにする必要があります。実行ループ中にフラッシュを実行できるようにすることで... ...そしてトランザクション間で機能するトランザクションとアニメーションは引き続き機能します。

ただし、CATransactionヘッダーファイルにはこの引用が含まれているため、たとえ好きではない場合でも、これは公式にサポートされている使用法です。

状況によっては(つまり、実行ループがないか、実行ループがブロックされている)、レンダーツリーの更新をタイムリーに取得するために明示的なトランザクションを使用する必要がある場合があります。

Appleのドキュメント - "+ [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はフレームごとに1回呼び出され、フレームごとに1つの操作のみを実行できるようにし、操作間で画面を更新できるようにします。

_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を直接呼び出します
無限ループが発生する可能性があるため、気に入らない。しかし、私はそれがありそうもないことを認めます。また、これが正式にサポートされているかどうか、または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()
_

これは、(runloopイベントに応答しているときに)CATransactionがrunloopの最後にウィンドウサーバーに送信されるときにrunloop呼び出しが完了するのをブロックするために何か他のことをしない限り機能します。


コード例

デモンストレーション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が10回呼び出される代わりに、フレームごとに1回だけsetNeedsLayoutを呼び出そうとします。

ただし、CPU(レイアウト、_-drawRect:_など)ではかなりのことが発生するため、どのようにすべてを組み合わせることができますか。

これはすべて簡略化されており、CALayerが実際にはUIViewではなく画面に表示される実際のビューオブジェクトであるなどの多くのことをスキップします...

各UIViewは、ビットマップ、イメージ/ GPUテクスチャと考えることができます。画面がレンダリングされると、GPUはビュー階層を、結果として表示されるフレームに合成します。これはビューを構成し、前のビューの上にあるサブビューテクスチャをレンダリングして、画面に表示される完成したレンダリングにします(ゲームと同様)。

これが、iOSがこのようなスムーズで簡単にアニメーション化できるインターフェースを可能にした理由です。画面全体のビューをアニメーション化するために、何も再レンダリングする必要はありません。テクスチャを表示する次のフレームでは、以前とは画面の少し異なる場所に合成されます。それも、その上にあるビューも、コンテンツを再レンダリングする必要はありません。

以前は、テーブルビューのセルを完全に_drawRect:_でレンダリングすることで、ビュー階層のビューの数を削減するのが一般的なパフォーマンスのヒントでした。このヒントは、初期のiOSデバイスでGPUコンポスト化のステップを高速化するためのものです。ただし、GPUは最近のiOSデバイスでは非常に高速になり、これはあまり心配されなくなりました。

LayoutSubviewsとDrawRect

_-setNeedsLayout_は、ビューの現在のレイアウトを無効にし、必要なレイアウトとしてマークします。
_-layoutIfNeeded_は、有効なレイアウトがない場合、ビューを再レイアウトします

_-setNeedsDisplay_は、ビューを再描画する必要があるとしてマークします。前に、各ビューはビューのテクスチャ/イメージにレンダリングされ、再描画する必要なくGPUで移動および操作できると述べました。これにより、再描画がトリガーされます。描画は、CPUで_-drawRect:_を呼び出すことによって行われるため、ほとんどのフレームを実行できるGPUに依存するよりも遅くなります。

そして注意すべき重要なことは、これらのメソッドが何をしないかです。レイアウトメソッドは視覚的には何もしません。ただし、ビューcontentModeredrawに設定されている場合、ビューフレームを変更すると、ビューのレンダリングが無効になる可能性があります(トリガー_-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で考えると)ビューとサブビューを単一のテクスチャにレンダリングします。各フレームで実行する必要がある合成を効果的にキャッシュします。これには、フレームごとにすべてのサブビューを構成する必要がないというパフォーマンス上の利点があります。ただし、ビューまたはそのサブビューが頻繁に変更される場合(アニメーション中など)、キャッシュされたテクスチャが無効になり、頻繁に再描画する必要があるため、パフォーマンスが低下します(_-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つすべての変更が1秒ずつではなく、3秒後に一度に表示されるのでしょうか。

ボタンを押すなどの結果としてこのコードが実行されている場合、メインスレッド(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_を呼び出すたびに別の呼び出しがトリガーされ、暴走再帰が発生する可能性があります。


THE END


このポイントの下には、説明があります。


ここで何が起こっているかについての追加情報


CADisplayLinkおよびNSRunLoop

私がKHを誤解していない場合、基本的には「実行ループ」(つまり、これ:RunLoop.current)はCADisplayLinkであると信じているようです。

RunloopとCADisplayLinkは同じものではありません。しかし、CADisplayLinkは、実行するために実行ループに接続されます。
私は、NSRunLoopがティックごとにCADisplayLinkを呼び出すと言ったとき、(チャットで)少し間違えて話しましたが、そうではありません。私の理解では、NSRunLoopは基本的にwhile(1)ループであり、スレッドの存続、イベントの処理などを行います...

実行ループは、その名前の響きとよく似ています。これは、スレッドが入り、着信イベントに応答してイベントハンドラーを実行するために使用するループです。コードは、実行ループの実際のループ部分を実装するために使用される制御ステートメントを提供します。つまり、コードは、実行ループを駆動するwhileまたはforループを提供します。ループ内では、実行ループオブジェクトを使用して、イベントを受信し、インストールされているハンドラーを呼び出すイベント処理コードを「実行」します。
実行ループの構造-スレッディングプログラミングガイド-developer.Apple.com

CADisplayLinkNSRunLoopを使用し、1つに追加する必要がありますが異なります。 CADisplayLinkヘッダーファイルを引用するには:

「一時停止しない限り、削除されるまですべてのvsyncを起動します。」
差出人:func add(to runloop: RunLoop, forMode mode: RunLoopMode)

そして、preferredFramesPerSecondプロパティのドキュメントから。

デフォルト値は0です。これは、ディスプレイリンクがディスプレイハードウェアのネイティブケイデンスで起動することを意味します。
...
たとえば、画面の最大リフレッシュレートが60フレーム/秒の場合、これはディスプレイリンクが実際のフレームレートとして設定する最高のフレームレートでもあります。

そのため、画面の更新に時間を設定したい場合は、CADisplayLink(デフォルト設定)を使用します。

レンダーサーバーの紹介

たまたまスレッドをブロックしたとしても、UIKitの動作とは関係ありません。

結構です。メインスレッドからのみUIViewに触れる必要があるのは、UIKitはスレッドセーフではなく、メインスレッドで実行されるためです。メインスレッドをブロックすると、UIKitが実行されているスレッドがブロックされます。

UIKitが「あなたの言うように」機能するかどうか{... "ビデオフレームを停止するためのメッセージを送信します。すべての作業を行ってください!別のメッセージを送信してビデオを再開してください!"}

それは私が言っていることではありません。

または、「私が言うように」機能するかどうか{...つまり、通常のプログラミングのように、「フレームが終了するまでできる限り多くのことを実行します。 }

これはUIKitの動作方法ではなく、アーキテクチャを根本的に変更しない限り、UIKitがどのように機能するかはわかりません。フレームの終了を監視するのはどういう意味ですか?

私の回答の「UIKitレイアウトとレンダリングの概要」セクションで説明したように、UIKitは事前に何もしません。 _-setNeedsLayout_および_-setNeedsDisplay_は、フレームごとに何度でも呼び出すことができます。それらはレイアウトとビューのレンダリングを無効にするだけで、すでにそのフレームが無効になっている場合、2番目の呼び出しは何もしません。これは、10の変更がすべてビューのレイアウトを無効にする場合でも、UIKitはレイアウトを再計算するコストを1回だけ支払う必要があることを意味します(_-layoutIfNeeded_呼び出しの間に_-setNeedsLayout_を使用した場合を除く)。

_-setNeedsDisplay_についても同様です。前述したように、これらはどちらも画面に表示されるものとは関係ありません。 layoutIfNeededはビューフレームを更新し、displayIfNeededはビューレンダーテクスチャを更新しますが、これは画面に表示されるものとは関係ありません。各UIViewに、バッキングストアを表すUIImage変数があると想像してください(実際にはCALayer以下にあり、UIImageではありません。ただし、これは図です)。そのビューを再描画すると、UIImageが更新されるだけです。ただし、UIImageは単なるデータであり、何かによって画面に描画されるまで、画面上のグラフィックではありません。

では、UIViewはどのように画面に描画されますか?

以前、私は疑似コードUIKitのメインレンダーランループを書きました。これまでのところ、私の回答では、UIKitの重要な部分を無視してきました。すべてがプロセス内で実行されるわけではありません。ものの表示に関連する驚くべき量のUIKitは、実際にはアプリのプロセスではなく、レンダリングサーバーのプロセスで発生します。レンダーサーバー/ウィンドウサーバーは、iOS 6まではSpringBoard(ホームスクリーンUI)でした(それ以来、BackBoardとFrontBoardは、より多くのSpringBoardにコアOS関連の機能を吸収し、メインのオペレーティングシステムUIに重点を置いています。画面/ロック画面/通知センター/コントロールセンター/アプリスイッチャーなど)。

UIKitのメインレンダーランループの疑似コードは、これに近いと思われます。また、UIKitのアーキテクチャはできる限り少ない作業を行うように設計されているため、これはフレームごとに1回だけ実行されることを覚えておいてください(ネットワークコールやメインランループが管理する他のものとは異なります)。

_-(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()
} 
_

これは理にかなっています。1つのiOSアプリがフリーズしても、デバイス全体をフリーズすることはできません。実際、2つのアプリを並べて実行しているiPadでこれを実証できます。一方をフリーズさせても、もう一方は影響を受けません。

これらは、私が作成して両方に同じコードを貼り付けた2つの空のアプリテンプレートです。どちらも、画面中央のラベルに現在の時刻が表示されます。フリーズを押すと、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");
    }
}
_

これが結果です:

実際、私たちは1つ上に行くことができます。

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はディスプレイに触れることができず、レイヤーと更新をフレームごとにウィンドウサーバーに送信します
  • メインスレッドをブロックすると、変更がウィンドウサーバーに送信されないため、表示されません。
18
Kyle Howells

以下で機能する3つの方法。 1つ目は、サブビューがコントローラーを直接追加していても、それがビューコントローラーに直接追加されていない場合に機能させることができます。2つ目は、カスタムビューです。 1000のサブビューに加えて連続的に表示されるため、この継続的なプロセスがディスプレイをフリーズさせます。状況によっては、childviewcontrollerビューを追加して、viewDidLayoutSubviews()が終了したときに通知を投稿できますが、これがあなたのユースケースに適しているかどうかはわかりません。追加されたビューコントローラービューで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
            })
        }
    }
}

または、EVENクレイジーな方法とおそらくより良い方法があります。ある意味で自分の時間を保ち、フレームを落とさない方がいいときにコールバックする独自のビューを作成します。これは洗練されていませんが、機能します。

completion 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})
1
agibson007

それは一種の解決策です。しかし、それはエンジニアリングではありません。

実際、そうです。遅延を追加することで、あなたは正確に実行したかったことを実行します。実行ループの完了とレイアウトの実行を許可し、それが完了するとすぐにメインスレッドに再び入ります。それは、実際、私の主な用途 of delayの1つです。 (ゼロのdelayを使用することもできます。)

1
matt

NSNotificationを使用して、必要な効果を達成します。

まず、メインビューにオブザーバーを登録し、オブザーバーハンドラーを作成します。

次に、これらのすべてのA、B、C ...オブジェクトを、たとえばself performSelectorInBackgroundによって別のスレッド(たとえば、バックグラウンド)で初期化します。

次に、サブビューからの通知を投稿し、最後に-performSelectorOnMainThreadを使用して、必要な遅延を付けてサブビューを希望の順序で追加します。
コメントの質問に答えるために、画面に表示されたUIViewControllerがあるとします。このオブジェクト-議論のポイントではなく、コードの配置場所を決定し、ビューの外観を制御できます。コードはUIViewControllerオブジェクト用です(つまり、それ自体です)。ビュー-親ビューと見なされるいくつかのUIViewオブジェクト。 ViewN-サブビューの1つ。後でスケーリングできます。

[[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];
}

このハンドラーを使用すると、メッセージを受信し、UIViewobjectを親ビューに配置できます。奇妙に見えますが、要点は、メインスレッドで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メカニズムにより、実行スレッドのブロックを回避できます。

0
ETech