diff --git a/BlueprintUI/Sources/CrossLayoutCaching/Cache/CacheImplementations/EnvironmentAndValueValidatingCache.swift b/BlueprintUI/Sources/CrossLayoutCaching/Cache/CacheImplementations/EnvironmentAndValueValidatingCache.swift new file mode 100644 index 000000000..2eb2440a2 --- /dev/null +++ b/BlueprintUI/Sources/CrossLayoutCaching/Cache/CacheImplementations/EnvironmentAndValueValidatingCache.swift @@ -0,0 +1,55 @@ +import Foundation + +@_spi(HostingViewContext) extension EnvironmentAndValueValidatingCache where AdditionalValidationData: CrossLayoutCacheable { + + /// 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: CrossLayoutCacheableContext, + create: (Environment) -> (Value) + ) -> Value { + retrieveOrCreate(key: key, environment: environment, context: context) { + $0.isCacheablyEquivalent(to: validationValue, in: context) + } create: { + (create($0), validationValue) + } + + } + +} + +@_spi(HostingViewContext) 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: CrossLayoutCacheableContext, + create: (Environment) -> (Value) + ) -> Value { + retrieveOrCreate(key: key, environment: environment, context: context) { + $0 == validationValue + } create: { + (create($0), validationValue) + } + } + +} + diff --git a/BlueprintUI/Sources/CrossLayoutCaching/Cache/CacheImplementations/EnvironmentValidatingCache.swift b/BlueprintUI/Sources/CrossLayoutCaching/Cache/CacheImplementations/EnvironmentValidatingCache.swift new file mode 100644 index 000000000..8af9cdb4a --- /dev/null +++ b/BlueprintUI/Sources/CrossLayoutCaching/Cache/CacheImplementations/EnvironmentValidatingCache.swift @@ -0,0 +1,33 @@ +import Foundation + +/// A convenience wrapper around ValidatingCache which ensures that only values which were cached in equivalent environments are returned. +@_spi(HostingViewContext) 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: CrossLayoutCacheableContext, + create: (Environment) -> Value + ) -> Value { + backing.retrieveOrCreate(key: key) { + environment.isCacheablyEquivalent(to: $0, in: context) + } create: { + environment.observingAccess { environment in + create(environment) + } + } + } + +} + diff --git a/BlueprintUI/Sources/CrossLayoutCaching/Cache/CacheImplementations/ValidatingCache.swift b/BlueprintUI/Sources/CrossLayoutCaching/Cache/CacheImplementations/ValidatingCache.swift new file mode 100644 index 000000000..5b417a15e --- /dev/null +++ b/BlueprintUI/Sources/CrossLayoutCaching/Cache/CacheImplementations/ValidatingCache.swift @@ -0,0 +1,112 @@ +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(HostingViewContext) 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.logValidatingCrossLayoutCacheKeyHit(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.logValidatingCrossLayoutCacheKeyMiss(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, and allows for additional data to be stored to be validated. +@_spi(HostingViewContext) 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: CrossLayoutCacheableContext, + validate: (AdditionalValidationData) -> Bool, + create: (Environment) -> (Value, AdditionalValidationData) + ) -> Value { + backing.retrieveOrCreate(key: key) { + environment.isCacheablyEquivalent(to: $0.0, in: context) && validate($0.1) + } create: { + let ((value, additional), accessList) = environment.observingAccess { environment in + create(environment) + } + return (value, (accessList, additional)) + } + } + +} + +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/CrossLayoutCaching/Cache/CacheKey.swift b/BlueprintUI/Sources/CrossLayoutCaching/Cache/CacheKey.swift new file mode 100644 index 000000000..89d735e04 --- /dev/null +++ b/BlueprintUI/Sources/CrossLayoutCaching/Cache/CacheKey.swift @@ -0,0 +1,27 @@ +import Foundation + +/// Types conforming to this protocol can be used as keys in `HostingViewContext`. +/// +/// Using a type as the key allows us to strongly type each value, with the +/// key's `CrossLayoutCacheKey.Value` associated value. +/// +/// ## Example +/// +/// Usually a key is implemented with an uninhabited type, such an empty enum. +/// +/// enum WidgetCountsKey: CrossLayoutCacheKey { +/// static let emptyValue: [WidgetID: Int] = [:] +/// } +/// +/// You can write a small extension on `HostingViewContext` to make it easier to use your key. +/// +/// extension HostingViewContext { +/// var widgetCounts: [WidgetID: Int] { +/// get { self[WidgetCountsKey.self] } +/// set { self[WidgetCountsKey.self] = newValue } +/// } +/// } +public protocol CrossLayoutCacheKey { + associatedtype Value + static var emptyValue: Self.Value { get } +} diff --git a/BlueprintUI/Sources/CrossLayoutCaching/Cache/HostingViewContext.swift b/BlueprintUI/Sources/CrossLayoutCaching/Cache/HostingViewContext.swift new file mode 100644 index 000000000..794dfdcbf --- /dev/null +++ b/BlueprintUI/Sources/CrossLayoutCaching/Cache/HostingViewContext.swift @@ -0,0 +1,85 @@ +import Foundation +import UIKit + +/// 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 `CrossLayoutCacheKey` protocol +/// Caches are responsible for managing their own lifetimes and eviction strategies. +@_spi(HostingViewContext) public final class HostingViewContext: Sendable, CustomDebugStringConvertible { + + // Optional name to distinguish between instances for debugging purposes. + public var name: String? = nil + fileprivate var storage: [ObjectIdentifier: Any] = [:] + + init() { + NotificationCenter.default.addObserver( + forName: UIApplication.didReceiveMemoryWarningNotification, + object: nil, + queue: .main + ) { [weak self] _ in + self?.storage.removeAll() + } + } + + public subscript(key: KeyType.Type) -> KeyType.Value where KeyType: CrossLayoutCacheKey { + get { + storage[ObjectIdentifier(key), default: KeyType.emptyValue] as! KeyType.Value + } + set { + storage[ObjectIdentifier(key)] = newValue + } + } + + public var debugDescription: String { + let debugName = if let name { + "HostingViewContext (\(name))" + } else { + "HostingViewContext" + } + return "\(debugName): \(storage.count) entries" + } + +} + +extension Environment { + + struct HostingViewContextKey: InternalEnvironmentKey { + static var defaultValue = HostingViewContext() + } + + + @_spi(HostingViewContext) public var hostingViewContext: HostingViewContext { + get { self[HostingViewContextKey.self] } + set { self[HostingViewContextKey.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 CacheComparisonFingerprint: CrossLayoutCacheable, 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 isCacheablyEquivalent(to other: CacheComparisonFingerprint?, in context: CrossLayoutCacheableContext) -> Bool { + value == other?.value + } + + var description: String { + value.uuidString + } + +} + diff --git a/BlueprintUI/Sources/Environment/Environment.swift b/BlueprintUI/Sources/Environment/Environment.swift index 3a122b38d..06d5abec7 100644 --- a/BlueprintUI/Sources/Environment/Environment.swift +++ b/BlueprintUI/Sources/Environment/Environment.swift @@ -40,7 +40,11 @@ public struct Environment { /// Each key will return its default value. public static let empty = Environment() + // Fingerprint used for referencing previously compared environments. + var fingerprint = CacheComparisonFingerprint() + private var values: [Keybox: Any] = [:] + private var observingAccess: ObservingAccessListEnvironment? // Internal values are hidden from consumers and do not participate in cross-layout cacheability checks. private var internalValues: [ObjectIdentifier: Any] = [:] @@ -52,15 +56,21 @@ public struct Environment { } set { let keybox = Keybox(key) + let oldValue = values[keybox] values[keybox] = newValue + fingerprint.modified() } } private subscript(keybox: Keybox) -> Any { - values[keybox, default: keybox.type.defaultValue] + let value = values[keybox, default: keybox.type.defaultValue] + if let observingAccess { + observingAccess.value.values[keybox] = value + } + return value } - public subscript(internal key: Key.Type) -> Key.Value where Key: EnvironmentKey { + subscript(key: Key.Type) -> Key.Value where Key: InternalEnvironmentKey { get { internalValues[ObjectIdentifier(key), default: key.defaultValue] as! Key.Value } @@ -80,23 +90,168 @@ public struct Environment { func merged(prioritizing other: Environment) -> Environment { var merged = self merged.values.merge(other.values) { $1 } + merged.fingerprint.modified() return merged } + func observingAccess(_ closure: (Environment) -> T) -> (T, EnvironmentAccessList) { + var watching = self + let observingAccess = ObservingAccessListEnvironment() + watching.observingAccess = observingAccess + let result = closure(watching) + return (result, observingAccess.value) + } + +} + +/// An environment access list is frozen-in-time copy of the comparable elements of an Environment struct that were accessed during the cached value's creaton. +struct EnvironmentAccessList { + + // Fingerprint used for referencing previously compared environments. + var fingerprint: CacheComparisonFingerprint + var values: [Environment.Keybox: Any] } +private final class ObservingAccessListEnvironment { + var value = EnvironmentAccessList(fingerprint: .init(), values: [:]) +} + extension Environment: CrossLayoutCacheable { public func isCacheablyEquivalent(to other: Self?, in context: CrossLayoutCacheableContext) -> Bool { guard let other else { return false } + if fingerprint.isCacheablyEquivalent(to: other.fingerprint) { + Logger.logEnvironmentEquivalencyFingerprintEqual(environment: self) + return true + } + if let evaluated = hostingViewContext.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 { + guard key.isCacheablyEquivalent(self[key], other[key], context) else { + hostingViewContext.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) + hostingViewContext.environmentComparisonCache[fingerprint, other.fingerprint, context] = true + return true + } + + func isCacheablyEquivalent(to accessList: EnvironmentAccessList?, in context: CrossLayoutCacheableContext) -> Bool { + guard let accessList else { return false } + // We don't even need to thaw the environment if the fingerprints match. + if accessList.fingerprint.isCacheablyEquivalent(to: fingerprint) { + Logger.logEnvironmentEquivalencyFingerprintEqual(environment: self) + return true + } + let scope = Set(accessList.values.keys.map(\.objectIdentifier)) + if let evaluated = hostingViewContext.environmentComparisonCache[fingerprint, accessList.fingerprint, context, scope] { + Logger.logEnvironmentEquivalencyFingerprintCacheHit(environment: self) + return evaluated + } + Logger.logEnvironmentEquivalencyFingerprintCacheMiss(environment: self) + let token = Logger.logEnvironmentEquivalencyComparisonStart(environment: self) + for (key, value) in accessList.values { + guard key.isCacheablyEquivalent(self[key], value, context) else { + hostingViewContext.environmentComparisonCache[fingerprint, accessList.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) + hostingViewContext.environmentComparisonCache[fingerprint, accessList.fingerprint, context, scope] = true return true + + } + + +} + +extension HostingViewContext { + + fileprivate struct EnvironmentFingerprintCache { + + struct Key: Hashable { + let lhs: CacheComparisonFingerprint.Value + let rhs: CacheComparisonFingerprint.Value + let scope: Set? + + init(_ lhs: CacheComparisonFingerprint.Value, _ rhs: CacheComparisonFingerprint.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 = [CrossLayoutCacheableContext: Bool] + var storage: [Key: [CrossLayoutCacheableContext: Bool]] = [:] + + public subscript( + lhs: CacheComparisonFingerprint, + rhs: CacheComparisonFingerprint, + context: CrossLayoutCacheableContext, + 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 EnvironmentComparisonCrossLayoutCacheKey: CrossLayoutCacheKey { + static var emptyValue = EnvironmentFingerprintCache() + } + + fileprivate var environmentComparisonCache: EnvironmentFingerprintCache { + get { self[EnvironmentComparisonCrossLayoutCacheKey.self] } + set { self[EnvironmentComparisonCrossLayoutCacheKey.self] = newValue } } } @@ -108,14 +263,14 @@ extension Environment { let objectIdentifier: ObjectIdentifier let type: any EnvironmentKey.Type - let isEquivalent: (Any?, Any?, CrossLayoutCacheableContext) -> Bool + let isCacheablyEquivalent: (Any?, Any?, CrossLayoutCacheableContext) -> Bool init(_ type: EnvironmentKeyType.Type) { objectIdentifier = ObjectIdentifier(type) self.type = type - isEquivalent = { + isCacheablyEquivalent = { 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) + return type.isCacheablyEquivalent(lhs: lhs, rhs: rhs, in: $2) } } diff --git a/BlueprintUI/Sources/Environment/EnvironmentKey.swift b/BlueprintUI/Sources/Environment/EnvironmentKey.swift index 649f25107..a141ca517 100644 --- a/BlueprintUI/Sources/Environment/EnvironmentKey.swift +++ b/BlueprintUI/Sources/Environment/EnvironmentKey.swift @@ -34,13 +34,13 @@ public protocol EnvironmentKey { /// - 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: CrossLayoutCacheableContext) -> Bool + static func isCacheablyEquivalent(lhs: Value, rhs: Value, in context: CrossLayoutCacheableContext) -> Bool } extension EnvironmentKey where Value: Equatable { - public static func isEquivalent(lhs: Value, rhs: Value, in context: CrossLayoutCacheableContext) -> Bool { + public static func isCacheablyEquivalent(lhs: Value, rhs: Value, in context: CrossLayoutCacheableContext) -> Bool { lhs == rhs } diff --git a/BlueprintUI/Sources/Internal/InternalEnvironmentKey.swift b/BlueprintUI/Sources/Internal/InternalEnvironmentKey.swift new file mode 100644 index 000000000..977fada5e --- /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 isCacheablyEquivalent(lhs: Value, rhs: Value, in context: CrossLayoutCacheableContext) -> Bool { + true + } + +} diff --git a/BlueprintUI/Sources/Internal/Logger.swift b/BlueprintUI/Sources/Internal/Logger.swift index b2074465a..349076315 100644 --- a/BlueprintUI/Sources/Internal/Logger.swift +++ b/BlueprintUI/Sources/Internal/Logger.swift @@ -2,12 +2,18 @@ import Foundation import os.log /// Namespace for logging helpers -enum Logger {} +enum Logger { + fileprivate static var signposter: OSSignposter { + 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 +106,8 @@ extension Logger { } } -/// Measuring signposts +// MARK: - HintingSizeCache signposts + extension Logger { static func logMeasureStart(object: AnyObject, description: String, constraint: SizeConstraint) { @@ -185,10 +192,169 @@ extension Logger { ) } - // MARK: Utilities + +} + +// MARK: - HostingViewContext + +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: CrossLayoutCacheableContext + ) { + 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: CrossLayoutCacheableContext) { + 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 logValidatingCrossLayoutCacheKeyMiss(key: some Hashable) { + guard BlueprintLogging.isEnabled else { return } + signposter.emitEvent("ValidatingCache key miss", id: key.signpost) + hook?("\(#function) \(String(describing: key))") + } + + static func logValidatingCrossLayoutCacheKeyHit(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/Tests/EnvironmentEquivalencyTests.swift b/BlueprintUI/Tests/EnvironmentEquivalencyTests.swift new file mode 100644 index 000000000..0f9ff6eb5 --- /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.isCacheablyEquivalent(to: b, in: .all)) + #expect(a.isCacheablyEquivalent(to: b, in: .elementSizing)) + } + + @Test func simpleChange() { + var a = Environment() + a[ExampleKey.self] = 1 + let b = Environment() + #expect(!a.isCacheablyEquivalent(to: b, in: .all)) + #expect(!a.isCacheablyEquivalent(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.isCacheablyEquivalent(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.isCacheablyEquivalent(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.isCacheablyEquivalent(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.isCacheablyEquivalent(to: d)) + } + + @Test func modification() { + var a = Environment() + let b = a + a[ExampleKey.self] = 1 + #expect(!a.isCacheablyEquivalent(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.isCacheablyEquivalent(to: b)) + #expect(hookedResult.contains("logEnvironmentEquivalencyFingerprintCacheMiss(environment:) \(a.fingerprint)")) + + hookedResult = [] + #expect(!a.isCacheablyEquivalent(to: b)) + // Subsequent comparison should be cached + #expect(hookedResult.contains("logEnvironmentEquivalencyFingerprintCacheHit(environment:) \(a.fingerprint)")) + + hookedResult = [] + #expect(!b.isCacheablyEquivalent(to: a)) + // Reversed order should still be cached + #expect(hookedResult.contains("logEnvironmentEquivalencyFingerprintCacheHit(environment:) \(b.fingerprint)")) + + hookedResult = [] + let c = b + #expect(!a.isCacheablyEquivalent(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.isCacheablyEquivalent(to: b, in: .elementSizing)) + #expect(hookedResult.contains("logEnvironmentEquivalencyFingerprintCacheMiss(environment:) \(a.fingerprint)")) + + hookedResult = [] + #expect(!a.isCacheablyEquivalent(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.isCacheablyEquivalent(to: d, in: .all)) + #expect(hookedResult.contains("logEnvironmentEquivalencyFingerprintCacheMiss(environment:) \(c.fingerprint)")) + + hookedResult = [] + #expect(c.isCacheablyEquivalent(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.isCacheablyEquivalent(to: f, in: .elementSizing)) + #expect(hookedResult.contains("logEnvironmentEquivalencyFingerprintCacheMiss(environment:) \(e.fingerprint)")) + + hookedResult = [] + #expect(!e.isCacheablyEquivalent(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 isCacheablyEquivalent(lhs: Int, rhs: Int, in context: CrossLayoutCacheableContext) -> Bool { + alwaysEquivalentIn([.elementSizing], evaluatingContext: context) + } +} diff --git a/BlueprintUI/Tests/UIViewElementTests.swift b/BlueprintUI/Tests/UIViewElementTests.swift index 5b34b6272..0accf00cc 100644 --- a/BlueprintUI/Tests/UIViewElementTests.swift +++ b/BlueprintUI/Tests/UIViewElementTests.swift @@ -109,7 +109,7 @@ class UIViewElementTests: XCTestCase { func test_environment() { enum TestKey: EnvironmentKey { static let defaultValue: Void? = nil - static func isEquivalent(lhs: Void?, rhs: Void?, in context: CrossLayoutCacheableContext) -> Bool { + static func isCacheablyEquivalent(lhs: Void?, rhs: Void?, in context: CrossLayoutCacheableContext) -> Bool { lhs == nil && rhs == nil || rhs != nil && lhs != nil } } diff --git a/BlueprintUI/Tests/ValidatingCacheTests.swift b/BlueprintUI/Tests/ValidatingCacheTests.swift new file mode 100644 index 000000000..70fde0bd4 --- /dev/null +++ b/BlueprintUI/Tests/ValidatingCacheTests.swift @@ -0,0 +1,292 @@ +import Foundation +import Testing +@_spi(HostingViewContext) @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, CrossLayoutCacheable { + 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 328c5b5b8..b574d11d9 100644 --- a/BlueprintUICommonControls/Sources/AttributedLabel+Environment.swift +++ b/BlueprintUICommonControls/Sources/AttributedLabel+Environment.swift @@ -33,7 +33,7 @@ public struct URLHandlerEnvironmentKey: EnvironmentKey { } }() - public static func isEquivalent(lhs: any URLHandler, rhs: any URLHandler, in context: CrossLayoutCacheableContext) -> Bool { + public static func isCacheablyEquivalent(lhs: any URLHandler, rhs: any URLHandler, in context: CrossLayoutCacheableContext) -> Bool { alwaysEquivalentIn([.elementSizing], evaluatingContext: context) } } diff --git a/SampleApp/Sources/PostsViewController.swift b/SampleApp/Sources/PostsViewController.swift index 27560b748..ea8128dad 100644 --- a/SampleApp/Sources/PostsViewController.swift +++ b/SampleApp/Sources/PostsViewController.swift @@ -124,7 +124,7 @@ final class PostsViewController: UIViewController { extension Environment { private enum FeedThemeKey: EnvironmentKey { - static func isEquivalent(lhs: FeedTheme, rhs: FeedTheme, in context: BlueprintUI.CrossLayoutCacheableContext) -> Bool { + static func isCacheablyEquivalent(lhs: FeedTheme, rhs: FeedTheme, in context: BlueprintUI.CrossLayoutCacheableContext) -> Bool { alwaysEquivalentIn([.elementSizing], evaluatingContext: context) }