Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Move view cache to Blueprint hierarchy #531

Draft
wants to merge 9 commits into
base: main
Choose a base branch
from
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ xcuserdata/
Pods/
*.xcworkspace

# Tuist
Derived/

# SPM

# Disabled so that we can commit the generated schemes that live in
Expand Down
9 changes: 9 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 lazy var viewCache: TypeKeyedCache = .init()

/// 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 Down Expand Up @@ -525,6 +527,13 @@ public final class BlueprintView: UIView {

var environment = inherited.merged(prioritizing: environment)

/// We're a root `BlueprintView`, so pass in the view measurer for our downstream
/// dependencies if we didn't inherit a measurement cache.

if environment.inheritedViewCache == nil {
environment.viewCache = viewCache
}
Comment on lines +533 to +535
Copy link
Member Author

Choose a reason for hiding this comment

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

I'm still a little nervous about cascading view caches through the environment. Won't it still leave open the possibility that nested blueprint view elements leak stuff through long(er)-lived caches?

Copy link
Collaborator

Choose a reason for hiding this comment

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

It's definitely possible, but largely the root blueprint view lives on the screen level.


if let displayScale = window?.screen.scale {
environment.displayScale = displayScale
}
Expand Down
73 changes: 16 additions & 57 deletions BlueprintUI/Sources/Element/UIViewElement.swift
Original file line number Diff line number Diff line change
Expand Up @@ -90,13 +90,23 @@ extension UIViewElement {

/// Defer to the reused measurement view to provide the size of the element.
public var content: ElementContent {

ElementContent { constraint, environment in
UIViewElementMeasurer.shared.measure(
element: self,
constraint: constraint,
environment: environment
)
let view = environment.viewCache.value {
makeUIView()
}

let bounds = CGRect(origin: .zero, size: constraint.maximum)

/// Ensure that during measurement / sizing, the inherited `Environment` is available
/// to any child `BlueprintView`s. We must manually wire this property up, as the
/// measurement views are not in the view hierarchy.

view.nativeViewNodeBlueprintEnvironment = environment
defer { view.nativeViewNodeBlueprintEnvironment = nil }

updateUIView(view, with: UIViewElementContext(isMeasuring: true, environment: environment))

return size(bounds.size, thatFits: view)
}
}

Expand Down Expand Up @@ -129,54 +139,3 @@ public struct UIViewElementContext {
/// The environment the element is rendered in.
public var environment: Environment
}

/// An private type which caches `UIViewElement` views to be reused for sizing and measurement.
private final class UIViewElementMeasurer {

/// The standard shared cache.
static let shared = UIViewElementMeasurer()

/// Provides the size for the provided element by using a cached measurement view.
func measure(
element: some UIViewElement,
constraint: SizeConstraint,
environment: Environment
) -> CGSize {

let bounds = CGRect(origin: .zero, size: constraint.maximum)

let view = measurementView(for: element)

/// Ensure that during measurement / sizing, the inherited `Environment` is available
/// to any child `BlueprintView`s. We must manually wire this property up, as the
/// measurement views are not in the view hierarchy.

view.nativeViewNodeBlueprintEnvironment = environment
defer { view.nativeViewNodeBlueprintEnvironment = nil }

element.updateUIView(view, with: .init(isMeasuring: true, environment: environment))

return element.size(bounds.size, thatFits: view)
}

func measurementView<ViewElement: UIViewElement>(for element: ViewElement) -> ViewElement.UIViewType {
let key = Key(
elementType: ObjectIdentifier(ViewElement.self)
)

if let existing = views[key] {
return existing as! ViewElement.UIViewType
} else {
let new = element.makeUIView()
views[key] = new
return new
}
}

private var views: [Key: UIView] = [:]

private struct Key: Hashable {
let elementType: ObjectIdentifier
}
}

55 changes: 55 additions & 0 deletions BlueprintUI/Sources/Environment/Keys/ViewCacheKey.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import Foundation

extension Environment {
private enum ViewCacheKey: EnvironmentKey {
static let defaultValue: TypeKeyedCache? = nil
}

private static let fallback = TypeKeyedCache()
Copy link
Member Author

Choose a reason for hiding this comment

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

I'm thinking we shouldn't have a static fallback.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Yeah if we can get away with it. I haven't done an integration in POS yet, and I believe there is older code that just out of band measures elements with an empty environment, so we may need it for now.


var inheritedViewCache: TypeKeyedCache? {
self[ViewCacheKey.self]
}

public internal(set) var viewCache: TypeKeyedCache {
get {
if let inheritedViewCache {
return inheritedViewCache
} else {
#if DEBUG
do {
/// Set a breakpoint on this `throw` if you'd like to understand where this error is occurring.
///
/// We throw a caught error so that program execution can continue, and folks can opt
/// in or out of stopping on the error.
throw ViewCacheErrors.fallingBackToStaticCache
} catch {

/// **Warning**: Blueprint is falling back to a static `TypeKeyedCache`,
/// which will result in prototype measurement values being retained for
/// the lifetime of the application, which can result in memory leaks.
///
/// If you are seeing this error, ensure you're passing the Blueprint `Environment`
/// properly through your element hierarchies – you should almost _never_ be
/// passing an `.empty` environment to methods, and instead passing an inherited
/// environment which will be passed to you by callers or a parent view controller,
/// screen, or element.
///
/// To learn more, see https://github.com/square/Blueprint/tree/main/Documentation/TODO.md.

}
#endif

return Self.fallback
}
}

set {
self[ViewCacheKey.self] = newValue
}
}

private enum ViewCacheErrors: Error {
case fallingBackToStaticCache
}
}
34 changes: 34 additions & 0 deletions BlueprintUI/Sources/Measuring/TypeKeyedCache.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import UIKit

/// A cache that uses the value's type as the key.
public final class TypeKeyedCache {

private var views: [Key: AnyObject] = [:]

// Intentionally internal. Not intended for public instantiation.
init() {}

// Returns the cached value for the Value type if it exists. If it doesn't
// exist, it creates a new instance of Value, caches it, and returns it.
//
// - Parameter create: A closure that creates a new instance of Value. It
// is only invoked when a cached value does not exist. The created value is
// cached for future usage.
public func value<Value: AnyObject>(_ create: () -> Value) -> Value {
Copy link
Collaborator

Choose a reason for hiding this comment

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

FWIW, I went with the "access" pattern with an access closure so people aren't tempted to keep a reference to the cached measurement view, kind of like the swift pointer APIs where the pointer shouldn't be stored outside the access closure.

let key = Key(
elementType: ObjectIdentifier(Value.self)
)

if let existing = views[key] {
return existing as! Value
} else {
let new = create()
views[key] = new
return new
}
}

private struct Key: Hashable {
let elementType: ObjectIdentifier
}
}
14 changes: 10 additions & 4 deletions BlueprintUICommonControls/Sources/AttributedLabel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -128,16 +128,22 @@ public struct AttributedLabel: Element, Hashable {
/// You can check if this value should be false via `NSAttributedString.needsNormalizingForView(...)`
public var needsTextNormalization: Bool = true

private static let prototypeLabel = LabelView()
Copy link
Member Author

Choose a reason for hiding this comment

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

@watt suggested we consider leaving this one as-is, at least for the initial iteration since its:

  • low likelihood of retaining something it shouldn't be (and would be flushed out by other measurements)
  • on a relatively hot code path


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
let label = Self.prototypeLabel
label.update(model: self, text: text, environment: environment, isMeasuring: true)
let label = environment.viewCache.value {
LabelView()
}

label.update(
model: self,
text: text,
environment: environment,
isMeasuring: true
)
return label.sizeThatFits(constraint.maximum)
}
}
Expand Down
8 changes: 4 additions & 4 deletions SampleApp/Sources/AccessibilityViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,8 @@ final class AccessibilityViewController: UIViewController {

Row {
Button(
onTap: {
self.firstTrigger.focus()
onTap: { [weak self] in
self?.firstTrigger.focus()
},
wrapping: Label(text: "Focus on First", configure: { label in
label.color = .systemBlue
Expand Down Expand Up @@ -75,9 +75,9 @@ final class AccessibilityViewController: UIViewController {
.accessibilityContainer()
.inset(uniform: 20)
.centered()
.onAppear {
.onAppear { [weak self] in
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) {
self.secondTrigger.focus()
self?.secondTrigger.focus()
}
}
}
Expand Down
4 changes: 2 additions & 2 deletions SampleApp/Sources/TextLinkViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,8 @@ final class TextLinkViewController: UIViewController {
return Column(alignment: .fill, minimumSpacing: 20) {
label
Label(text: "Custom link handling:")
label.onLinkTapped {
self.presentAlert(message: $0.absoluteString)
label.onLinkTapped { [weak self] in
self?.presentAlert(message: $0.absoluteString)
}

AttributedLabel(attributedText: NSAttributedString(string: "https://squareup.com")) {
Expand Down
Loading