diff --git a/BlueprintUI/Sources/BlueprintView/BlueprintView.swift b/BlueprintUI/Sources/BlueprintView/BlueprintView.swift index c934db457..29652434d 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 cacheStorage = Environment.CacheStorageEnvironmentKey.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.isEquivalent( + 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 contextuallyEquivalent = element as? ContextuallyEquivalent, contextuallyEquivalent.isEquivalent( + to: oldValue as? ContextuallyEquivalent, + in: .all + ) { + return + } + cacheStorage = Environment.CacheStorageEnvironmentKey.defaultValue Logger.logElementAssigned(view: self) @@ -148,6 +164,7 @@ public final class BlueprintView: UIView { self.element = element self.environment = environment + self.environment.cacheStorage = cacheStorage rootController = NativeViewController( node: NativeViewNode( @@ -542,9 +559,13 @@ public final class BlueprintView: UIView { environment.layoutMode = layoutMode } + environment.cacheStorage = cacheStorage + return environment } + + private func handleAppeared() { rootController.traverse { node in node.onAppear?() diff --git a/BlueprintUI/Sources/Element/ElementContent.swift b/BlueprintUI/Sources/Element/ElementContent.swift index aa3ac6bfd..396483808 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 validationKey: If present, measureFunction will attempt to cache sizing based on the path of the node. validationKey will be evaluated to ensure that the result is valid. + /// - parameter measureFunction: How to measure the `ElementContent` in the given `SizeConstraint` and `Environment`. + public init( + validationKey: some ContextuallyEquivalent, + measureFunction: @escaping (SizeConstraint, Environment) -> CGSize + ) { + storage = MeasurableStorage(validationKey: validationKey, 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..da75840f5 100644 --- a/BlueprintUI/Sources/Element/MeasurableStorage.swift +++ b/BlueprintUI/Sources/Element/MeasurableStorage.swift @@ -7,7 +7,18 @@ struct MeasurableStorage: ContentStorage { let childCount = 0 + let validationKey: AnyContextuallyEquivalent? let measurer: (SizeConstraint, Environment) -> CGSize + + init(validationKey: some ContextuallyEquivalent, measurer: @escaping (SizeConstraint, Environment) -> CGSize) { + self.validationKey = AnyContextuallyEquivalent(validationKey) + self.measurer = measurer + } + + init(measurer: @escaping (SizeConstraint, Environment) -> CGSize) { + validationKey = 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 validationKey else { + return measurer(proposal, environment) + } + + let key = MeasurableSizeKey(path: node.path, max: proposal.maximum) + return environment.cacheStorage.measurableStorageCache.retrieveOrCreate( + key: key, + environment: environment, + validationValue: validationKey, + 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 max: CGSize + + func hash(into hasher: inout Hasher) { + path.hash(into: &hasher) + max.hash(into: &hasher) + } + + } + +} + +extension CacheStorage { + + private struct MeasurableStorageCacheKey: CacheStorage.Key { + static var emptyValue = EnvironmentAndValueValidatingCache< + MeasurableStorage.MeasurableSizeKey, + CGSize, + AnyContextuallyEquivalent + >() + } + + fileprivate var measurableStorageCache: EnvironmentAndValueValidatingCache< + MeasurableStorage.MeasurableSizeKey, + CGSize, + AnyContextuallyEquivalent + > { + get { self[MeasurableStorageCacheKey.self] } + set { self[MeasurableStorageCacheKey.self] = newValue } + } + +} diff --git a/BlueprintUI/Sources/Environment/Cache/CacheKey.swift b/BlueprintUI/Sources/Environment/Cache/CacheKey.swift new file mode 100644 index 000000000..1d3be132a --- /dev/null +++ b/BlueprintUI/Sources/Environment/Cache/CacheKey.swift @@ -0,0 +1,32 @@ +import Foundation + +/// Types conforming to this protocol can be used as keys in `CacheStorage`. +/// +/// Using a type as the key allows us to strongly type each value, with the +/// key's `CacheStorage.Key.Value` associated value. +/// +/// ## Example +/// +/// Usually a key is implemented with an uninhabited type, such an empty enum. +/// +/// enum WidgetCountsKey: CacheStorage.Key { +/// static let emptyValue: [WidgetID: Int] = [:] +/// } +/// +/// You can write a small extension on `CacheStorage` to make it easier to use your key. +/// +/// extension CacheStorage { +/// var widgetCounts: [WidgetID: Int] { +/// get { self[WidgetCountsKey.self] } +/// set { self[WidgetCountsKey.self] = newValue } +/// } +/// } +/// +extension CacheStorage { + + public protocol Key { + associatedtype Value + static var emptyValue: Self.Value { get } + } + +} diff --git a/BlueprintUI/Sources/Environment/Cache/CacheStorage.swift b/BlueprintUI/Sources/Environment/Cache/CacheStorage.swift new file mode 100644 index 000000000..195f5238f --- /dev/null +++ b/BlueprintUI/Sources/Environment/Cache/CacheStorage.swift @@ -0,0 +1,89 @@ +import Foundation +#if canImport(UIKit) +import UIKit +#endif + +/// Environment-associated storage used to cache types used across layout passes (eg, size calculations). +/// The storage itself is type-agnostic, requiring only that its keys and values conform to the `CacheKey` protocol +/// Caches are responsible for managing their own lifetimes and eviction strategies. +@_spi(CacheStorage) public final class CacheStorage: Sendable, CustomDebugStringConvertible { + + // Optional name to distinguish between instances for debugging purposes. + public var name: String? = nil + fileprivate var storage: [ObjectIdentifier: Any] = [:] + + init() { + #if canImport(UIKit) + NotificationCenter.default.addObserver( + forName: UIApplication.didReceiveMemoryWarningNotification, + object: nil, + queue: .main + ) { [weak self] _ in + self?.storage.removeAll() + } + #endif + } + + public subscript(key: KeyType.Type) -> KeyType.Value where KeyType: CacheStorage.Key { + get { + storage[ObjectIdentifier(key), default: KeyType.emptyValue] as! KeyType.Value + } + set { + storage[ObjectIdentifier(key)] = newValue + } + } + + public var debugDescription: String { + let debugName = if let name { + "CacheStorage (\(name))" + } else { + "CacheStorage" + } + return "\(debugName): \(storage.count) entries" + } + +} + +extension Environment { + + struct CacheStorageEnvironmentKey: InternalEnvironmentKey { + static var defaultValue = CacheStorage() + } + + + @_spi(CacheStorage) public var cacheStorage: CacheStorage { + get { self[CacheStorageEnvironmentKey.self] } + set { self[CacheStorageEnvironmentKey.self] = newValue } + } + +} + +/// A UUID that changes based on value changes of the containing type. +/// Two fingerprinted objects may be quickly compared for equality by comparing their fingerprints. +/// This is roughly analagous to a hash, although with inverted properties: Two objects with the same fingerprint can be trivially considered equal, but two otherwise equal objects may have different fingerprint. +/// - Note: This type is deliberately NOT equatable – this is to prevent accidental inclusion of it when its containing type is equatable. +struct ComparableFingerprint: ContextuallyEquivalent, CustomStringConvertible { + + typealias Value = UUID + + var value: Value + + init() { + value = Value() + } + + mutating func modified() { + value = Value() + } + + /// - Note: This is a duplicate message but: this type is deliberately NOT equatable – this is to prevent accidental inclusion of it when its containing type is equatable. Use this instead. + func isEquivalent(to other: ComparableFingerprint?, in context: EquivalencyContext) -> Bool { + value == other?.value + } + + var description: String { + value.uuidString + } + +} + diff --git a/BlueprintUI/Sources/Environment/Cache/ValidatingCache.swift b/BlueprintUI/Sources/Environment/Cache/ValidatingCache.swift new file mode 100644 index 000000000..b48a14761 --- /dev/null +++ b/BlueprintUI/Sources/Environment/Cache/ValidatingCache.swift @@ -0,0 +1,198 @@ +import Foundation + +/// Validating cache is a cache which, if it has a value for a key, runs a closure to verify that the cache value is still relevant and not state. +/// This is useful for cases when you might otherwise wish to store the validation data as a key, but it does not conform to Hashable, or its hashability properties do not neccessarily affect the validity of the cached data. +@_spi(CacheStorage) public struct ValidatingCache: Sendable where Key: Hashable { + + private var storage: [Key: ValueStorage] = [:] + + private struct ValueStorage { + let value: Value + let validationData: ValidationData + } + + public init() {} + + /// Retrieves the value for a given key, without evaluating any validation conditions. + public subscript(uncheckedKey key: Key) -> Value? { + storage[key]?.value + } + + /// Retrieves or creates a value based on a key and validation function. + /// - Parameters: + /// - key: The key to look up. + /// - validate: A function that evaluates whether or not a given result is still valid. + /// - create: Creates a fresh cache entry no valid cached data is available, and stores it. + /// - Returns: Either a cached or newly created value. + public mutating func retrieveOrCreate( + key: Key, + validate: (ValidationData) -> Bool, + create: () -> (Value, ValidationData) + ) -> Value { + if let valueStorage = storage[key] { + Logger.logValidatingCacheKeyHit(key: key) + let validationToken = Logger.logValidatingCacheValidationStart(key: key) + if validate(valueStorage.validationData) { + Logger.logValidatingCacheHitAndValidationSuccess(key: key) + Logger.logValidatingCacheValidationEnd(validationToken, key: key) + return valueStorage.value + #if DEBUG + // FIXME: WAY TO MAKE SURE THIS DOESN'T SHIP ON. + // Enable this to always evaluate the create block to assert that the caching is producing the expected value. + // if let stored = valueStorage.value as? (any Equatable) { + // let fresh = create().0 as! Equatable + // assert(stored.isEqual(fresh)) + // } + // return valueStorage.value + #endif + } else { + Logger.logValidatingCacheHitAndValidationFailure(key: key) + Logger.logValidatingCacheValidationEnd(validationToken, key: key) + } + } else { + Logger.logValidatingCacheKeyMiss(key: key) + } + let createToken = Logger.logValidatingCacheFreshValueCreationStart(key: key) + let (fresh, validationData) = create() + Logger.logValidatingCacheFreshValueCreationEnd(createToken, key: key) + storage[key] = ValueStorage(value: fresh, validationData: validationData) + return fresh + } + + public mutating func removeValue(forKey key: Key) -> Value? { + storage.removeValue(forKey: key)?.value + } + +} + +/// A convenience wrapper around ValidatingCache which ensures that only values which were cached in equivalent environments are returned. +@_spi(CacheStorage) public struct EnvironmentValidatingCache: Sendable where Key: Hashable { + + private var backing = ValidatingCache() + + public init() {} + + /// Retrieves or creates a value based on a key and environment validation. + /// - Parameters: + /// - key: The key to look up. + /// - environment: The current environment. A frozen version of this environment will be preserved along with freshly cached values for comparison. + /// - context: The equivalency context in which the environment should be evaluated. + /// - create: Creates a fresh cache entry no valid cached data is available, and stores it. + /// - Returns: Either a cached or newly created value. + mutating func retrieveOrCreate( + key: Key, + environment: Environment, + context: EquivalencyContext, + create: (Environment) -> Value + ) -> Value { + backing.retrieveOrCreate(key: key) { + environment.isEquivalent(to: $0, in: context) + } create: { + environment.snapshottingAccess { environment in + create(environment) + } + } + } + +} + +/// A convenience wrapper around ValidatingCache which ensures that only values which were cached in equivalent environments are returned, and allows for additional data to be stored to be validated. +@_spi(CacheStorage) public struct EnvironmentAndValueValidatingCache: Sendable where Key: Hashable { + + private var backing = ValidatingCache() + + public init() {} + + /// Retrieves or creates a value based on a key and a validation function, alongside environment validation. + /// - Parameters: + /// - key: The key to look up. + /// - environment: The current environment. A frozen version of this environment will be preserved along with freshly cached values for comparison. + /// - context: The equivalency context in which the environment should be evaluated. + /// - validate: A function that evaluates whether or not a given result is still valid. + /// - create: Creates a fresh cache entry no valid cached data is available, and stores it. + /// - Returns: Either a cached or newly created value. + /// - Note: Generally, prefer the `validationValue` versions of this method if the validation value conforms to ContextuallyEquivalent or Equatable. + mutating func retrieveOrCreate( + key: Key, + environment: Environment, + context: EquivalencyContext, + validate: (AdditionalValidationData) -> Bool, + create: (Environment) -> (Value, AdditionalValidationData) + ) -> Value { + backing.retrieveOrCreate(key: key) { + environment.isEquivalent(to: $0.0, in: context) && validate($0.1) + } create: { + let ((value, additional), snapshot) = environment.snapshottingAccess { environment in + create(environment) + } + return (value, (snapshot, additional)) + } + } + +} + + +@_spi(CacheStorage) extension EnvironmentAndValueValidatingCache where AdditionalValidationData: ContextuallyEquivalent { + + /// Retrieves or creates a value based on a key and a validation value, alongside environment validation. + /// - Parameters: + /// - key: The key to look up. + /// - environment: The current environment. A frozen version of this environment will be preserved along with freshly cached values for comparison. + /// - context: The equivalency context in which the environment and validation values should be evaluated. + /// - validationValue: A value that will be compared using contextual equivalence that evaluates whether or not a given result is still valid. + /// - create: Creates a fresh cache entry no valid cached data is available, and stores it. + /// - Returns: Either a cached or newly created value. + public mutating func retrieveOrCreate( + key: Key, + environment: Environment, + validationValue: AdditionalValidationData, + context: EquivalencyContext, + create: (Environment) -> (Value) + ) -> Value { + retrieveOrCreate(key: key, environment: environment, context: context) { + $0.isEquivalent(to: validationValue, in: context) + } create: { + (create($0), validationValue) + } + + } + +} + +@_spi(CacheStorage) extension EnvironmentAndValueValidatingCache where AdditionalValidationData: Equatable { + + /// Retrieves or creates a value based on a key and a validation value, alongside environment validation. + /// - Parameters: + /// - key: The key to look up. + /// - environment: The current environment. A frozen version of this environment will be preserved along with freshly cached values for comparison. + /// - context: The equivalency context in which the environment should be evaluated. + /// - validationValue: A value that will be compared using strict equality that evaluates whether or not a given result is still valid. + /// - create: Creates a fresh cache entry no valid cached data is available, and stores it. + /// - Returns: Either a cached or newly created value. + @_disfavoredOverload public mutating func retrieveOrCreate( + key: Key, + environment: Environment, + validationValue: AdditionalValidationData, + context: EquivalencyContext, + create: (Environment) -> (Value) + ) -> Value { + retrieveOrCreate(key: key, environment: environment, context: context) { + $0 == validationValue + } create: { + (create($0), validationValue) + } + } + +} + + +extension Equatable { + + fileprivate func isEqual(_ other: any Equatable) -> Bool { + guard let other = other as? Self else { + return false + } + return self == other + } + +} diff --git a/BlueprintUI/Sources/Environment/Environment.swift b/BlueprintUI/Sources/Environment/Environment.swift index ed948eef2..4cbfbf2ba 100644 --- a/BlueprintUI/Sources/Environment/Environment.swift +++ b/BlueprintUI/Sources/Environment/Environment.swift @@ -40,21 +40,41 @@ public struct Environment { /// Each key will return its default value. public static let empty = Environment() - private var values: [ObjectIdentifier: Any] = [:] + // Fingerprint used for referencing previously compared environments. + var fingerprint = ComparableFingerprint() + + private var values: [Keybox: Any] = [:] + private var snapshotting: SnapshottingEnvironment? + + private var internalValues: [ObjectIdentifier: Any] = [:] /// Gets or sets an environment value by its key. public subscript(key: Key.Type) -> Key.Value where Key: EnvironmentKey { get { - let objectId = ObjectIdentifier(key) + self[Keybox(key)] as! Key.Value + } + set { + let keybox = Keybox(key) + let oldValue = values[keybox] + values[keybox] = newValue + fingerprint.modified() + } + } - if let value = values[objectId] { - return value as! Key.Value - } + private subscript(keybox: Keybox) -> Any { + let value = values[keybox, default: keybox.type.defaultValue] + if let snapshotting { + snapshotting.value.values[keybox] = value + } + return value + } - return key.defaultValue + subscript(key: Key.Type) -> Key.Value where Key: InternalEnvironmentKey { + get { + internalValues[ObjectIdentifier(key), default: key.defaultValue] as! Key.Value } set { - values[ObjectIdentifier(key)] = newValue + internalValues[ObjectIdentifier(key)] = newValue } } @@ -69,10 +89,205 @@ public struct Environment { func merged(prioritizing other: Environment) -> Environment { var merged = self merged.values.merge(other.values) { $1 } + merged.fingerprint.modified() return merged } + + func snapshottingAccess(_ closure: (Environment) -> T) -> (T, EnvironmentSnapshot) { + var watching = self + let snapshotting = SnapshottingEnvironment() + watching.snapshotting = snapshotting + let result = closure(watching) + return (result, snapshotting.value) + } + +} + +/// An environment snapshot is immutable copy of the comparable elements of an Environment struct that were accessed during the cached value's creaton.. +struct EnvironmentSnapshot { + + // Fingerprint used for referencing previously compared environments. + var fingerprint: ComparableFingerprint + var values: [Environment.Keybox: Any] + +} + +private final class SnapshottingEnvironment { + var value = EnvironmentSnapshot(fingerprint: .init(), values: [:]) +} + +extension Environment: ContextuallyEquivalent { + + public func isEquivalent(to other: Self?, in context: EquivalencyContext) -> Bool { + guard let other else { return false } + if fingerprint.isEquivalent(to: other.fingerprint) { + Logger.logEnvironmentEquivalencyFingerprintEqual(environment: self) + return true + } + if let evaluated = cacheStorage.environmentComparisonCache[fingerprint, other.fingerprint, context] { + Logger.logEnvironmentEquivalencyFingerprintCacheHit(environment: self) + return evaluated + } + Logger.logEnvironmentEquivalencyFingerprintCacheMiss(environment: self) + let token = Logger.logEnvironmentEquivalencyComparisonStart(environment: self) + let keys = Set(values.keys).union(other.values.keys) + for key in keys { + guard key.isEquivalent(self[key], other[key], context) else { + cacheStorage.environmentComparisonCache[fingerprint, other.fingerprint, context] = false + Logger.logEnvironmentEquivalencyCompletedWithNonEquivalence( + environment: self, + key: key, + context: context + ) + Logger.logEnvironmentEquivalencyComparisonEnd(token, environment: self) + return false + } + } + Logger.logEnvironmentEquivalencyComparisonEnd(token, environment: self) + Logger.logEnvironmentEquivalencyCompletedWithEquivalence(environment: self, context: context) + cacheStorage.environmentComparisonCache[fingerprint, other.fingerprint, context] = true + return true + } + + func isEquivalent(to snapshot: EnvironmentSnapshot?, in context: EquivalencyContext) -> Bool { + guard let snapshot else { return false } + // We don't even need to thaw the environment if the fingerprints match. + if snapshot.fingerprint.isEquivalent(to: fingerprint) { + Logger.logEnvironmentEquivalencyFingerprintEqual(environment: self) + return true + } + let scope = Set(snapshot.values.keys.map(\.objectIdentifier)) + if let evaluated = cacheStorage.environmentComparisonCache[fingerprint, snapshot.fingerprint, context, scope] { + Logger.logEnvironmentEquivalencyFingerprintCacheHit(environment: self) + return evaluated + } + Logger.logEnvironmentEquivalencyFingerprintCacheMiss(environment: self) + let token = Logger.logEnvironmentEquivalencyComparisonStart(environment: self) + for (key, value) in snapshot.values { + guard key.isEquivalent(self[key], value, context) else { + cacheStorage.environmentComparisonCache[fingerprint, snapshot.fingerprint, context, scope] = false + Logger.logEnvironmentEquivalencyCompletedWithNonEquivalence( + environment: self, + key: key, + context: context + ) + Logger.logEnvironmentEquivalencyComparisonEnd(token, environment: self) + return false + } + } + Logger.logEnvironmentEquivalencyComparisonEnd(token, environment: self) + Logger.logEnvironmentEquivalencyCompletedWithEquivalence(environment: self, context: context) + cacheStorage.environmentComparisonCache[fingerprint, snapshot.fingerprint, context, scope] = true + return true + + } + + +} + +extension CacheStorage { + + fileprivate struct EnvironmentFingerprintCache { + + struct Key: Hashable { + let lhs: ComparableFingerprint.Value + let rhs: ComparableFingerprint.Value + let scope: Set? + + init(_ lhs: ComparableFingerprint.Value, _ rhs: ComparableFingerprint.Value, scope: Set?) { + // Sort lhs/rhs so we don't have diff results based on caller. + self.lhs = min(lhs, rhs) + self.rhs = max(lhs, rhs) + self.scope = scope + } + } + + typealias EquivalencyResult = [EquivalencyContext: Bool] + var storage: [Key: [EquivalencyContext: Bool]] = [:] + + public subscript( + lhs: ComparableFingerprint, + rhs: ComparableFingerprint, + context: EquivalencyContext, + scope: Set? = nil + ) -> Bool? { + get { + let key = Key(lhs.value, rhs.value, scope: scope) + if let exact = storage[key]?[context] { + return exact + } else if let allComparisons = storage[key] { + switch context { + case .all: + // If we're checking for equivalency in ALL contexts, we can short circuit based on any case where equivalency is false. + if allComparisons.contains(where: { $1 == false }) { + return false + } else { + return nil + } + case .elementSizing: + // If we've already evaluated it to be equivalent in all cases, we can short circuit because we know that means any more specific checks must also be equivalent + if allComparisons[.all] == true { + return true + } else { + return nil + } + } + } else { + return nil + } + } + set { + storage[Key(lhs.value, rhs.value, scope: scope), default: [:]][context] = newValue + } + } + + } + + /// A cache of previously compared environments and their results. + private struct EnvironmentComparisonCacheKey: CacheStorage.Key { + static let emptyValue = EnvironmentFingerprintCache() + } + + fileprivate var environmentComparisonCache: EnvironmentFingerprintCache { + get { self[EnvironmentComparisonCacheKey.self] } + set { self[EnvironmentComparisonCacheKey.self] = newValue } + } + } +extension Environment { + + /// Lightweight key type eraser. + struct Keybox: Hashable, CustomStringConvertible { + + let objectIdentifier: ObjectIdentifier + let type: any EnvironmentKey.Type + let isEquivalent: (Any?, Any?, EquivalencyContext) -> Bool + + init(_ type: EnvironmentKeyType.Type) { + objectIdentifier = ObjectIdentifier(type) + self.type = type + isEquivalent = { + guard let lhs = $0 as? EnvironmentKeyType.Value, let rhs = $1 as? EnvironmentKeyType.Value else { return false } + return type.isEquivalent(lhs: lhs, rhs: rhs, in: $2) + } + } + + func hash(into hasher: inout Hasher) { + objectIdentifier.hash(into: &hasher) + } + + static func == (lhs: Keybox, rhs: Keybox) -> Bool { + lhs.objectIdentifier == rhs.objectIdentifier + } + + var description: String { + String(describing: type) + } + + } + +} extension UIView { diff --git a/BlueprintUI/Sources/Environment/EnvironmentKey.swift b/BlueprintUI/Sources/Environment/EnvironmentKey.swift index e7d15b216..a61b0a487 100644 --- a/BlueprintUI/Sources/Environment/EnvironmentKey.swift +++ b/BlueprintUI/Sources/Environment/EnvironmentKey.swift @@ -26,4 +26,88 @@ public protocol EnvironmentKey { /// The default value that will be vended by an `Environment` for this key if no other value /// has been set. static var defaultValue: Self.Value { get } + + + /// Compares two environment values without direct conformance of the values. + /// - Parameters: + /// - lhs: The left hand side value being compared. + /// - rhs: The right hand side value being compared. + /// - context: The context to evaluate the equivalency. + /// - Returns: Whether or not the two values are equivalent in the specified context. + static func isEquivalent(lhs: Value, rhs: Value, in context: EquivalencyContext) -> Bool + +} + +extension EnvironmentKey where Value: Equatable { + + public static func isEquivalent(lhs: Value, rhs: Value, in context: EquivalencyContext) -> Bool { + lhs == rhs + } + + /// Convenience implementation returning that the values are always equivalent in the specified contexts, and otherwise evaluates using Equality. + /// - Parameters: + /// - contexts: Contexts in which to always return true for equivalency. + /// - lhs: The left hand side value being compared. + /// - rhs: The right hand side value being compared. + /// - evaluatingContext: The context in which the values are currently being compared. + /// - Returns: Whether or not the two values are equivalent in the specified context. + /// - Note: This is often used for convenience in cases where layout is unaffected, e.g., for an environment value like dark mode, which will have no effect on internal or external layout. + public static func alwaysEquivalentIn( + _ contexts: Set, + lhs: Value, + rhs: Value, + evaluatingContext: EquivalencyContext + ) -> Bool { + if contexts.contains(evaluatingContext) { + true + } else { + lhs == rhs + } + } + +} + +extension EnvironmentKey where Value: ContextuallyEquivalent { + + public static func isEquivalent(lhs: Value, rhs: Value, in context: EquivalencyContext) -> Bool { + lhs.isEquivalent(to: rhs, in: context) + } + + /// Convenience implementation returning that the values are always equivalent in the specified contexts, and otherwise evaluates using ContextuallyEquivalent. + /// - Parameters: + /// - contexts: Contexts in which to always return true for equivalency. + /// - lhs: The left hand side value being compared. + /// - rhs: The right hand side value being compared. + /// - evaluatingContext: The context in which the values are currently being compared. + /// - Returns: Whether or not the two values are equivalent in the specified context. + /// - Note: This is often used for convenience in cases where layout is unaffected, e.g., for an environment value like dark mode, which will have no effect on internal or external layout. + public static func alwaysEquivalentIn( + _ contexts: Set, + lhs: Value, + rhs: Value, + evaluatingContext: EquivalencyContext + ) -> Bool { + if contexts.contains(evaluatingContext) { + true + } else { + lhs.isEquivalent(to: rhs, in: evaluatingContext) + } + } + +} + +extension EnvironmentKey { + + /// Convenience comparison to express default equality in specific contexts. + /// - Parameters: + /// - contexts: The contexts in which the values are always equilvalent. + /// - evaluatingContext: The context being evaulated. + /// - Returns: Whether or not the value is equivalent in the context. + public static func alwaysEquivalentIn( + _ contexts: Set, + evaluatingContext: EquivalencyContext + ) -> Bool { + contexts.contains(evaluatingContext) + } + } diff --git a/BlueprintUI/Sources/Environment/Keys/AccessibilityLinkKey.swift b/BlueprintUI/Sources/Environment/Keys/AccessibilityLinkKey.swift index 3f0a5cbe9..71bb54a57 100644 --- a/BlueprintUI/Sources/Environment/Keys/AccessibilityLinkKey.swift +++ b/BlueprintUI/Sources/Environment/Keys/AccessibilityLinkKey.swift @@ -5,6 +5,10 @@ extension Environment { static var defaultValue: String? { UIImage(systemName: "link")?.accessibilityLabel } + + static func isEquivalent(lhs: String?, rhs: String?, in context: EquivalencyContext) -> Bool { + alwaysEquivalentIn([.elementSizing], lhs: lhs, rhs: rhs, evaluatingContext: context) + } } /// The localised accessibility label elements should use when handling links. diff --git a/BlueprintUI/Sources/Internal/Equivalency.swift b/BlueprintUI/Sources/Internal/Equivalency.swift new file mode 100644 index 000000000..e7ad0f9a6 --- /dev/null +++ b/BlueprintUI/Sources/Internal/Equivalency.swift @@ -0,0 +1,72 @@ +import Foundation + +// A context in which to evaluate whether or not two values are equivalent. +public enum EquivalencyContext: Hashable, Sendable, CaseIterable { + + /// The two values are identicial in every respect that could affect displayed output. + case all + + // More fine-grained contexts: + + /// The two values are equivalent in all aspects that would affect the size of the element. + /// - Warning:Non-obvious things may affect element-sizing – for example, setting a time zone may seem like something that would only affect date calculations, but can result in different text being displayed, and therefore affect sizing. Consider carefully whether you are truly affecting sizing or not. + case elementSizing +} + +public protocol ContextuallyEquivalent { + + /// Allows a type to express equivilancy within certain contexts. For example, an Environment that represents dark mode would be equivalent to an Environment that represents light mode in a `elementSizing` context, but not in `all` contexts. + /// - Parameters: + /// - other: The instance of the type being compared against. + /// - context: The context to compare within. + /// - Returns: Whether or not the other instance is equivalent in the specified context. + /// - Note: Equivilancy within a given context is transitive – that is, if value A is equivalent to value B in a given context, and B is equivalent to C in that same context, A will be considered equivalent to C with that context. + func isEquivalent(to other: Self?, in context: EquivalencyContext) -> Bool + +} + +extension ContextuallyEquivalent { + + /// Convenience equivalency check passing in .all for context. + /// - other: The instance of the type being compared against. + /// - Returns: Whether or not the other instance is equivalent in all contexts. + public func isEquivalent(to other: Self?) -> Bool { + isEquivalent(to: other, in: .all) + } + +} + +extension ContextuallyEquivalent { + + // Allows comparison between types which may or may not be equivalent. + @_disfavoredOverload + public func isEquivalent(to other: (any ContextuallyEquivalent)?, in context: EquivalencyContext) -> Bool { + isEquivalent(to: other as? Self, in: context) + } + +} + +// Default implementation that always returns strict equivalency. +extension ContextuallyEquivalent where Self: Equatable { + + public func isEquivalent(to other: Self?, in context: EquivalencyContext) -> Bool { + self == other + } + +} + +public struct AnyContextuallyEquivalent: ContextuallyEquivalent { + + let base: Any + + public init(_ value: some ContextuallyEquivalent) { + base = value + } + + public func isEquivalent(to other: AnyContextuallyEquivalent?, in context: EquivalencyContext) -> Bool { + guard let base = (base as? any ContextuallyEquivalent) else { return false } + return base.isEquivalent(to: other?.base as? any ContextuallyEquivalent, in: context) + } + +} + diff --git a/BlueprintUI/Sources/Internal/InternalEnvironmentKey.swift b/BlueprintUI/Sources/Internal/InternalEnvironmentKey.swift new file mode 100644 index 000000000..fdc39922d --- /dev/null +++ b/BlueprintUI/Sources/Internal/InternalEnvironmentKey.swift @@ -0,0 +1,13 @@ +import Foundation + +/// An `EnvironmentKey` which is only stored in the internal storage of the `Environment`, and which does not participate in equivalency comparsions. +protocol InternalEnvironmentKey: EnvironmentKey {} + +extension InternalEnvironmentKey { + + // Internal environment keys do not participate in equivalency checks. + static func isEquivalent(lhs: Value, rhs: Value, in context: EquivalencyContext) -> Bool { + true + } + +} diff --git a/BlueprintUI/Sources/Internal/Logger.swift b/BlueprintUI/Sources/Internal/Logger.swift index b2074465a..a05acfee9 100644 --- a/BlueprintUI/Sources/Internal/Logger.swift +++ b/BlueprintUI/Sources/Internal/Logger.swift @@ -2,12 +2,16 @@ import Foundation import os.log /// Namespace for logging helpers -enum Logger {} +enum Logger { + fileprivate static let signposter = OSSignposter(logHandle: .active) + static var hook: ((String) -> Void)? +} -/// BlueprintView signposts +// MARK: - BlueprintView signposts extension Logger { static func logLayoutStart(view: BlueprintView) { + guard BlueprintLogging.isEnabled else { return } os_signpost( @@ -100,7 +104,8 @@ extension Logger { } } -/// Measuring signposts +// MARK: - HintingSizeCache signposts + extension Logger { static func logMeasureStart(object: AnyObject, description: String, constraint: SizeConstraint) { @@ -185,10 +190,169 @@ extension Logger { ) } - // MARK: Utilities + +} + +// MARK: - CacheStorage + +extension Logger { + + // MARK: Environment Comparison + + static func logEnvironmentKeySetEquivalencyComparisonStart(key: some Hashable) -> OSSignpostIntervalState? { + guard BlueprintLogging.isEnabled else { return nil } + let token = signposter.beginInterval( + "Environment key set equivalency comparison", + id: key.signpost, + "Start: \(String(describing: key))" + ) + hook?("\(#function) \(String(describing: key))") + return token + } + + static func logEnvironmentKeySetEquivalencyComparisonEnd(_ token: OSSignpostIntervalState?, key: some Hashable) { + guard BlueprintLogging.isEnabled, let token else { return } + signposter.endInterval("Environment key set equivalency comparison", token, "\(String(describing: key))") + hook?("\(#function) \(String(describing: key))") + } + + static func logEnvironmentEquivalencyComparisonStart(environment: Environment) -> OSSignpostIntervalState? { + guard BlueprintLogging.isEnabled else { return nil } + let token = signposter.beginInterval( + "Environment equivalency comparison", + id: environment.fingerprint.value.signpost, + "Start: \(String(describing: environment))" + ) + hook?("\(#function) \(environment.fingerprint)") + return token + } + + static func logEnvironmentEquivalencyComparisonEnd(_ token: OSSignpostIntervalState?, environment: Environment) { + guard BlueprintLogging.isEnabled, let token else { return } + signposter.endInterval("Environment equivalency comparison", token, "\(String(describing: environment))") + hook?("\(#function) \(environment.fingerprint)") + } + + static func logEnvironmentEquivalencyFingerprintEqual(environment: Environment) { + guard BlueprintLogging.isEnabled else { return } + signposter.emitEvent("Environments trivially equal from fingerprint", id: environment.fingerprint.value.signpost) + hook?("\(#function) \(environment.fingerprint)") + } + + static func logEnvironmentEquivalencyFingerprintCacheHit(environment: Environment) { + guard BlueprintLogging.isEnabled else { return } + signposter.emitEvent("Environment cached comparison result hit", id: environment.fingerprint.value.signpost) + hook?("\(#function) \(environment.fingerprint)") + } + + static func logEnvironmentEquivalencyFingerprintCacheMiss(environment: Environment) { + guard BlueprintLogging.isEnabled else { return } + signposter.emitEvent("Environment cached comparison result miss", id: environment.fingerprint.value.signpost) + hook?("\(#function) \(environment.fingerprint)") + } + + static func logEnvironmentEquivalencyCompletedWithNonEquivalence( + environment: Environment, + key: some Hashable, + context: EquivalencyContext + ) { + guard BlueprintLogging.isEnabled else { return } + signposter.emitEvent( + "Environment equivalency completed with non-equivalent result", + id: environment.fingerprint.value.signpost, + "\(String(describing: context)): \(String(describing: key)) not equivalent" + ) + hook?("\(#function) \(String(describing: key))") + } + + static func logEnvironmentEquivalencyCompletedWithEquivalence(environment: Environment, context: EquivalencyContext) { + guard BlueprintLogging.isEnabled else { return } + signposter.emitEvent( + "Environment equivalency completed with equivalent result", + id: environment.fingerprint.value.signpost, + "\(String(describing: context))" + ) + hook?("\(#function) \(environment.fingerprint)") + } + + + // MARK: ValidatingCache + + static func logValidatingCacheValidationStart(key: some Hashable) -> OSSignpostIntervalState? { + guard BlueprintLogging.isEnabled else { return nil } + let token = signposter.beginInterval( + "ValidatingCache validation", + id: key.signpost, + "Start: \(String(describing: key))" + ) + hook?("\(#function) \(String(describing: key))") + return token + } + + static func logValidatingCacheValidationEnd(_ token: OSSignpostIntervalState?, key: some Hashable) { + guard BlueprintLogging.isEnabled, let token else { return } + signposter.endInterval("ValidatingCache validation", token, "\(String(describing: key))") + hook?("\(#function) \(String(describing: key))") + } + + static func logValidatingCacheFreshValueCreationStart(key: some Hashable) -> OSSignpostIntervalState? { + guard BlueprintLogging.isEnabled else { return nil } + let token = signposter.beginInterval( + "ValidatingCache fresh value creation", + id: key.signpost, + "\(String(describing: key))" + ) + hook?("\(#function) \(String(describing: key))") + return token + } + + static func logValidatingCacheFreshValueCreationEnd(_ token: OSSignpostIntervalState?, key: some Hashable) { + guard BlueprintLogging.isEnabled, let token else { return } + signposter.endInterval("ValidatingCache fresh value creation", token) + hook?("\(#function) \(String(describing: key))") + } + + static func logValidatingCacheKeyMiss(key: some Hashable) { + guard BlueprintLogging.isEnabled else { return } + signposter.emitEvent("ValidatingCache key miss", id: key.signpost) + hook?("\(#function) \(String(describing: key))") + } + + static func logValidatingCacheKeyHit(key: some Hashable) { + guard BlueprintLogging.isEnabled else { return } + signposter.emitEvent("ValidatingCache key hit", id: key.signpost) + hook?("\(#function) \(String(describing: key))") + } + + static func logValidatingCacheHitAndValidationSuccess(key: some Hashable) { + guard BlueprintLogging.isEnabled else { return } + signposter.emitEvent("ValidatingCache validation success", id: key.signpost) + hook?("\(#function) \(String(describing: key))") + } + + static func logValidatingCacheHitAndValidationFailure(key: some Hashable) { + guard BlueprintLogging.isEnabled else { return } + signposter.emitEvent("ValidatingCache validation failure", id: key.signpost) + hook?("\(#function) \(String(describing: key))") + } + +} + +extension Hashable { + + fileprivate var signpost: OSSignpostID { + OSSignpostID(UInt64(abs(hashValue))) + } + +} + +// MARK: - Utilities + +extension Logger { private static func shouldRecordMeasurePass() -> Bool { BlueprintLogging.isEnabled && BlueprintLogging.config.recordElementMeasures } + } 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..8ff17dbc4 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: true, + stringNormalizationCache: true, + skipUnneededSetNeedsViewHierarchyUpdates: true, + labelAttributedStringCache: true ) /// 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/BlueprintViewTests.swift b/BlueprintUI/Tests/BlueprintViewTests.swift index 0ee4aa674..72a4d7a8f 100755 --- a/BlueprintUI/Tests/BlueprintViewTests.swift +++ b/BlueprintUI/Tests/BlueprintViewTests.swift @@ -228,7 +228,7 @@ class BlueprintViewTests: XCTestCase { } func test_baseEnvironment() { - enum TestValue { + enum TestValue: Equatable { case defaultValue case right } diff --git a/BlueprintUI/Tests/EnvironmentEntangledCacheTests.swift b/BlueprintUI/Tests/EnvironmentEntangledCacheTests.swift new file mode 100644 index 000000000..2ea946699 --- /dev/null +++ b/BlueprintUI/Tests/EnvironmentEntangledCacheTests.swift @@ -0,0 +1,7 @@ +// +// EnvironmentEntangledCacheTests.swift +// Development +// +// Created by Max Goedjen on 7/23/25. +// + diff --git a/BlueprintUI/Tests/EnvironmentEquivalencyTests.swift b/BlueprintUI/Tests/EnvironmentEquivalencyTests.swift new file mode 100644 index 000000000..85541f037 --- /dev/null +++ b/BlueprintUI/Tests/EnvironmentEquivalencyTests.swift @@ -0,0 +1,164 @@ +import Testing +@testable import BlueprintUI + +@MainActor +struct EnvironmentEquivalencyTests { + + @Test func simpleEquivalency() { + let a = Environment() + let b = Environment() + #expect(a.isEquivalent(to: b, in: .all)) + #expect(a.isEquivalent(to: b, in: .elementSizing)) + } + + @Test func simpleChange() { + var a = Environment() + a[ExampleKey.self] = 1 + let b = Environment() + #expect(!a.isEquivalent(to: b, in: .all)) + #expect(!a.isEquivalent(to: b, in: .elementSizing)) + } + + @Test func orderingWithDefaults() { + // The ordering of the comparison shouldn't matter if one value has a setting but the other doesn't. + var a = Environment() + a[ExampleKey.self] = 1 + let b = Environment() + #expect(!a.isEquivalent(to: b)) + + // Explicitly duplicated to ensure we don't hit a cached comparison. + let c = Environment() + var d = Environment() + d[ExampleKey.self] = 1 + #expect(!c.isEquivalent(to: d)) + } + + @Test func orderingWithNullability() { + // The ordering of the comparison shouldn't matter if one value has a setting but the other doesn't. + var a = Environment() + a[OptionalKey.self] = 1 + let b = Environment() + #expect(!a.isEquivalent(to: b)) + + // Explicitly duplicated to ensure we don't hit a cached comparison. + let c = Environment() + var d = Environment() + d[OptionalKey.self] = 1 + #expect(!c.isEquivalent(to: d)) + } + + @Test func modification() { + var a = Environment() + let b = a + a[ExampleKey.self] = 1 + #expect(!a.isEquivalent(to: b)) + } + + @Test func caching() { + BlueprintLogging.isEnabled = true + var hookedResult: [String] = [] + Logger.hook = { + hookedResult.append($0) + } + var a = Environment() + let b = a + a[ExampleKey.self] = 1 + hookedResult = [] + #expect(!a.isEquivalent(to: b)) + #expect(hookedResult.contains("logEnvironmentEquivalencyFingerprintCacheMiss(environment:) \(a.fingerprint)")) + + hookedResult = [] + #expect(!a.isEquivalent(to: b)) + // Subsequent comparison should be cached + #expect(hookedResult.contains("logEnvironmentEquivalencyFingerprintCacheHit(environment:) \(a.fingerprint)")) + + hookedResult = [] + #expect(!b.isEquivalent(to: a)) + // Reversed order should still be cached + #expect(hookedResult.contains("logEnvironmentEquivalencyFingerprintCacheHit(environment:) \(b.fingerprint)")) + + hookedResult = [] + let c = b + #expect(!a.isEquivalent(to: c)) + // Copying without mutation should preserve fingerprint, and be cached. + #expect(hookedResult.contains("logEnvironmentEquivalencyFingerprintCacheHit(environment:) \(a.fingerprint)")) + + } + + @Test func cascading() { + BlueprintLogging.isEnabled = true + var hookedResult: [String] = [] + Logger.hook = { + hookedResult.append($0) + } + var a = Environment() + a[ExampleKey.self] = 1 + a[NonSizeAffectingKey.self] = 1 + var b = Environment() + b[ExampleKey.self] = 1 + b[NonSizeAffectingKey.self] = 2 + + hookedResult = [] + #expect(a.isEquivalent(to: b, in: .elementSizing)) + #expect(hookedResult.contains("logEnvironmentEquivalencyFingerprintCacheMiss(environment:) \(a.fingerprint)")) + + hookedResult = [] + #expect(!a.isEquivalent(to: b, in: .all)) + // A specific equivalency being true doesn't imply `.all` to be true, so we should see another evaluation. + #expect(hookedResult.contains("logEnvironmentEquivalencyFingerprintCacheMiss(environment:) \(a.fingerprint)")) + + var c = Environment() + c[ExampleKey.self] = 1 + var d = Environment() + d[ExampleKey.self] = 1 + + hookedResult = [] + #expect(c.isEquivalent(to: d, in: .all)) + #expect(hookedResult.contains("logEnvironmentEquivalencyFingerprintCacheMiss(environment:) \(c.fingerprint)")) + + hookedResult = [] + #expect(c.isEquivalent(to: d, in: .elementSizing)) + // `.all` equivalency implies that any more fine-grained equivalency should also be true, so we should be using a cached result. + #expect(hookedResult.contains("logEnvironmentEquivalencyFingerprintCacheHit(environment:) \(c.fingerprint)")) + + // A specific equivalency being false implies `.all` to be be false, so we should be using a cached result. + var e = Environment() + e[ExampleKey.self] = 2 + let f = Environment() + + hookedResult = [] + #expect(!e.isEquivalent(to: f, in: .elementSizing)) + #expect(hookedResult.contains("logEnvironmentEquivalencyFingerprintCacheMiss(environment:) \(e.fingerprint)")) + + hookedResult = [] + #expect(!e.isEquivalent(to: f, in: .all)) + #expect(hookedResult.contains("logEnvironmentEquivalencyFingerprintCacheHit(environment:) \(e.fingerprint)")) + + } + + func hello(closure: @autoclosure () -> Bool, message: String) { + var hookedResult: [String] = [] + Logger.hook = { + hookedResult.append($0) + } + #expect(closure()) + #expect(hookedResult.contains(message)) + } + +} + +enum ExampleKey: EnvironmentKey { + static let defaultValue = 0 +} + +enum OptionalKey: EnvironmentKey { + static let defaultValue: Int? = nil +} + +enum NonSizeAffectingKey: EnvironmentKey { + static let defaultValue = 0 + + static func isEquivalent(lhs: Int, rhs: Int, in context: EquivalencyContext) -> Bool { + alwaysEquivalentIn([.elementSizing], evaluatingContext: context) + } +} diff --git a/BlueprintUI/Tests/EnvironmentTests.swift b/BlueprintUI/Tests/EnvironmentTests.swift index f354c6beb..7889ce8a7 100644 --- a/BlueprintUI/Tests/EnvironmentTests.swift +++ b/BlueprintUI/Tests/EnvironmentTests.swift @@ -307,7 +307,7 @@ private class TestView: UIView { var testValue = TestValue.defaultValue } -private enum TestValue { +private enum TestValue: Equatable { case defaultValue case wrong case right 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/BlueprintUI/Tests/UIViewElementTests.swift b/BlueprintUI/Tests/UIViewElementTests.swift index 3c7f25874..71394b3c8 100644 --- a/BlueprintUI/Tests/UIViewElementTests.swift +++ b/BlueprintUI/Tests/UIViewElementTests.swift @@ -109,6 +109,9 @@ class UIViewElementTests: XCTestCase { func test_environment() { enum TestKey: EnvironmentKey { static let defaultValue: Void? = nil + static func isEquivalent(lhs: Void?, rhs: Void?, in context: EquivalencyContext) -> Bool { + lhs == nil && rhs == nil || rhs != nil && lhs != nil + } } @propertyWrapper diff --git a/BlueprintUI/Tests/ValidatingCacheTests.swift b/BlueprintUI/Tests/ValidatingCacheTests.swift new file mode 100644 index 000000000..0d9ae9f3f --- /dev/null +++ b/BlueprintUI/Tests/ValidatingCacheTests.swift @@ -0,0 +1,292 @@ +import Foundation +import Testing +@_spi(CacheStorage) @testable import BlueprintUI + +@MainActor +struct ValidatingCacheTests { + + @Test func setAndRetrieve() { + var cache = ValidatingCache() + var createCount = 0 + var validateCount = 0 + let value = cache.retrieveOrCreate(key: "Hello") { + fatalError() + } create: { + createCount += 1 + return ("World", ()) + } + #expect(value == "World") + #expect(createCount == 1) + #expect(validateCount == 0) + let secondValue = cache.retrieveOrCreate(key: "Hello") { + validateCount += 1 + return true + } create: { + createCount += 1 + return ("Hello", ()) + } + #expect(secondValue == "World") + #expect(createCount == 1) + #expect(validateCount == 1) + } + + @Test func invalidation() { + var cache = ValidatingCache() + var createCount = 0 + var validateCount = 0 + + let value = cache.retrieveOrCreate(key: "Hello") { _ in + validateCount += 1 + return true + } create: { + createCount += 1 + return ("One", ()) + } + #expect(value == "One") + #expect(createCount == 1) + #expect(validateCount == 0) + let secondValue = cache.retrieveOrCreate(key: "Hello") { _ in + validateCount += 1 + return true + } create: { + createCount += 1 + return ("Two", ()) + } + #expect(secondValue == "One") + #expect(createCount == 1) + #expect(validateCount == 1) + + let thirdValue = cache.retrieveOrCreate(key: "Hello") { + validateCount += 1 + return false + } create: { + createCount += 1 + return ("Three", ()) + } + #expect(thirdValue == "Three") + #expect(createCount == 2) + #expect(validateCount == 2) + } + +} + +@MainActor +struct EnvironmentValidatingCacheTests { + + @Test func basic() { + var cache = EnvironmentValidatingCache() + var environment = Environment() + environment[ExampleKey.self] = 1 + let one = cache.retrieveOrCreate(key: "Hello", environment: environment, context: .all) { + _ = $0[ExampleKey.self] + return "One" + } + #expect(one == "One") + + let two = cache.retrieveOrCreate(key: "Hello", environment: environment, context: .all) { + _ = $0[ExampleKey.self] + return "Two" + } + #expect(two == "One") + + let three = cache.retrieveOrCreate(key: "KeyMiss", environment: environment, context: .all) { + _ = $0[ExampleKey.self] + return "Three" + } + #expect(three == "Three") + + var differentEnvironment = environment + differentEnvironment[ExampleKey.self] = 2 + let four = cache.retrieveOrCreate(key: "Hello", environment: differentEnvironment, context: .all) { + _ = $0[ExampleKey.self] + return "Four" + } + #expect(four == "Four") + } + +} + + +@MainActor +struct EnvironmentAndValueValidatingCacheTests { + + @Test func basic() { + var cache = EnvironmentAndValueValidatingCache() + var environment = Environment() + environment[ExampleKey.self] = 1 + let one = cache.retrieveOrCreate( + key: "Hello", + environment: environment, + validationValue: "Validate", + context: .all + ) { + _ = $0[ExampleKey.self] + return "One" + } + #expect(one == "One") + + let two = cache.retrieveOrCreate( + key: "Hello", + environment: environment, + validationValue: "Validate", + context: .all + ) { + _ = $0[ExampleKey.self] + return "Two" + } + #expect(two == "One") + + let three = cache.retrieveOrCreate( + key: "KeyMiss", + environment: environment, + validationValue: "Validate", + context: .all + ) { + _ = $0[ExampleKey.self] + return "Three" + } + #expect(three == "Three") + + var differentEnvironment = environment + differentEnvironment[ExampleKey.self] = 2 + let four = cache.retrieveOrCreate( + key: "Hello", + environment: differentEnvironment, + validationValue: "Validate", + context: .all + ) { + _ = $0[ExampleKey.self] + return "Four" + } + #expect(four == "Four") + + let five = cache.retrieveOrCreate( + key: "Hello", + environment: differentEnvironment, + validationValue: "Invalid", + context: .all + ) { _ in + "Five" + } + #expect(five == "Five") + } + + @Test func basicElementsAndPaths() { + + var cache = EnvironmentAndValueValidatingCache() + let elementOne = TestCachedElement(value: "Hello") + let elementOnePath = "some/element/path" + let elementTwo = TestCachedElement(value: "Hi") + let elementTwoPath = "some/other/path" + let elementOneModified = TestCachedElement(value: "Hello World") + var environment = Environment() + + var evaluationCount = 0 + func sizeForElement(element: TestCachedElement) -> CGSize { + evaluationCount += 1 + // Fake size obviously, for demo purposes + return CGSize(width: element.value.count * 10, height: 100) + } + + // First will be a key miss, so evaluate. + let firstSize = cache.retrieveOrCreate( + key: elementOnePath, + environment: environment, + validationValue: elementOne, + context: .elementSizing + ) { _ in + sizeForElement(element: elementOne) + } + #expect(firstSize == CGSize(width: 50, height: 100)) + #expect(evaluationCount == 1) + + // Second will be a key miss also, so evaluate. + let secondSize = cache.retrieveOrCreate( + key: elementTwoPath, + environment: environment, + validationValue: elementTwo, + context: .elementSizing + ) { _ in + sizeForElement(element: elementTwo) + } + #expect(secondSize == CGSize(width: 20, height: 100)) + #expect(evaluationCount == 2) + + // Querying first size again with matching environment and validation value. Cache hit, validation pass, no evaluation. + let firstSizeAgain = cache.retrieveOrCreate( + key: elementOnePath, + environment: environment, + validationValue: elementOne, + context: .elementSizing + ) { _ in + sizeForElement(element: elementOne) + } + #expect(firstSizeAgain == CGSize(width: 50, height: 100)) + #expect(evaluationCount == 2) + + // Querying first size again with matching environment and non-matching validation value. Cache hit, validation fail, evaluation. + let firstSizeWithNewElement = cache.retrieveOrCreate( + key: elementOnePath, + environment: environment, + validationValue: elementOneModified, + context: .elementSizing + ) { _ in + sizeForElement(element: elementOneModified) + } + #expect(firstSizeWithNewElement == CGSize(width: 110, height: 100)) + #expect(evaluationCount == 3) + + // Querying first size again with matching environment and validation value. Cache hit, validation pass, no evaluation. + let firstSizeWithNewElementAgain = cache.retrieveOrCreate( + key: elementOnePath, + environment: environment, + validationValue: elementOneModified, + context: .elementSizing + ) { _ in + sizeForElement(element: elementOneModified) + } + #expect(firstSizeWithNewElementAgain == CGSize(width: 110, height: 100)) + #expect(evaluationCount == 3) + + // Querying first size again with matching environment and original validation value. Cache hit, validation fail (because we don't preserve old values for keys with different validations), evaluation. + let originalFirstSizeAgain = cache.retrieveOrCreate( + key: elementOnePath, + environment: environment, + validationValue: elementOne, + context: .elementSizing + ) { _ in + sizeForElement(element: elementOne) + } + #expect(originalFirstSizeAgain == CGSize(width: 50, height: 100)) + #expect(evaluationCount == 4) + + // Querying first size again with non-equivalent environment and matching validation value. Cache hit, validation fail (due to environment diff), evaluation. + environment[ExampleKey.self] = 1 + let firstSizeWithNewEnvironment = cache.retrieveOrCreate( + key: elementOnePath, + environment: environment, + validationValue: elementOneModified, + context: .elementSizing + ) { _ in + sizeForElement(element: elementOne) + } + #expect(firstSizeWithNewEnvironment == CGSize(width: 50, height: 100)) + #expect(evaluationCount == 5) + + + } + +} + +struct TestCachedElement: Element, Equatable, ContextuallyEquivalent { + let value: String + + var content: ElementContent { + fatalError() + } + + func backingViewDescription(with context: ViewDescriptionContext) -> ViewDescription? { + fatalError() + } + +} diff --git a/BlueprintUICommonControls/Sources/AttributedLabel+Environment.swift b/BlueprintUICommonControls/Sources/AttributedLabel+Environment.swift index ac81cb1f9..4116d2698 100644 --- a/BlueprintUICommonControls/Sources/AttributedLabel+Environment.swift +++ b/BlueprintUICommonControls/Sources/AttributedLabel+Environment.swift @@ -32,6 +32,10 @@ public struct URLHandlerEnvironmentKey: EnvironmentKey { return DefaultURLHandler() } }() + + public static func isEquivalent(lhs: any URLHandler, rhs: any URLHandler, in context: EquivalencyContext) -> Bool { + alwaysEquivalentIn([.elementSizing], evaluatingContext: context) + } } extension Environment { diff --git a/BlueprintUICommonControls/Sources/AttributedLabel.swift b/BlueprintUICommonControls/Sources/AttributedLabel.swift index b9f642212..6eab633e3 100644 --- a/BlueprintUICommonControls/Sources/AttributedLabel.swift +++ b/BlueprintUICommonControls/Sources/AttributedLabel.swift @@ -1,8 +1,9 @@ import BlueprintUI import Foundation import UIKit +@_spi(CacheStorage) import BlueprintUI -public struct AttributedLabel: Element, Hashable { +public struct AttributedLabel: Element, Hashable, ContextuallyEquivalent { /// 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(validationKey: 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 @@ -211,9 +211,6 @@ extension AttributedLabel { } } - // Store bounding shapes in this cache to avoid costly recalculations - private var boundingShapeCache: [Link: Link.BoundingShape] = [:] - override var accessibilityCustomRotors: [UIAccessibilityCustomRotor]? { set { assertionFailure("accessibilityCustomRotors is not settable.") } get { !linkElements.isEmpty ? [linkElements.accessibilityRotor(systemType: .link)] : [] } @@ -225,46 +222,6 @@ extension AttributedLabel { var urlHandler: URLHandler? - override init(frame: CGRect) { - super.init(frame: frame) - - if #available(iOS 17.0, *) { - registerForTraitChanges([UITraitPreferredContentSizeCategory.self]) { ( - view: LabelView, - previousTraitCollection: UITraitCollection - ) in - view.invalidateLinkBoundingShapeCaches() - } - } else { - NotificationCenter - .default - .addObserver( - self, - selector: #selector(sizeCategoryChanged(notification:)), - name: UIContentSizeCategory.didChangeNotification, - object: nil - ) - } - } - - deinit { - if #available(iOS 17.0, *) { - // Do nothing - } else { - NotificationCenter - .default - .removeObserver(self) - } - } - - @objc private func sizeCategoryChanged(notification: Notification) { - invalidateLinkBoundingShapeCaches() - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - func update(model: AttributedLabel, text: NSAttributedString, environment: Environment, isMeasuring: Bool) { let previousAttributedText = isMeasuring ? nil : attributedText @@ -294,8 +251,6 @@ extension AttributedLabel { layoutDirection = environment.layoutDirection if !isMeasuring { - invalidateLinkBoundingShapeCaches() - if previousAttributedText != attributedText { links = attributedLinks(in: model.attributedText) + detectedDataLinks(in: model.attributedText) accessibilityLabel = accessibilityLabel( @@ -689,38 +644,8 @@ extension AttributedLabel { trackingLinks = nil applyLinkColors() } - - override func layoutSubviews() { - super.layoutSubviews() - - invalidateLinkBoundingShapeCaches() - } - - func boundingShape(for link: Link) -> Link.BoundingShape { - if let cachedShape = boundingShapeCache[link] { - return cachedShape - } - - let calculatedShape = link.calculateBoundingShape() - boundingShapeCache[link] = calculatedShape - return calculatedShape - } - - private func invalidateLinkBoundingShapeCaches() { - boundingShapeCache.removeAll() - } } -} -extension AttributedLabel.LabelView { - // Without this, we were seeing console messages like the following: - // "LabelView implements focusItemsInRect: - caching for linear focus movement is limited as long as this view is on screen." - // It's unclear as to why they are appearing despite using the API in the intended manner. - // To suppress the messages, we implemented this function much like Apple did with `UITableView`, - // `UICollectionView`, etc. - @objc private class func _supportsInvalidatingFocusCache() -> Bool { - true - } } extension AttributedLabel { @@ -749,10 +674,6 @@ extension AttributedLabel { } var boundingShape: BoundingShape { - container?.boundingShape(for: self) ?? calculateBoundingShape() - } - - fileprivate func calculateBoundingShape() -> BoundingShape { guard let container = container, let textStorage = container.makeTextStorage(), let layoutManager = textStorage.layoutManagers.first, @@ -855,7 +776,7 @@ extension AttributedLabel { override var accessibilityPath: UIBezierPath? { set { assertionFailure("cannot set accessibilityPath") } get { - if let path = link.boundingShape.path?.copy() as? UIBezierPath, let container = link.container { + if let path = link.boundingShape.path, let container = link.container { return UIAccessibility.convertToScreenCoordinates(path, in: container) } @@ -964,7 +885,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.cacheStorage.attributedStringNormalizationCache[key] { + return cached + } var attributedText = AttributedText(self) for run in attributedText.runs { @@ -1000,7 +925,11 @@ extension NSAttributedString { attributedText.paragraphStyle = paragraphStyle } - return attributedText.attributedString + let resolved = attributedText.attributedString + if environment.layoutMode.options.stringNormalizationCache { + environment.cacheStorage.attributedStringNormalizationCache[key] = resolved + } + return resolved } } @@ -1022,3 +951,18 @@ extension String { } } +fileprivate struct AttributedStringNormalizationKey: Hashable { + let label: NSAttributedString + let lines: Int +} + +extension CacheStorage { + private struct AttributedStringNormalizationCacheKey: CacheStorage.Key { + static let emptyValue: [AttributedStringNormalizationKey: NSAttributedString] = [:] + } + + fileprivate var attributedStringNormalizationCache: [AttributedStringNormalizationKey: NSAttributedString] { + get { self[AttributedStringNormalizationCacheKey.self] } + set { self[AttributedStringNormalizationCacheKey.self] = newValue } + } +} diff --git a/SampleApp/Sources/PostsViewController.swift b/SampleApp/Sources/PostsViewController.swift index 2e308385e..b40c428ba 100644 --- a/SampleApp/Sources/PostsViewController.swift +++ b/SampleApp/Sources/PostsViewController.swift @@ -122,7 +122,12 @@ final class PostsViewController: UIViewController { } extension Environment { + private enum FeedThemeKey: EnvironmentKey { + static func isEquivalent(lhs: FeedTheme, rhs: FeedTheme, in context: BlueprintUI.EquivalencyContext) -> Bool { + alwaysEquivalentIn([.elementSizing], evaluatingContext: context) + } + static let defaultValue = FeedTheme(authorColor: .black) }