TokamakUI / Tokamak

SwiftUI-compatible framework for building browser apps with WebAssembly and native apps for other platforms
Apache License 2.0
2.62k stars 111 forks source link

Add `Layout` protocol for FiberReconciler #498

Closed carson-katri closed 2 years ago

carson-katri commented 2 years ago

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:

carson-katri commented 2 years ago

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 commented 2 years ago

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: ```swift struct TestView: View { let items: Int @State var update = -1 var body: some View { VStack { ForEach(0..
`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 commented 2 years ago

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 commented 2 years ago

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 commented 2 years ago

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.

carson-katri commented 2 years ago

Not sure how accurate this is, but I ran the same ForEach benchmark on native SwiftUI in the iOS 16 simulator, and it was actually slower than this implementation and crashed before it got to 10,000 views.

Screen Shot 2022-06-23 at 3 46 01 PM

This tests the first render for a SwiftUI view though, not an update like the other tests. I think an update will be significantly faster than this, but I'm not sure how to benchmark that. I used the below snippet to run the test in SwiftUI:

for i in stride(from: 1, to: 10000, by: 100) {
  let start = DispatchTime.now()
  let controller = UIHostingController(rootView: TestView(items: i))
  _ = controller.view.intrinsicContentSize // Access the size to trigger SwiftUI layout.
  let end = DispatchTime.now()
  graph.append(end.uptimeNanoseconds - start.uptimeNanoseconds)
}
MaxDesiatov commented 2 years ago

Sorry for the delay with the review. I have it on my list and will get to it during this weekend.

carson-katri commented 2 years ago

Here's my attempt at measuring an update (not a first render) in SwiftUI. This is a more accurate comparison, but still not perfect:

Screen Shot 2022-06-24 at 10 58 45 AM

Also, no rush on the review!

carson-katri commented 2 years ago

ReconcilePass was copying LayoutSubviews each time a subview was added. Fixing that got the update down to <400ms!

Screen Shot 2022-06-24 at 11 53 22 AM
carson-katri commented 2 years ago

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.