diff --git a/BlueprintUI/Sources/BlueprintView/BlueprintView.swift b/BlueprintUI/Sources/BlueprintView/BlueprintView.swift index c934db457..321ea772a 100644 --- a/BlueprintUI/Sources/BlueprintView/BlueprintView.swift +++ b/BlueprintUI/Sources/BlueprintView/BlueprintView.swift @@ -40,6 +40,8 @@ public final class BlueprintView: UIView { private var sizesThatFit: [SizeConstraint: CGSize] = [:] + private var hostingViewContext = Environment.HostingViewContextKey.defaultValue + /// A base environment used when laying out and rendering the element tree. /// /// Some keys will be overridden with the traits from the view itself. Eg, `windowSize`, `safeAreaInsets`, etc. @@ -52,6 +54,13 @@ public final class BlueprintView: UIView { didSet { // Shortcut: If both environments were empty, nothing changed. if oldValue.isEmpty && environment.isEmpty { return } + // Shortcut: If there are no changes to the environment, then, well, nothing changed. + if let layoutMode, layoutMode.options.skipUnneededSetNeedsViewHierarchyUpdates && oldValue.isCacheablyEquivalent( + to: environment, + in: .all + ) { + return + } setNeedsViewHierarchyUpdate() } @@ -86,6 +95,13 @@ public final class BlueprintView: UIView { if oldValue == nil && element == nil { return } + if let layoutMode, layoutMode.options.skipUnneededSetNeedsViewHierarchyUpdates, let crossLayoutCacheable = element as? CrossLayoutCacheable, crossLayoutCacheable.isCacheablyEquivalent( + to: oldValue as? CrossLayoutCacheable, + in: .all + ) { + return + } + hostingViewContext = Environment.HostingViewContextKey.defaultValue Logger.logElementAssigned(view: self) @@ -148,6 +164,7 @@ public final class BlueprintView: UIView { self.element = element self.environment = environment + self.environment.hostingViewContext = hostingViewContext rootController = NativeViewController( node: NativeViewNode( @@ -542,9 +559,13 @@ public final class BlueprintView: UIView { environment.layoutMode = layoutMode } + environment.hostingViewContext = hostingViewContext + return environment } + + private func handleAppeared() { rootController.traverse { node in node.onAppear?() diff --git a/BlueprintUI/Sources/CrossLayoutCaching/Cache/CacheKey.swift b/BlueprintUI/Sources/CrossLayoutCaching/Cache/CacheKey.swift index 89d735e04..2a101acf9 100644 --- a/BlueprintUI/Sources/CrossLayoutCaching/Cache/CacheKey.swift +++ b/BlueprintUI/Sources/CrossLayoutCaching/Cache/CacheKey.swift @@ -21,6 +21,7 @@ import Foundation /// set { self[WidgetCountsKey.self] = newValue } /// } /// } +/// public protocol CrossLayoutCacheKey { associatedtype Value static var emptyValue: Self.Value { get } diff --git a/BlueprintUI/Sources/Element/ElementContent.swift b/BlueprintUI/Sources/Element/ElementContent.swift index aa3ac6bfd..d5dea9d75 100644 --- a/BlueprintUI/Sources/Element/ElementContent.swift +++ b/BlueprintUI/Sources/Element/ElementContent.swift @@ -249,6 +249,17 @@ extension ElementContent { storage = MeasurableStorage(measurer: measureFunction) } + /// Initializes a new `ElementContent` with no children that delegates to the provided measure function. + /// + /// - parameter cacheKey: If present, measureFunction will attempt to cache sizing based on the path of the node and the cacheKey. + /// - parameter measureFunction: How to measure the `ElementContent` in the given `SizeConstraint` and `Environment`. + public init( + cacheKey: some CrossLayoutCacheable, + measureFunction: @escaping (SizeConstraint, Environment) -> CGSize + ) { + storage = MeasurableStorage(cacheKey: cacheKey, measurer: measureFunction) + } + /// Initializes a new `ElementContent` with no children that uses the provided intrinsic size for measuring. public init(intrinsicSize: CGSize) { self = ElementContent(measureFunction: { _ in intrinsicSize }) diff --git a/BlueprintUI/Sources/Element/MeasurableStorage.swift b/BlueprintUI/Sources/Element/MeasurableStorage.swift index 1fd389230..8ee18f4ea 100644 --- a/BlueprintUI/Sources/Element/MeasurableStorage.swift +++ b/BlueprintUI/Sources/Element/MeasurableStorage.swift @@ -7,7 +7,18 @@ struct MeasurableStorage: ContentStorage { let childCount = 0 + let cacheKey: AnyCrossLayoutCacheable? let measurer: (SizeConstraint, Environment) -> CGSize + + init(cacheKey: some CrossLayoutCacheable, measurer: @escaping (SizeConstraint, Environment) -> CGSize) { + self.cacheKey = AnyCrossLayoutCacheable(cacheKey) + self.measurer = measurer + } + + init(measurer: @escaping (SizeConstraint, Environment) -> CGSize) { + cacheKey = nil + self.measurer = measurer + } } extension MeasurableStorage: CaffeinatedContentStorage { @@ -17,7 +28,19 @@ extension MeasurableStorage: CaffeinatedContentStorage { environment: Environment, node: LayoutTreeNode ) -> CGSize { - measurer(proposal, environment) + guard environment.layoutMode.options.measureableStorageCache, let cacheKey else { + return measurer(proposal, environment) + } + + let key = MeasurableSizeKey(path: node.path, proposedSizeConstraint: proposal) + return environment.hostingViewContext.measurableStorageCache.retrieveOrCreate( + key: key, + environment: environment, + validationValue: cacheKey, + context: .elementSizing, + ) { environment in + measurer(proposal, environment) + } } func performCaffeinatedLayout( @@ -28,3 +51,40 @@ extension MeasurableStorage: CaffeinatedContentStorage { [] } } + +extension MeasurableStorage { + + fileprivate struct MeasurableSizeKey: Hashable { + + let path: String + let proposedSizeConstraint: SizeConstraint + + func hash(into hasher: inout Hasher) { + path.hash(into: &hasher) + proposedSizeConstraint.hash(into: &hasher) + } + + } + +} + +extension HostingViewContext { + + private struct MeasurableStorageCacheKey: CrossLayoutCacheKey { + static var emptyValue = EnvironmentAndValueValidatingCache< + MeasurableStorage.MeasurableSizeKey, + CGSize, + AnyCrossLayoutCacheable + >() + } + + fileprivate var measurableStorageCache: EnvironmentAndValueValidatingCache< + MeasurableStorage.MeasurableSizeKey, + CGSize, + AnyCrossLayoutCacheable + > { + get { self[MeasurableStorageCacheKey.self] } + set { self[MeasurableStorageCacheKey.self] = newValue } + } + +} diff --git a/BlueprintUI/Sources/Layout/LayoutMode.swift b/BlueprintUI/Sources/Layout/LayoutMode.swift index da4f7f2d5..53b892c5a 100644 --- a/BlueprintUI/Sources/Layout/LayoutMode.swift +++ b/BlueprintUI/Sources/Layout/LayoutMode.swift @@ -11,7 +11,7 @@ import Foundation /// Changing the default will cause all instances of ``BlueprintView`` to be invalidated, and re- /// render their contents. /// -public struct LayoutMode: Equatable { +public struct LayoutMode: Hashable { public static var `default`: Self = .caffeinated { didSet { guard oldValue != .default else { return } @@ -41,15 +41,29 @@ public struct LayoutMode: Equatable { extension LayoutMode: CustomStringConvertible { public var description: String { - switch (options.hintRangeBoundaries, options.searchUnconstrainedKeys) { - case (true, true): - return "Caffeinated (hint+search)" - case (true, false): - return "Caffeinated (hint)" - case (false, true): - return "Caffeinated (search)" - case (false, false): + var optionsDescription: [String] = [] + if options.hintRangeBoundaries { + optionsDescription.append("hint") + } + if options.searchUnconstrainedKeys { + optionsDescription.append("search") + } + if options.measureableStorageCache { + optionsDescription.append("measureableStorageCache") + } + if options.stringNormalizationCache { + optionsDescription.append("stringNormalizationCache") + } + if options.skipUnneededSetNeedsViewHierarchyUpdates { + optionsDescription.append("needsViewHierarchyUpdates") + } + if options.labelAttributedStringCache { + optionsDescription.append("labelAttributedStringCache") + } + if optionsDescription.isEmpty { return "Caffeinated" + } else { + return "Caffeinated \(optionsDescription.joined(separator: "+"))" } } } diff --git a/BlueprintUI/Sources/Layout/LayoutOptions.swift b/BlueprintUI/Sources/Layout/LayoutOptions.swift index eb778295d..d10bad07e 100644 --- a/BlueprintUI/Sources/Layout/LayoutOptions.swift +++ b/BlueprintUI/Sources/Layout/LayoutOptions.swift @@ -4,12 +4,16 @@ import Foundation /// /// Generally these are only useful for experimenting with the performance profile of different /// element compositions, and you should stick with ``default``. -public struct LayoutOptions: Equatable { +public struct LayoutOptions: Hashable { /// The default configuration. public static let `default` = LayoutOptions( hintRangeBoundaries: true, - searchUnconstrainedKeys: true + searchUnconstrainedKeys: true, + measureableStorageCache: false, + stringNormalizationCache: false, + skipUnneededSetNeedsViewHierarchyUpdates: false, + labelAttributedStringCache: false ) /// Enables aggressive cache hinting along the boundaries of the range between constraints and @@ -22,8 +26,32 @@ public struct LayoutOptions: Equatable { /// Layout contract for correct behavior. public var searchUnconstrainedKeys: Bool - public init(hintRangeBoundaries: Bool, searchUnconstrainedKeys: Bool) { + /// Allows caching the results of `MeasurableStorage` `sizeThatFits`. + public var measureableStorageCache: Bool + + /// Caches results of AttributedLabel normalization process. + public var stringNormalizationCache: Bool + + /// Allows skipping calls to setNeedsViewHierarchyUpdates when updating Environment, if the environment is + /// equilvalent to the prior value. + public var skipUnneededSetNeedsViewHierarchyUpdates: Bool + + /// Caches MarketLabel attributed string generation + public var labelAttributedStringCache: Bool + + public init( + hintRangeBoundaries: Bool, + searchUnconstrainedKeys: Bool, + measureableStorageCache: Bool, + stringNormalizationCache: Bool, + skipUnneededSetNeedsViewHierarchyUpdates: Bool, + labelAttributedStringCache: Bool + ) { self.hintRangeBoundaries = hintRangeBoundaries self.searchUnconstrainedKeys = searchUnconstrainedKeys + self.measureableStorageCache = measureableStorageCache + self.stringNormalizationCache = stringNormalizationCache + self.skipUnneededSetNeedsViewHierarchyUpdates = skipUnneededSetNeedsViewHierarchyUpdates + self.labelAttributedStringCache = labelAttributedStringCache } } diff --git a/BlueprintUI/Tests/HintingSizeCacheTests.swift b/BlueprintUI/Tests/HintingSizeCacheTests.swift index fa38dbce6..08f4e28e5 100644 --- a/BlueprintUI/Tests/HintingSizeCacheTests.swift +++ b/BlueprintUI/Tests/HintingSizeCacheTests.swift @@ -17,7 +17,14 @@ final class HintingSizeCacheTests: XCTestCase { size: size, // we expect to hit each of the unique keys exactly once misses: boundaries, - options: .init(hintRangeBoundaries: false, searchUnconstrainedKeys: false) + options: .init( + hintRangeBoundaries: false, + searchUnconstrainedKeys: false, + measureableStorageCache: false, + stringNormalizationCache: false, + skipUnneededSetNeedsViewHierarchyUpdates: false, + labelAttributedStringCache: false + ) ) } @@ -45,7 +52,14 @@ final class HintingSizeCacheTests: XCTestCase { size: size, // we expect to miss the first key, deduce the other boundaries, and miss the off keys misses: [key] + offKeys, - options: .init(hintRangeBoundaries: true, searchUnconstrainedKeys: false) + options: .init( + hintRangeBoundaries: true, + searchUnconstrainedKeys: false, + measureableStorageCache: false, + stringNormalizationCache: false, + skipUnneededSetNeedsViewHierarchyUpdates: false, + labelAttributedStringCache: false + ) ) } @@ -67,7 +81,14 @@ final class HintingSizeCacheTests: XCTestCase { size: size, // we expect to hit only the first key, and range-match the second misses: [SizeConstraint(width: .atMost(200), height: .unconstrained)], - options: .init(hintRangeBoundaries: false, searchUnconstrainedKeys: true) + options: .init( + hintRangeBoundaries: false, + searchUnconstrainedKeys: true, + measureableStorageCache: false, + stringNormalizationCache: false, + skipUnneededSetNeedsViewHierarchyUpdates: false, + labelAttributedStringCache: false + ) ) assertMisses( @@ -78,7 +99,14 @@ final class HintingSizeCacheTests: XCTestCase { size: size, // we expect to hit only the first key, and range-match the second misses: [SizeConstraint(width: .unconstrained, height: .atMost(200))], - options: .init(hintRangeBoundaries: false, searchUnconstrainedKeys: true) + options: .init( + hintRangeBoundaries: false, + searchUnconstrainedKeys: true, + measureableStorageCache: false, + stringNormalizationCache: false, + skipUnneededSetNeedsViewHierarchyUpdates: false, + labelAttributedStringCache: false + ) ) let keys = [ @@ -92,7 +120,14 @@ final class HintingSizeCacheTests: XCTestCase { size: size, // we do not search the double-unconstrained key, so these are all misses misses: keys, - options: .init(hintRangeBoundaries: false, searchUnconstrainedKeys: true) + options: .init( + hintRangeBoundaries: false, + searchUnconstrainedKeys: true, + measureableStorageCache: false, + stringNormalizationCache: false, + skipUnneededSetNeedsViewHierarchyUpdates: false, + labelAttributedStringCache: false + ) ) } @@ -108,7 +143,14 @@ final class HintingSizeCacheTests: XCTestCase { size: size, // we will miss the first key, but can then range-match the others off of hinted boundary keys misses: [.unconstrained], - options: .init(hintRangeBoundaries: true, searchUnconstrainedKeys: true) + options: .init( + hintRangeBoundaries: true, + searchUnconstrainedKeys: true, + measureableStorageCache: false, + stringNormalizationCache: false, + skipUnneededSetNeedsViewHierarchyUpdates: false, + labelAttributedStringCache: false + ) ) } diff --git a/BlueprintUI/Tests/StackTests.swift b/BlueprintUI/Tests/StackTests.swift index cecb44b3f..2e88dbf1b 100644 --- a/BlueprintUI/Tests/StackTests.swift +++ b/BlueprintUI/Tests/StackTests.swift @@ -1427,6 +1427,10 @@ extension VerticalAlignment { extension LayoutOptions { static let optimizationsDisabled: Self = .init( hintRangeBoundaries: false, - searchUnconstrainedKeys: false + searchUnconstrainedKeys: false, + measureableStorageCache: false, + stringNormalizationCache: false, + skipUnneededSetNeedsViewHierarchyUpdates: false, + labelAttributedStringCache: false ) } diff --git a/BlueprintUICommonControls/Sources/AttributedLabel.swift b/BlueprintUICommonControls/Sources/AttributedLabel.swift index 395e4f274..1e990899d 100644 --- a/BlueprintUICommonControls/Sources/AttributedLabel.swift +++ b/BlueprintUICommonControls/Sources/AttributedLabel.swift @@ -1,8 +1,9 @@ import BlueprintUI import Foundation import UIKit +@_spi(HostingViewContext) import BlueprintUI -public struct AttributedLabel: Element, Hashable { +public struct AttributedLabel: Element, Hashable, CrossLayoutCacheable { /// The attributed text to render in the label. /// @@ -114,9 +115,9 @@ public struct AttributedLabel: Element, Hashable { // MARK: Element /// The text to pass to the underlying `UILabel`, normalized for display if necessary. - var displayableAttributedText: NSAttributedString { + func displayableAttributedText(environment: Environment) -> NSAttributedString { if needsTextNormalization || linkDetectionTypes.isEmpty == false { - return attributedText.normalizingForView(with: numberOfLines) + return attributedText.normalizingForView(with: numberOfLines, environment: environment) } else { return attributedText } @@ -132,20 +133,19 @@ public struct AttributedLabel: Element, Hashable { public var content: ElementContent { - // We create this outside of the measurement block so it's called fewer times. - let text = displayableAttributedText - - return ElementContent { constraint, environment -> CGSize in + ElementContent(cacheKey: self) { constraint, environment -> CGSize in + let text = displayableAttributedText(environment: environment) let label = Self.prototypeLabel label.update(model: self, text: text, environment: environment, isMeasuring: true) - return label.sizeThatFits(constraint.maximum) + let size = label.sizeThatFits(constraint.maximum) + return size } } public func backingViewDescription(with context: ViewDescriptionContext) -> ViewDescription? { // We create this outside of the application block so it's called fewer times. - let text = displayableAttributedText + let text = displayableAttributedText(environment: context.environment) return LabelView.describe { config in config.frameRoundingBehavior = .prioritizeSize @@ -963,7 +963,11 @@ extension NSAttributedString { NSRange(location: 0, length: length) } - fileprivate func normalizingForView(with numberOfLines: Int) -> NSAttributedString { + fileprivate func normalizingForView(with numberOfLines: Int, environment: Environment) -> NSAttributedString { + let key = AttributedStringNormalizationKey(label: self, lines: numberOfLines) + if environment.layoutMode.options.stringNormalizationCache, let cached = environment.hostingViewContext.attributedStringNormalizationCache[key] { + return cached + } var attributedText = AttributedText(self) for run in attributedText.runs { @@ -999,7 +1003,11 @@ extension NSAttributedString { attributedText.paragraphStyle = paragraphStyle } - return attributedText.attributedString + let resolved = attributedText.attributedString + if environment.layoutMode.options.stringNormalizationCache { + environment.hostingViewContext.attributedStringNormalizationCache[key] = resolved + } + return resolved } } @@ -1021,3 +1029,18 @@ extension String { } } +fileprivate struct AttributedStringNormalizationKey: Hashable { + let label: NSAttributedString + let lines: Int +} + +extension HostingViewContext { + private struct AttributedStringNormalizationCacheKey: CrossLayoutCacheKey { + static let emptyValue: [AttributedStringNormalizationKey: NSAttributedString] = [:] + } + + fileprivate var attributedStringNormalizationCache: [AttributedStringNormalizationKey: NSAttributedString] { + get { self[AttributedStringNormalizationCacheKey.self] } + set { self[AttributedStringNormalizationCacheKey.self] = newValue } + } +}