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

Improve perception performance #24

Merged
merged 15 commits into from
Jan 20, 2024
2 changes: 2 additions & 0 deletions Example/Example/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ class CounterModel {
var count = 0
var isDisplayingCount = true
var isPresentingSheet = false
var text = ""
func decrementButtonTapped() {
count -= 1
}
Expand All @@ -24,6 +25,7 @@ struct ContentView: View {
WithPerceptionTracking {
let _ = print("\(Self.self): tracked change.")
Form {
TextField("Text", text: $model.text)
if model.isDisplayingCount {
Text(model.count.description)
} else {
Expand Down
113 changes: 80 additions & 33 deletions Sources/Perception/PerceptionRegistrar.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,16 @@ import Foundation
@available(watchOS, deprecated: 10, renamed: "ObservationRegistrar")
public struct PerceptionRegistrar: Sendable {
private let _rawValue: AnySendable
private let isPerceptionCheckingEnabled: Bool
fileprivate let perceptionChecks = LockIsolated<[FileLine: Bool]>([:])

/// Creates an instance of the observation registrar.
///
/// You don't need to create an instance of
/// ``PerceptionRegistrar`` when using the
/// ``Perception/Perceptible()`` macro to indicate observably
/// of a type.
public init() {
public init(isPerceptionCheckingEnabled: Bool = Perception.isPerceptionCheckingEnabled) {
if #available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) {
#if canImport(Observation)
self._rawValue = AnySendable(ObservationRegistrar())
Expand All @@ -31,6 +33,7 @@ public struct PerceptionRegistrar: Sendable {
} else {
self._rawValue = AnySendable(_PerceptionRegistrar())
}
self.isPerceptionCheckingEnabled = isPerceptionCheckingEnabled
}

#if canImport(Observation)
Expand Down Expand Up @@ -78,10 +81,12 @@ extension PerceptionRegistrar {
@_disfavoredOverload
public func access<Subject: Perceptible, Member>(
_ subject: Subject,
keyPath: KeyPath<Subject, Member>
keyPath: KeyPath<Subject, Member>,
file: StaticString = #file,
line: UInt = #line
) {
perceptionCheck()
self.perceptionCheck(file: file, line: line)

#if canImport(Observation)
if #available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) {
func `open`<T: Observable>(_ subject: T) {
Expand Down Expand Up @@ -194,37 +199,46 @@ extension PerceptionRegistrar: Hashable {
}

#if DEBUG
private func perceptionCheck() {
if
isPerceptionCheckingEnabled,
!_PerceptionLocals.isInPerceptionTracking,
!_PerceptionLocals.skipPerceptionChecking,
isInSwiftUIBody()
{
runtimeWarn(
"""
Perceptible state was accessed but is not being tracked. Track changes to state by \
wrapping your view in a 'WithPerceptionTracking' view.
"""
)
extension PerceptionRegistrar {
fileprivate func perceptionCheck(file: StaticString, line: UInt) {
if self.isPerceptionCheckingEnabled,
Perception.isPerceptionCheckingEnabled,
!_PerceptionLocals.isInPerceptionTracking,
!_PerceptionLocals.skipPerceptionChecking,
self.isInSwiftUIBody(file: file, line: line)
{
runtimeWarn(
"""
Perceptible state was accessed but is not being tracked. Track changes to state by \
wrapping your view in a 'WithPerceptionTracking' view.
"""
)
}
}
}

private let isInSwiftUIBody: () -> Bool = memoize {
for callStackSymbol in Thread.callStackSymbols {
let mangledSymbol = callStackSymbol.utf8
.drop(while: { $0 != .init(ascii: "$") })
.prefix(while: { $0 != .init(ascii: " ") })
guard
mangledSymbol.isMangledViewBodyGetter,
let demangled = String(Substring(mangledSymbol)).demangled,
!demangled.isActionClosure
else {
continue
fileprivate func isInSwiftUIBody(file: StaticString, line: UInt) -> Bool {
self.perceptionChecks.withValue { perceptionChecks in
if let result = perceptionChecks[FileLine(file: file, line: line)] {
return result
}
for callStackSymbol in Thread.callStackSymbols {
let mangledSymbol = callStackSymbol.utf8
.drop(while: { $0 != .init(ascii: "$") })
.prefix(while: { $0 != .init(ascii: " ") })

guard
mangledSymbol.isMangledViewBodyGetter,
let demangled = String(Substring(mangledSymbol)).demangled,
!demangled.isActionClosure
else {
continue
}
return true
}
perceptionChecks[FileLine(file: file, line: line)] = false
return false
}
return true
}
return false
}

extension String {
Expand Down Expand Up @@ -306,8 +320,7 @@ extension Substring.UTF8View {
var input = self
while let index = input.firstIndex(where: { first == $0 }) {
input = input[index...]
if
input.count >= otherCount,
if input.count >= otherCount,
zip(input, other).allSatisfy(==)
{
return true
Expand All @@ -317,3 +330,37 @@ extension Substring.UTF8View {
return false
}
}

fileprivate final class LockIsolated<Value>: @unchecked Sendable {
private var _value: Value
private let lock = NSRecursiveLock()
init(_ value: @autoclosure @Sendable () throws -> Value) rethrows {
self._value = try value()
}
func withValue<T: Sendable>(
_ operation: @Sendable (inout Value) throws -> T
) rethrows -> T {
try self.lock.sync {
var value = self._value
defer { self._value = value }
return try operation(&value)
}
}
}
extension NSRecursiveLock {
@inlinable @discardableResult
@_spi(Internals) public func sync<R>(work: () throws -> R) rethrows -> R {
self.lock()
defer { self.unlock() }
return try work()
}
}

private struct FileLine: Hashable {
let file: String
let line: UInt
init(file: StaticString, line: UInt) {
self.file = file.description
self.line = line
}
}
6 changes: 4 additions & 2 deletions Sources/PerceptionMacros/PerceptibleMacro.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,11 @@ public struct PerceptibleMacro {
return
"""
internal nonisolated func access<Member>(
keyPath: KeyPath<\(perceptibleType), Member>
keyPath: KeyPath<\(perceptibleType), Member>,
file: StaticString = #file,
line: UInt = #line
) {
\(raw: registrarVariableName).access(self, keyPath: keyPath)
\(raw: registrarVariableName).access(self, keyPath: keyPath, file: file, line: line)
}
"""
}
Expand Down
Loading
Loading