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

Change how we cache sizing across sizeThatFits and autolayout #277

Merged
merged 6 commits into from
Nov 19, 2021

Conversation

kyleve
Copy link
Collaborator

@kyleve kyleve commented Nov 5, 2021

@n8chur recently added support for caching intrinsic content size, which made me wonder: Why don't we do this everywhere? This will provide benefits for any existing impl which calls sizeThatFits or relies on the other autolayout methods, since we now only do an expensive re-measure when content has actually changed.

Changes

  • sizeThatFits now also caches results.
  • intrinsicContentSize is now backed by sizeThatFits.
  • Added systemLayoutSizeFitting... overrides to call through to sizeThatFits.
  • We invalidate sizing when environment, safe area, etc, change.

One more thing!

The old impl of intrinsicContentSize would use an unconstrained width if we had a new element, but had not yet laid out. This seems really wrong to me, since it was very inconsistent and hard to predict, to the point of non-determinism: Had you laid out yet? Great! Your constraint is the view width! Had you not? Uhh, you have no width constraint. This is a behaviour change, and there was value to the old behaviour, so it might make sense to add an optional/mode for this. Thoughts?

@kyleve kyleve force-pushed the kve/sizing-caching-changes branch from 6d085fb to 9e22cfd Compare November 5, 2021 02:11
@@ -226,18 +294,20 @@ public final class BlueprintView: UIView {
setNeedsViewHierarchyUpdate()
}

private func performUpdate() {
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

This was called in exactly one place and only did this, so I removed it.

@kyleve kyleve force-pushed the kve/sizing-caching-changes branch from 9e22cfd to af9e981 Compare November 5, 2021 02:20
Copy link
Contributor

@kylebshr kylebshr left a comment

Choose a reason for hiding this comment

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

Does this automatically solve our perf issues with preferredContentSize?

BlueprintUI/Sources/BlueprintView/BlueprintView.swift Outdated Show resolved Hide resolved
BlueprintUI/Sources/BlueprintView/BlueprintView.swift Outdated Show resolved Hide resolved
@kyleve
Copy link
Collaborator Author

kyleve commented Nov 8, 2021

Does this automatically solve our perf issues with preferredContentSize?

Ish. Kinda. But not enough. In my testing, it seems to help with about 50% of the excess perf, since we now cache some calls to sizeThatFits, but because in many workflows, elements are re-rendered pretty often, the cache is still evicted too frequently. Even in the best case, this still only will get us down to 1 extra measurement, which can be 200-500ms+ for complex screens.

Comment on lines +244 to +248
if bounds.width == 0 {
return .unconstrained
} else {
return .init(width: bounds.width)
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Should we also be doing this for height?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

We'd have to switch on the content hugging priority I think; since without a passed in constraint or layout priority, we'd have to base it on those.

@n8chur
Copy link
Contributor

n8chur commented Nov 8, 2021

One more thing!

The old impl of intrinsicContentSize would use an unconstrained width if we had a new element, but had not yet laid out. This seems really wrong to me, since it was very inconsistent and hard to predict, to the point of non-determinism: Had you laid out yet? Great! Your constraint is the view width! Had you not? Uhh, you have no width constraint. This is a behaviour change, and there was value to the old behaviour, so it might make sense to add an optional/mode for this. Thoughts?

I'm not sure I follow—what is the functionality you'd be missing out on by omitting this mode? Couldn't you still get this same behavior by attempting a layout with a 0-width frame with the changes you have in this PR? I'm not sure I understand the value of the current state over what's here.

@kyleve
Copy link
Collaborator Author

kyleve commented Nov 8, 2021

I'm not sure I follow—what is the functionality you'd be missing out on by omitting this mode?

I think (unintentionally or not), this previously allowed Swift previews to always display the natural size of an embedded blueprint view. @bencochran added the existing behaviour and could speak more to it

…anges

* origin/main:
  Fix size assertion grammar.
  Prep 0.32.0
  Update BlueprintUI/Sources/Layout/Builder.swift
  Add support for for loop and available checks in result builders.
  Update changelog
  Break a retain cycle in posts demo
  Break retain cycle in FocusState storage
  chore(ios): update changelog
  chore(ios): update tests
  fix(ios): adjust keyboard correctly
  Improve assertion messages for unconstrained values.
  Fix test warnings by adding .self references
  Go with 0.31.0 instead
  Prep 0.30.1
  Comment in new methods on result builders
  Update CHANGELOG
  Improve error messages for result builders with optionals
@kyleve
Copy link
Collaborator Author

kyleve commented Nov 18, 2021

This should be good for a re-review.

Copy link
Collaborator

@watt watt left a comment

Choose a reason for hiding this comment

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

I'm a little concerned that the interactions between various functions, caches, and cache invalidations are intertwined and hard to reason about. Perhaps it was just as bad before and I just got used to it.

@@ -70,6 +70,11 @@ public struct Environment {
}
}

/// If the `Environment` contains any values.
public var isEmpty: Bool {
Copy link
Collaborator

Choose a reason for hiding this comment

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

I don't think this should be public. An Environment is never really "empty", because every key has a default. The presence of overrides is intentionally opaque.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Hmm, true; though in that case there's no values stored; so in the cases where this is used the default values would always be the same anyway. I guess this probably isn't useful outside of Blueprint though so I can make it internal.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Done

Comment on lines 279 to 291
private func setNeedsViewHierarchyUpdate() {
guard !needsViewHierarchyUpdate else { return }

invalidateIntrinsicContentSize()
sizesThatFit.removeAll()

if needsViewHierarchyUpdate { return }

needsViewHierarchyUpdate = true

/// We currently rely on CA's layout pass to actually perform a hierarchy update.
/// We use `UIView`'s layout pass to actually perform a hierarchy update.
/// If a manual update is required, call `layoutIfNeeded()`.
setNeedsLayout()
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

This seems like an unexpected set of responsibilities to me. Why would setNeedsViewHierarchyUpdate have a cache invalidation side effect, rather than just setting a flag? It could get called a lot before the render pass actually runs. Some docs, maybe?

Copy link
Collaborator Author

@kyleve kyleve Nov 18, 2021

Choose a reason for hiding this comment

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

I had them separated before, but Kyle rightly pointed out that we called the two methods in all the same places – so not unifying to one method was less error-prone for future callsites. Maybe worth renaming this method instead? Or something...

public override func invalidateIntrinsicContentSize() {
cachedIntrinsicContentSize = nil
super.invalidateIntrinsicContentSize()
return sizeThatFits(constraint.maximum)
Copy link
Collaborator

Choose a reason for hiding this comment

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

I don't like the idea of intentionally round-tripping SizeConstraint.unconstrained through .greatestFiniteMagnitude back to SizeConstraint.unconstrained. Can you extract out the part of measurement that takes in a constraint?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Sure; will do; though note that sizeThatFits already handles this case because people call sizeThatFits with tons of weird "unconstrained" values.

@@ -36,7 +36,7 @@ public final class BlueprintView: UIView {

private let rootController: NativeViewController

private var cachedIntrinsicContentSize: CGSize? = nil
private var sizesThatFit: [SizeConstraint: CGSize] = [:]
Copy link
Collaborator

Choose a reason for hiding this comment

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

Can you use the CacheTree for this? Caching multiple measurements/layouts on the same elements was one of the follow-up goals from #209, and this seems like it does exactly that!

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

We could; but those are currently all short-lived because they depend on the exact element state at call time (eg, the subtrees are stored by element index); so it seemed to make more sense to introduce a top-level cache that holds "inert" measurement values. Eg, we'd still need to invalidate the CacheTree in all the same places we do here, and keeping one around seems to potentially invite mis-use when all we really care about here is the top-level size; not digging into child sizes.

Copy link
Collaborator

Choose a reason for hiding this comment

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

we'd still need to invalidate the CacheTree in all the same places we do here

Yep, that's exactly what I had in mind. The CacheTree has an advantage in that a top-level miss can still have nested cache hits.

This is fine as-is though; I'll explore converting to CacheTree later.

BlueprintUI/Sources/BlueprintView/BlueprintView.swift Outdated Show resolved Hide resolved

/// Measures the size needed to display the view within the given `SizeConstraint`.
/// by measuring the current `element` of the `BlueprintView`.
public func sizeThatFits(_ constraint: SizeConstraint) -> CGSize {
Copy link
Collaborator

Choose a reason for hiding this comment

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

internal?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

It felt reasonable to expose publicly; since the SizeConstraint through its types (eg, Axis.unconstrained) is clearer about how to use the provided SizeConstraint vs the regular CGSize magic numbers for unconstrained measurements.

Copy link
Contributor

Choose a reason for hiding this comment

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

Yeah, I like this for the same reason as the original suggestion to make it explicit. That is, for consumers who know they have a blueprint view, they can do unconstrained measurement without jumping through greatest finite magnitude weirdness.

@kyleve kyleve merged commit 738aecd into main Nov 19, 2021
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants