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

[WIP DNR] Cache Measurements & Layouts Across Updates #239

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 53 additions & 12 deletions BlueprintUI/Sources/BlueprintView/BlueprintView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ public final class BlueprintView: UIView {
/// A cache passed to elements which they can use to request a prototype view for measuring
/// their contents. See ``LayoutContext/measure(_:using:measure:)`` for more.
private let measurementViews : LayoutContext.MeasurementViews = .init()

/// The live, tracked state for each element in the element tree.
private let rootState : RootElementState = .init(name: "BlueprintView")

/// A base environment used when laying out and rendering the element tree.
///
Expand All @@ -51,6 +54,8 @@ public final class BlueprintView: UIView {
/// environment, the values from this environment will take priority over the inherited environment.
public var environment: Environment {
didSet {
guard oldValue != self.environment else { return }

setNeedsViewHierarchyUpdate()
invalidateIntrinsicContentSize()
}
Expand All @@ -74,11 +79,21 @@ public final class BlueprintView: UIView {
invalidateIntrinsicContentSize()
}
}

/// If cross-layout path measurement and layout caching is enabled.
/// This value defaults to `true`. You should usually leave this property enabled.
///
/// The only time you should disable it is if your `BlueprintView` is being used
/// as a prototype/measurement view, where caching layout related information
/// longer than needed may result in undesirable outcomes, such as objects being
/// retained longer than expected.
public var isLayoutCachingEnabled : Bool = true

/// The root element that is displayed within the view.
public var element: Element? {
didSet {
// Minor performance optimization: We do not need to update anything if the element remains nil.
// Minor performance optimization:
// We do not need to update anything if the element remains nil.
if oldValue == nil && self.element == nil {
return
}
Expand Down Expand Up @@ -170,14 +185,18 @@ public final class BlueprintView: UIView {
)
}

let environment = self.makeEnvironment()

let root = RootElementState(name: "BlueprintView<\(type(of:element))>.sizeThatFits")
root.update(with: element, in: environment)

return element.content.measure(
in: measurementConstraint(with: size),
with: .init(
environment: self.makeEnvironment(),
measurementCache: .init(),
environment: environment,
measurementViews: self.measurementViews
),
cache: CacheFactory.makeCache(name: "sizeThatFits:\(type(of: element))")
states: root.root!
)
}

Expand All @@ -201,14 +220,18 @@ public final class BlueprintView: UIView {
constraint = SizeConstraint(width: bounds.width)
}

let environment = self.makeEnvironment()

let root = RootElementState(name: "BlueprintView<\(type(of:element))>.intrinsicContentSize")
root.update(with: element, in: environment)

return element.content.measure(
in: constraint,
with: .init(
environment: self.makeEnvironment(),
measurementCache: .init(),
environment: environment,
measurementViews: self.measurementViews
),
cache: CacheFactory.makeCache(name: "intrinsicContentSize:\(type(of: element))")
states: root.root!
)
}

Expand Down Expand Up @@ -254,16 +277,17 @@ public final class BlueprintView: UIView {
assert(!isInsideUpdate, "Reentrant updates are not supported in BlueprintView. Ensure that view events from within the hierarchy are not synchronously triggering additional updates.")
isInsideUpdate = true

self.rootState.root?.viewSizeChanged(from: lastViewHierarchyUpdateBounds.size, to: bounds.size)

needsViewHierarchyUpdate = false
lastViewHierarchyUpdateBounds = bounds

let start = Date()
Logger.logLayoutStart(view: self)

let environment = self.makeEnvironment()

/// Grab view descriptions
let viewNodes = self.calculateNativeViewNodes(in: environment)

let viewNodes = self.calculateNativeViewNodes(in: environment, states: self.rootState)

let measurementEndDate = Date()
Logger.logLayoutEnd(view: self)
Expand Down Expand Up @@ -292,6 +316,10 @@ public final class BlueprintView: UIView {
Logger.logViewUpdateEnd(view: self)
let viewUpdateEndDate = Date()

if self.isLayoutCachingEnabled == false {
self.rootState.root?.recursiveClearAllCachedData()
}

hasUpdatedViewHierarchy = true

isInsideUpdate = false
Expand All @@ -309,14 +337,27 @@ public final class BlueprintView: UIView {
/// Performs a full measurement and layout pass of all contained elements, and then collapses the nodes down
/// into `NativeViewNode`s, which represent only the view-backed elements. These view nodes
/// are then pushed into a `NativeViewController` to update the on-screen view hierarchy.
private func calculateNativeViewNodes(in environment : Environment) -> [(path: ElementPath, node: NativeViewNode)] {
private func calculateNativeViewNodes(
in environment : Environment,
states : RootElementState
) -> [(path: ElementPath, node: NativeViewNode)]
{
guard let element = self.element else { return [] }

self.rootState.root?.prepareForLayout()

defer {
self.rootState.root?.finishedLayout()
}

states.update(with: element, in: environment)

let laidOutNodes = LayoutResultNode(
root: element,
layoutAttributes: .init(frame: self.bounds),
environment: environment,
measurementViews: self.measurementViews
measurementViews: self.measurementViews,
states: states.root!
)

return laidOutNodes.resolve()
Expand Down
144 changes: 144 additions & 0 deletions BlueprintUI/Sources/Element/ComparableElement.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
//
// ComparableElement.swift
// BlueprintUI
//
// Created by Kyle Van Essen on 7/3/21.
//

import Foundation


/// An `ComparableElement` is an element which can cache its
/// measurements and layouts across layout passes in order to dramatically improve
/// performance of repeated layouts.
///
/// Many element types within Blueprint already conform to `ComparableElement`.
/// If your element can be easily compared for equality, consider making your
/// it conform to `ComparableElement` for improved performance across layouts.
public protocol ComparableElement : AnyComparableElement {

///
/// Indicates if the element is equivalent to the other provided element.
/// Return true if the elements are the same, or return false if something about
/// the element changed, such as its content or measurement attributes.
///
/// Note that even if this method returns true, the `ViewDescription`
/// backing the element will still be re-applied to the on-screen view.
///
/// If your element conforms to `Equatable`, this method is synthesized automatically.
///
func isEquivalent(to other : Self) throws -> Bool

/// Return true if the layout and measurement caches should be cleared from the given size change.
func willSizeChangeAffectLayout(from : CGSize, to : CGSize) -> Bool

///
var appliesViewDescriptionIfEquivalent : Bool { get }
}


public protocol KeyPathComparableElement : ComparableElement {

static var isEquivalent : IsEquivalent<Self> { get }
}


/// A type-erased version of `ComparableElement`, allowing the comparison
/// of two arbitrary elements, and allowing direct access to methods, without self or associated type constraints.
public protocol AnyComparableElement : Element {

/// Returns true if the two elements are the same type, and are equivalent.
func anyIsEquivalent(to other : AnyComparableElement) throws -> Bool

/// Return true if the layout and measurement caches should be cleared from the given size change.
func willSizeChangeAffectLayout(from : CGSize, to : CGSize) -> Bool

///
var appliesViewDescriptionIfEquivalent : Bool { get }
}


public extension ComparableElement where Self:Equatable {

func isEquivalent(to other : Self) -> Bool {
self == other
}
}


public extension ComparableElement where Self:KeyPathComparableElement {

func isEquivalent(to other: Self) throws -> Bool {
try Self.isEquivalent.compare(self, other)
}
}


public extension ComparableElement {

func anyIsEquivalent(to other: AnyComparableElement) throws -> Bool {
guard let other = other as? Self else { return false }

return try self.isEquivalent(to: other)
}

func willSizeChangeAffectLayout(from : CGSize, to : CGSize) -> Bool {
true
}

var appliesViewDescriptionIfEquivalent : Bool {
true
}
}


public enum ComparableElementNotEquivalent : Error {
case nonEquivalentValue(Any, Any, AnyKeyPath)
}


public extension Array {

func isEquivalent(to other : Self, using compare : (Element, Element) throws -> Bool) rethrows -> Bool {

guard self.count == other.count else { return false }

for index in 0..<self.count {
let lhs = self[index]
let rhs = other[index]

if try compare(lhs, rhs) == false {
return false
}
}

return true
}
}

public extension Array where Self.Element == BlueprintUI.Element {

func isEquivalent(to other : Self) throws -> Bool {

guard self.count == other.count else { return false }

for index in 0..<self.count {
let lhs = self[index]
let rhs = other[index]

guard
let lhs = lhs as? AnyComparableElement,
let rhs = rhs as? AnyComparableElement
else {
return false
}


if try lhs.anyIsEquivalent(to: rhs) == false {
return false
}
}

return true
}
}
Loading