Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Layout protocol for FiberReconciler #498

Merged
merged 39 commits into from
Jun 27, 2022
Merged

Conversation

carson-katri
Copy link
Member

@carson-katri carson-katri commented Jun 16, 2022

This adds support for the new Layout protocol introduced in iOS 16 and aligned releases.

The FiberReconciler's layout code has been refactored to use this protocol in place of the previous LayoutComputer implementation. In order to match the behavior of native SwiftUI, I split the reconciling and layout passes into two separate loops. The reason for this change is that all of the LayoutSubviews must be collected prior to performing any layout computations. However, the previous LayoutComputer approach built the size incrementally as the Views appeared.

VStack/HStack, and other LayoutComputers have been rewritten to use the Layout protocol. I specifically worked on getting the stacks to match SwiftUI as closely as possible, other layouts may need tweaking.

TODOs:

  • Documentation on layout pass
  • Compare benchmarks with previous layout code
  • Maybe: Refactor FiberReconciler.swift to split the reconciliation and layout loops into separate files
  • Cleanup filenames referencingLayoutComputer
  • Add tests for Layout protocol (possibly test against the computed frames)
  • Allow renderers and primitive views to customize ViewSpacing (for instance, Text seems to prefer no spacing on the top/bottom, but default spacing on the leading/trailing edges)
  • Background layout does not respect alignment
  • Layout.Cache does not actually cache between runs
  • Support LayoutValueKey

@carson-katri carson-katri added SwiftUI compatibility Tokamak API differences with SwiftUI Fiber Changes related to the FiberReconciler or a FiberRenderer labels Jun 16, 2022
@carson-katri
Copy link
Member Author

Here are the results of the TokamakCoreBenchmark with layout enabled:

Layout protocol

name                             time      std        iterations
----------------------------------------------------------------
update wide (StackReconciler)    46.404 ms ±   1.92 %         30
update wide (FiberReconciler)    42.673 ms ±   3.23 %         33
update narrow (StackReconciler)  45.874 ms ±   2.04 %         31
update narrow (FiberReconciler)  42.407 ms ±   1.84 %         33
update deep (StackReconciler)    17.447 ms ±   2.48 %         80
update deep (FiberReconciler)     7.656 ms ±   8.38 %        177
update shallow (StackReconciler)  8.899 ms ±   1.93 %        157
update shallow (FiberReconciler)  4.995 ms ±   4.32 %        277

LayoutComputer implementation

name                             time      std        iterations
----------------------------------------------------------------
update wide (StackReconciler)    46.891 ms ±   1.67 %         30
update wide (FiberReconciler)    33.054 ms ±   1.82 %         42
update narrow (StackReconciler)  46.437 ms ±   2.98 %         30
update narrow (FiberReconciler)  33.150 ms ±   3.61 %         42
update deep (StackReconciler)    17.587 ms ±   1.54 %         80
update deep (FiberReconciler)     6.135 ms ±   3.47 %        227
update shallow (StackReconciler)  8.952 ms ±   1.56 %        156
update shallow (FiberReconciler)  3.854 ms ±   3.17 %        363

@carson-katri
Copy link
Member Author

Graphs! I wanted to visualize the performance of these different methods to see how they compare more visually (easier on the eyes than a bunch of nanosecond numbers 😅).

x-axis is # of views, y-axis is time in ms to update (not the first render).

`ForEach` Test

Rendering from 1-5000 views in a VStack with ForEach. This tests an update, not the first render.
Here is the View used to test:

struct TestView: View {
  let items: Int
  @State
  var update = -1

  var body: some View {
    VStack {
      ForEach(0..<items) {
        if update == $0 {
          Text("Updated")
        } else {
          Text("\($0)")
        }
      }
      Button("Update") {
        update = items - 1
      }
    }
  }
}
Screen Shot 2022-06-17 at 6 22 17 PM
`ForEach` Test (first 30 values) Screen Shot 2022-06-17 at 6 23 00 PM
`ForEach` Test (first render) This is the same as the `ForEach` test, but it only tests the first render, not an update. Screen Shot 2022-06-17 at 6 47 55 PM
`RecursiveView` Test This test doesn't stress the layout engine because the number of visible Views does not change, it only renders a `Text` at the end of the recursive chain. This also tests an update, not the first render. Screen Shot 2022-06-17 at 6 39 01 PM

@carson-katri
Copy link
Member Author

carson-katri commented Jun 18, 2022

I added a few things that have optimized the Layout protocol implementation for the "ForEach Test" from above. Specifically, I attempted to optimize array capacity by having Views that know their child counts implement a _viewChildrenCount method, which gives us the information needed to reserveCapacity on the LayoutSubviews array.

The cache is now persisted as well, although currently I don't think any built-in Layout implementations would benefit much from this.

Actually, seems like 2f018a2 made the biggest difference. I didn't realize I hadn't run the benchmarks against that commit. I reverted the reserveCapacity code, because I do think it has potential to hurt performance more than help. The cache updates should still help in some scenarios (although I need to test cache invalidation against SwiftUI further to ensure the implementation matches).

Here is the new graph:

Screen Shot 2022-06-17 at 10 59 49 PM

@carson-katri
Copy link
Member Author

I've added a new TokamakLayoutTests target, which compares native SwiftUI to custom layout implementations via image.

I only use grayscale colors in those tests because the RGB colors don't quite match between the images, and we really just want to test the actual view geometry.

@carson-katri
Copy link
Member Author

I've adjusted the reconciler to perform updates from the root node when layout is enabled, because otherwise the Views and children are not collected and the parents at the top of the hierarchy don't correctly place their children.

If anyone has a better solution to this, let me know.

@@ -0,0 +1,339 @@
// Copyright 2022 Tokamak contributors
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this file be named ReconcilerPass.swift, not ReconcilePass.swift?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The filename matches the struct in this file. Unless you think the struct should also be called Reconcile**r**Pass not ReconcilePass.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I think it's a little bit confusing that it conforms to FiberReconcilerPass, but the name of the struct uses verb form, not noun form. I also would expect the name of the struct to be more specific than the name of the struct, but I don't have a good suggestion for a new name right now.

Copy link
Collaborator

@MaxDesiatov MaxDesiatov left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems legit, great work!

I only have formatting and doc comment nits.

Comment on lines +229 to +247
public func render<A: App>(_ app: A) -> String {
_ = FiberReconciler(self, app)
return """
<!doctype html>
<html>
\(rootElement.description)
</html>
"""
}

public func render<V: View>(_ view: V) -> String {
_ = FiberReconciler(self, view)
return """
<!doctype html>
<html>
\(rootElement.description)
</html>
"""
}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

will the presence of doctype here make #489 obsolete?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe that PR deals with the stack reconciler StaticHTMLRenderer, not this fiber reconciler one.
Although this could be enhanced to support meta tags in the head and such once preferences are supported in the fiber reconciler.

@carson-katri
Copy link
Member Author

I made a few changes to (1) move the LayoutSubview logic into the type instead of in the ReconcilePass, and (2) remove layout values from Views that don't participate in layout.

I was hoping this could help with stack overflows when using dynamic layout. But now I think the only real way to solve this is to make the calls into LayoutSubview iterative. I have not been able to figure out how to do this yet. Currently the FiberReconciler without dynamic layout enabled can handle ~400 nested views without overflowing, while dynamic layout can only handle ~15 unless you increase the stack size. For now, we can just use recursion though.

Copy link
Collaborator

@MaxDesiatov MaxDesiatov left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great, thanks!

@carson-katri carson-katri merged commit d78ab20 into main Jun 27, 2022
@carson-katri carson-katri deleted the fiber/layout-protocol branch June 27, 2022 12:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Fiber Changes related to the FiberReconciler or a FiberRenderer SwiftUI compatibility Tokamak API differences with SwiftUI
Development

Successfully merging this pull request may close these issues.

None yet

2 participants