Skip to content

Commit 2069be2

Browse files
authored
Refactor EntityGridView for better performance & memory usage (#269)
Refactors EntityGridView (used for Artists and Albums grid) to have better performance around scrolling and memory usage while also sharing artwork caching logic between the tracks list and entity grid views.
1 parent d9a463a commit 2069be2

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)