Skip to content

Commit 891365e

Browse files
committed
Refactor EntityGridView for better performance & memory usage
1 parent d9a463a commit 891365e

4 files changed

Lines changed: 143 additions & 169 deletions

File tree

Models/Core/Entity.swift

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import Foundation
22
import AppKit
33
import SwiftUI
4+
import CryptoKit
45

56
// MARK: - Artist Initials
67

@@ -168,17 +169,22 @@ struct CategoryEntity: Entity {
168169
// MARK: - UUID Extension
169170

170171
extension UUID {
172+
/// Deterministic name-based UUID
171173
init(name: String, namespace: UUID) {
172-
let combined = "\(namespace.uuidString)-\(name)"
173-
let hash = combined.hashValue
174-
let uuidString = String(
175-
format: "%08X-%04X-%04X-%04X-%012X",
176-
UInt32(hash & 0xFFFFFFFF),
177-
UInt16((hash >> 32) & 0xFFFF),
178-
UInt16((hash >> 48) & 0x0FFF) | 0x5000,
179-
UInt16((hash >> 60) & 0x3FFF) | 0x8000,
180-
UInt64(abs(hash)) & 0xFFFFFFFFFFFF
174+
var input = Data()
175+
withUnsafeBytes(of: namespace.uuid) { input.append(contentsOf: $0) }
176+
input.append(contentsOf: name.utf8)
177+
178+
var digest = Array(Insecure.SHA1.hash(data: input))
179+
digest[6] = (digest[6] & 0x0F) | 0x50
180+
digest[8] = (digest[8] & 0x3F) | 0x80
181+
182+
let bytes: uuid_t = (
183+
digest[0], digest[1], digest[2], digest[3],
184+
digest[4], digest[5], digest[6], digest[7],
185+
digest[8], digest[9], digest[10], digest[11],
186+
digest[12], digest[13], digest[14], digest[15]
181187
)
182-
self = UUID(uuidString: uuidString)!
188+
self.init(uuid: bytes)
183189
}
184190
}

Utilities/ArtworkCache.swift

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import AppKit
2+
import Foundation
3+
4+
/// Operation subclass that guarantees its continuation is resumed
5+
/// even when cancelled, unlike `BlockOperation` whose execution blocks
6+
/// are skipped entirely for cancelled operations.
7+
final class ArtworkLoadOperation: Operation, @unchecked Sendable {
8+
private let continuation: CheckedContinuation<NSImage?, Never>
9+
private let work: () -> NSImage?
10+
11+
init(continuation: CheckedContinuation<NSImage?, Never>, work: @escaping () -> NSImage?) {
12+
self.continuation = continuation
13+
self.work = work
14+
super.init()
15+
}
16+
17+
override func main() {
18+
continuation.resume(returning: isCancelled ? nil : work())
19+
}
20+
}
21+
22+
extension OperationQueue {
23+
/// Enqueues a render and resumes its continuation when complete.
24+
/// Cancelling the awaiting task cancels the queued operation; cancelled
25+
/// operations resume with `nil` without rendering.
26+
func renderArtwork(_ work: @escaping () -> NSImage?) async -> NSImage? {
27+
final class Holder: @unchecked Sendable {
28+
var operation: ArtworkLoadOperation?
29+
}
30+
let holder = Holder()
31+
32+
return await withTaskCancellationHandler {
33+
await withCheckedContinuation { continuation in
34+
let operation = ArtworkLoadOperation(continuation: continuation, work: work)
35+
holder.operation = operation
36+
self.addOperation(operation)
37+
}
38+
} onCancel: {
39+
holder.operation?.cancel()
40+
}
41+
}
42+
}

Views/Components/EntityGridView.swift

Lines changed: 55 additions & 110 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,7 @@ struct EntityGridView<T: Entity>: View {
66
let contextMenuItems: (T) -> [ContextMenuItem]
77

88
@State private var hoveredEntityID: UUID?
9-
@State private var isScrolling = false
10-
9+
1110
private let columns = [
1211
GridItem(.adaptive(minimum: ViewDefaults.gridArtworkSize, maximum: ViewDefaults.gridArtworkSize + 40), spacing: 16)
1312
]
@@ -18,30 +17,24 @@ struct EntityGridView<T: Entity>: View {
1817
ForEach(entities) { entity in
1918
EntityGridItem(
2019
entity: entity,
21-
isHovered: isScrolling ? false : (hoveredEntityID == entity.id),
22-
isScrolling: isScrolling,
20+
isHovered: hoveredEntityID == entity.id,
2321
onSelect: {
2422
onSelectEntity(entity)
2523
},
2624
onHover: { isHovered in
27-
if !isScrolling {
28-
hoveredEntityID = isHovered ? entity.id : nil
29-
}
25+
hoveredEntityID = isHovered ? entity.id : nil
3026
}
3127
)
3228
.contextMenu {
3329
ForEach(contextMenuItems(entity), id: \.id) { item in
3430
contextMenuItem(item)
3531
}
3632
}
37-
.id(entity.id)
3833
}
3934
}
4035
.padding(.horizontal, 14)
4136
.padding(.vertical, 6)
4237
}
43-
.coordinateSpace(name: "scroll")
44-
.modifier(ScrollDetectionModifier(isScrolling: $isScrolling, hoveredEntityID: $hoveredEntityID))
4538
}
4639

4740
@ViewBuilder
@@ -50,106 +43,58 @@ struct EntityGridView<T: Entity>: View {
5043
}
5144
}
5245

53-
// MARK: - Cross-OS Scroll Detection
46+
// MARK: - Image Cache
5447

55-
private struct ScrollDetectionModifier: ViewModifier {
56-
@Binding var isScrolling: Bool
57-
@Binding var hoveredEntityID: UUID?
58-
59-
func body(content: Content) -> some View {
60-
if #available(macOS 15.0, *) {
61-
content
62-
.onScrollPhaseChange { _, newPhase in
63-
withAnimation(.none) {
64-
let wasScrolling = isScrolling
65-
isScrolling = newPhase == .interacting || newPhase == .decelerating
66-
67-
if isScrolling && !wasScrolling {
68-
hoveredEntityID = nil
69-
}
70-
}
71-
}
72-
} else {
73-
content
74-
.background(
75-
ScrollDetectionView { isDetectedScrolling in
76-
if isDetectedScrolling != isScrolling {
77-
withAnimation(.none) {
78-
isScrolling = isDetectedScrolling
79-
if isScrolling {
80-
hoveredEntityID = nil
81-
}
82-
}
83-
}
84-
}
85-
)
86-
}
87-
}
88-
}
48+
private final class EntityArtworkCache: @unchecked Sendable {
49+
static let shared = EntityArtworkCache()
50+
private let cache = NSCache<NSString, NSImage>()
51+
private let loadQueue: OperationQueue = {
52+
let queue = OperationQueue()
53+
queue.maxConcurrentOperationCount = max(2, ProcessInfo.processInfo.activeProcessorCount / 2)
54+
queue.qualityOfService = .utility
55+
return queue
56+
}()
8957

90-
// MARK: - Scroll Detection for macOS 14
58+
private static let pixelSize = Int(ViewDefaults.gridArtworkSize * 2)
59+
private static let bytesPerImage = pixelSize * pixelSize * 4
9160

92-
private struct ScrollDetectionView: View {
93-
let onScrollingChanged: (Bool) -> Void
94-
@State private var lastOffset: CGFloat = 0
95-
@State private var scrollTimer: Timer?
96-
97-
var body: some View {
98-
GeometryReader { geometry in
99-
Color.clear
100-
.preference(
101-
key: ScrollOffsetKey.self,
102-
value: geometry.frame(in: .named("scroll")).origin.y
103-
)
104-
}
105-
.onPreferenceChange(ScrollOffsetKey.self) { newOffset in
106-
if abs(newOffset - lastOffset) > 1 {
107-
onScrollingChanged(true)
108-
lastOffset = newOffset
109-
110-
scrollTimer?.invalidate()
111-
scrollTimer = Timer.scheduledTimer(withTimeInterval: 0.15, repeats: false) { _ in
112-
onScrollingChanged(false)
113-
}
114-
}
115-
}
61+
init() {
62+
cache.countLimit = 500
63+
cache.totalCostLimit = 80 * 1024 * 1024
11664
}
117-
}
11865

119-
private struct ScrollOffsetKey: PreferenceKey {
120-
static var defaultValue: CGFloat = 0
121-
static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
122-
value = nextValue()
66+
private func cacheKey(for entity: any Entity) -> NSString {
67+
let artworkSize = entity.artworkData?.count ?? 0
68+
return "\(entity.id.uuidString)-\(artworkSize)-rendered" as NSString
12369
}
124-
}
125-
126-
// MARK: - Image Cache
12770

128-
private class RenderedImageCache {
129-
static let shared = RenderedImageCache()
130-
private let cache = NSCache<NSString, NSImage>()
131-
132-
init() {
133-
cache.countLimit = 1000
71+
func getCachedImage(for entity: any Entity) -> NSImage? {
72+
cache.object(forKey: cacheKey(for: entity))
13473
}
135-
136-
func getImage(for entity: any Entity) -> NSImage? {
137-
let artworkHash = entity.artworkData?.hashValue ?? 0
138-
let key = "\(entity.id.uuidString)-\(artworkHash)-rendered" as NSString
74+
75+
func loadImage(for entity: any Entity) async -> NSImage? {
76+
let key = cacheKey(for: entity)
13977

14078
if let cached = cache.object(forKey: key) {
14179
return cached
14280
}
14381

14482
guard let artworkData = entity.artworkData else { return nil }
14583

146-
let renderedImage = createRenderedImage(from: artworkData)
147-
148-
if let image = renderedImage {
149-
cache.setObject(image, forKey: key)
84+
return await loadQueue.renderArtwork { [self] in
85+
// Re-check cache, another operation may have loaded it while queued
86+
if let cached = cache.object(forKey: key) {
87+
return cached
88+
}
89+
90+
let renderedImage = createRenderedImage(from: artworkData)
91+
92+
if let image = renderedImage {
93+
cache.setObject(image, forKey: key, cost: Self.bytesPerImage)
94+
}
95+
96+
return renderedImage
15097
}
151-
152-
return renderedImage
15398
}
15499

155100
private func createRenderedImage(from data: Data) -> NSImage? {
@@ -206,7 +151,6 @@ private class RenderedImageCache {
206151
private struct EntityGridItem<T: Entity>: View {
207152
let entity: T
208153
let isHovered: Bool
209-
let isScrolling: Bool
210154
let onSelect: () -> Void
211155
let onHover: (Bool) -> Void
212156

@@ -242,11 +186,8 @@ private struct EntityGridItem<T: Entity>: View {
242186
.shadow(color: .black.opacity(0.2), radius: 10, x: 0, y: 5)
243187
}
244188
}
245-
.onAppear {
246-
loadArtwork()
247-
}
248-
.onChange(of: entity.artworkData) {
249-
loadArtwork()
189+
.task(id: artworkTaskID) {
190+
await loadArtwork()
250191
}
251192

252193
VStack(alignment: .leading, spacing: 2) {
@@ -293,23 +234,27 @@ private struct EntityGridItem<T: Entity>: View {
293234
.background(
294235
RoundedRectangle(cornerRadius: 10)
295236
.fill(isHovered ? Color(NSColor.selectedContentBackgroundColor).opacity(0.15) : Color.clear)
296-
.animation(
297-
isScrolling ? .none : .easeInOut(duration: 0.08),
298-
value: isHovered
299-
)
237+
.animation(.easeInOut(duration: 0.08), value: isHovered)
300238
)
301239
.contentShape(Rectangle())
302240
.onTapGesture(perform: onSelect)
303241
.onHover(perform: onHover)
304242
}
305243

306-
private func loadArtwork() {
307-
DispatchQueue.global(qos: .userInitiated).async {
308-
let image = RenderedImageCache.shared.getImage(for: entity)
244+
private var artworkTaskID: String {
245+
"\(entity.id.uuidString)-\(entity.artworkData?.count ?? 0)"
246+
}
309247

310-
DispatchQueue.main.async {
311-
self.renderedImage = image
312-
}
248+
private func loadArtwork() async {
249+
// Serve cache hits synchronously to avoid placeholder flicker on scroll recycle
250+
if let cached = EntityArtworkCache.shared.getCachedImage(for: entity) {
251+
renderedImage = cached
252+
return
313253
}
254+
255+
let image = await EntityArtworkCache.shared.loadImage(for: entity)
256+
257+
guard !Task.isCancelled else { return }
258+
renderedImage = image
314259
}
315260
}

0 commit comments

Comments
 (0)