Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
a63d20d
Add equivalency protocols and implementations
maxg-square Jul 16, 2025
3abebaf
CacheStorage
maxg-square Jul 16, 2025
9b87e2a
Add MeasurableStorage caching.
maxg-square Jul 16, 2025
6802a22
Add skipUnneededSetNeedsViewHierarchyUpdates
maxg-square Jul 16, 2025
6429e56
Cache string normalization
maxg-square Jul 16, 2025
76aee10
Fix key name
maxg-square Jul 16, 2025
44aa682
Fix testkey
maxg-square Jul 16, 2025
10d11e9
Merge branch 'maxg/cache_1_equiv' into maxg/cache_2_envcache
maxg-square Jul 16, 2025
ee32e1c
Merge branch 'maxg/cache_2_envcache' into maxg/cache_3a_measurable
maxg-square Jul 16, 2025
23a1dee
Merge branch 'maxg/cache_2_envcache' into maxg/cache_3b_needsvhu
maxg-square Jul 16, 2025
dad612b
Merge branch 'maxg/cache_2_envcache' into cache_3c_stringnorm
maxg-square Jul 16, 2025
0e78167
Fix testkey
maxg-square Jul 16, 2025
8a39fce
include other TestKeys
maxg-square Jul 16, 2025
7c6b2dc
Merge branch 'maxg/cache_1_equivalency' into maxg/cache_2_envcache
maxg-square Jul 16, 2025
e46e778
Merge branch 'maxg/cache_2_envcache' into maxg/cache_3a_measurable
maxg-square Jul 16, 2025
5faf9e1
Merge branch 'maxg/cache_2_envcache' into maxg/cache_3b_needsvhu
maxg-square Jul 16, 2025
5ef770e
Merge branch 'maxg/cache_2_envcache' into maxg/cache_3c_stringnorm
maxg-square Jul 16, 2025
6dafc50
Fix HSC tests
maxg-square Jul 17, 2025
56b751d
Fix HSC tests
maxg-square Jul 17, 2025
4a126e7
Fix HSC tests
maxg-square Jul 17, 2025
499ee96
Fix HSC tests
maxg-square Jul 17, 2025
15d0c5d
Tmp merge
maxg-square Jul 17, 2025
0d413d3
Tmp merge
maxg-square Jul 17, 2025
94e9b23
Merge
maxg-square Jul 17, 2025
1c45f7f
LASC
maxg-square Jul 17, 2025
9afcdd1
Merge
maxg-square Jul 25, 2025
deb85cb
Cleanup
maxg-square Jul 25, 2025
42104f9
Merge branch 'maxg/cache_1_equivalency' into maxg/cache_2_envcache
maxg-square Jul 25, 2025
398dc40
Merge.
maxg-square Jul 25, 2025
22ed032
Merge branch 'maxg/cache_2_envcache' into maxg/cache_3a_measurable
maxg-square Jul 25, 2025
be9dd21
Merge
maxg-square Jul 25, 2025
942c1fa
Tests and fixes
maxg-square Jul 25, 2025
6519418
More tests
maxg-square Jul 25, 2025
e8c27c0
Merge branch 'maxg/cache_2_envcache' into maxg/cache_3a_measurable
maxg-square Jul 25, 2025
872bcaa
Merge
maxg-square Jul 25, 2025
afbbc3c
More tests
maxg-square Jul 25, 2025
f5ea2eb
Merge branch 'main' into maxg/cache_3x_all
maxg-square Jul 25, 2025
f4c5c2e
Tweak env tests
maxg-square Jul 28, 2025
c2e2ddc
Merge branch 'maxg/cache_2_envcache' into maxg/cache_3x_all
maxg-square Jul 28, 2025
9c0da9f
Existentials for Xcode 15
maxg-square Jul 28, 2025
1842de6
Merge branch 'main' into maxg/cache_2_envcache
maxg-square Jul 28, 2025
0f19b47
Merge branch 'maxg/cache_2_envcache' into maxg/cache_3x_all
maxg-square Jul 28, 2025
707cc8d
Log guard
maxg-square Jul 30, 2025
3ec654b
Log guard
maxg-square Jul 30, 2025
76cac01
Tweak internal env api.
maxg-square Jul 30, 2025
ea63760
Enable logging for test.
maxg-square Jul 30, 2025
f35c210
Merge branch 'maxg/cache_2_envcache' into maxg/cache_3x_all
maxg-square Jul 30, 2025
a599c4a
Snapshot
maxg-square Jul 30, 2025
993355b
Name
maxg-square Jul 31, 2025
094dbcd
Fix tests.
maxg-square Jul 31, 2025
33979ef
Feedback.
maxg-square Aug 12, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions BlueprintUI/Sources/BlueprintView/BlueprintView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ public final class BlueprintView: UIView {

private var sizesThatFit: [SizeConstraint: CGSize] = [:]

private var cacheStorage = Environment.CacheStorageEnvironmentKey.defaultValue
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I think we need to do some sort of garbage collection on this, right? Eg, evict paths that are no longer present after a layout update?

Copy link
Contributor

Choose a reason for hiding this comment

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

I experimented a bit with this – (basically just tweaking the traverse method here to also include paths, comparing before/after update) – in the sample app it didn't seem to make much of a difference, and actually seemed to produce some false positives (especially in list contexts) – relying on BlueprintView deinit seemed much more consistent with the eviction behavior I'd expect.


/// 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.
Expand All @@ -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()
}
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -148,6 +164,7 @@ public final class BlueprintView: UIView {

self.element = element
self.environment = environment
self.environment.cacheStorage = cacheStorage

rootController = NativeViewController(
node: NativeViewNode(
Expand Down Expand Up @@ -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?()
Expand Down
11 changes: 11 additions & 0 deletions BlueprintUI/Sources/Element/ElementContent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand Down
62 changes: 61 additions & 1 deletion BlueprintUI/Sources/Element/MeasurableStorage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Unrelated to your PR, but TIL we store the path as a string, I thought we'd keep the fully qualified ElementPath around

return environment.cacheStorage.measurableStorageCache.retrieveOrCreate(
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Thought: Could we stick making the key and looking up the value on LayoutTreeNode (in a lazy way) so that we don't need to do this key creation and lookup dance outside of a single spot / in every Storage that will need it?

key: key,
environment: environment,
validationValue: validationKey,
context: .elementSizing,
) { environment in
measurer(proposal, environment)
}
}

func performCaffeinatedLayout(
Expand All @@ -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) {
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Nit: Shouldn't need this

Copy link
Contributor

Choose a reason for hiding this comment

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

Artifact of me precalculating the hash in an earlier rev

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 }
}

}
32 changes: 32 additions & 0 deletions BlueprintUI/Sources/Environment/Cache/CacheKey.swift
Original file line number Diff line number Diff line change
@@ -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 }
}

}
89 changes: 89 additions & 0 deletions BlueprintUI/Sources/Environment/Cache/CacheStorage.swift
Original file line number Diff line number Diff line change
@@ -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<KeyType>(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
}

}

Loading
Loading