Skip to content

Commit 4eb877c

Browse files
authored
perf: optimize network, caching, and SwiftUI rendering (#57)
1 parent c6337f2 commit 4eb877c

File tree

11 files changed

+238
-40
lines changed

11 files changed

+238
-40
lines changed

AGENTS.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -257,8 +257,12 @@ Before completing non-trivial features, verify:
257257
- [ ] Lists use `LazyVStack`/`LazyHStack` for large datasets
258258
- [ ] Network calls cancelled on view disappear (`.task` handles this)
259259
- [ ] Parsers have `measure {}` tests if processing large payloads
260-
- [ ] Images use `ImageCache` (not loading inline)
260+
- [ ] Images use `ImageCache` with appropriate `targetSize` (not loading inline)
261261
- [ ] Search input is debounced (not firing on every keystroke)
262+
- [ ] ForEach uses stable identity (avoid `Array(enumerated())` unless rank is needed)
263+
- [ ] Frequently updating UI (e.g., progress) caches formatted strings
264+
265+
> 📚 See [docs/architecture.md#performance-guidelines](docs/architecture.md#performance-guidelines) for detailed patterns.
262266
263267
## Task Planning: Phases with Exit Criteria
264268

Core/Services/API/APICache.swift

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -39,9 +39,19 @@ final class APICache {
3939
/// Maximum number of cached entries before LRU eviction kicks in.
4040
private static let maxEntries = 50
4141

42-
private var cache: [String: CacheEntry] = [:]
42+
/// Pre-allocated dictionary with initial capacity to reduce rehashing.
43+
private var cache: [String: CacheEntry]
4344

44-
private init() {}
45+
/// Timestamp of last eviction to avoid running on every access.
46+
private var lastEvictionTime: Date = .distantPast
47+
48+
/// Minimum interval between automatic evictions (30 seconds).
49+
private static let evictionInterval: TimeInterval = 30
50+
51+
private init() {
52+
// Pre-allocate capacity to avoid rehashing during normal operation
53+
self.cache = Dictionary(minimumCapacity: Self.maxEntries)
54+
}
4555

4656
/// Gets cached data if available and not expired.
4757
func get(key: String) -> [String: Any]? {
@@ -61,15 +71,20 @@ final class APICache {
6171
/// Stores data in the cache with the specified TTL.
6272
/// Evicts least recently used entries if cache is at capacity.
6373
func set(key: String, data: [String: Any], ttl: TimeInterval) {
64-
// Evict expired entries first
65-
self.evictExpiredEntries()
74+
let now = Date()
75+
76+
// Evict expired entries periodically (not on every set)
77+
if now.timeIntervalSince(self.lastEvictionTime) > Self.evictionInterval {
78+
self.evictExpiredEntries()
79+
self.lastEvictionTime = now
80+
}
6681

6782
// Evict LRU entries if still at capacity
6883
while self.cache.count >= Self.maxEntries {
6984
self.evictLeastRecentlyUsed()
7085
}
7186

72-
self.cache[key] = CacheEntry(data: data, timestamp: Date(), ttl: ttl)
87+
self.cache[key] = CacheEntry(data: data, timestamp: now, ttl: ttl)
7388
}
7489

7590
/// Generates a stable, deterministic cache key from endpoint and request body.

Core/Services/API/YTMusicClient.swift

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,14 @@ final class YTMusicClient: YTMusicClientProtocol {
6060
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15",
6161
"Accept-Encoding": "gzip, deflate, br",
6262
]
63+
// Increase connection pool for parallel requests (HTTP/2 multiplexing is automatic)
64+
configuration.httpMaximumConnectionsPerHost = 6
65+
// Use shared URL cache for transport-level caching
66+
configuration.urlCache = URLCache.shared
67+
configuration.requestCachePolicy = .useProtocolCachePolicy
68+
// Reduce timeout for faster failure detection
69+
configuration.timeoutIntervalForRequest = 15
70+
configuration.timeoutIntervalForResource = 30
6371
self.session = URLSession(configuration: configuration)
6472
}
6573

@@ -1203,8 +1211,10 @@ final class YTMusicClient: YTMusicClientProtocol {
12031211
// Handle errors back on main actor
12041212
switch result {
12051213
case let .success(data):
1206-
// Parse JSON - URLSession already decompresses gzip/deflate on a background thread,
1207-
// and JSONSerialization is very fast for typical response sizes (~5-15ms)
1214+
// Parse JSON synchronously - JSONSerialization is highly optimized
1215+
// and typically completes in <5ms even for large responses.
1216+
// The actual response parsing (in Parsers/) is more expensive
1217+
// but must happen on MainActor anyway for @Observable updates.
12081218
guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
12091219
throw YTMusicError.parseError(message: "Response is not a JSON object")
12101220
}

Core/Services/WebKit/WebKitManager.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,7 @@ final class WebKitManager: NSObject, WebKitManagerProtocol {
155155
private var cookieDebounceTask: Task<Void, Never>?
156156

157157
/// Minimum interval between cookie backup operations (in seconds).
158-
private static let cookieDebounceInterval: Duration = .seconds(2)
158+
private static let cookieDebounceInterval: Duration = .seconds(5)
159159

160160
/// The YouTube Music origin URL.
161161
static let origin = "https://music.youtube.com"

Core/Utilities/ImageCache.swift

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -99,28 +99,38 @@ actor ImageCache {
9999
}
100100

101101
/// Prefetches images with controlled concurrency to avoid network congestion.
102+
/// Supports cooperative cancellation from SwiftUI's structured concurrency.
102103
/// - Parameters:
103104
/// - urls: URLs to prefetch.
104105
/// - targetSize: Optional target size for downsampling.
105106
/// - maxConcurrent: Maximum number of concurrent fetches (default: 4).
106-
func prefetch(urls: [URL], targetSize: CGSize? = nil, maxConcurrent: Int = maxConcurrentPrefetch)
107-
async
108-
{
107+
func prefetch(urls: [URL], targetSize: CGSize? = nil, maxConcurrent: Int = maxConcurrentPrefetch) async {
108+
// Use structured concurrency directly - cancellation propagates automatically
109+
// when SwiftUI's .task is cancelled (view disappears or id changes)
109110
await withTaskGroup(of: Void.self) { group in
110111
var inProgress = 0
111112
for url in urls {
113+
// Check cancellation before starting new work
114+
guard !Task.isCancelled else { break }
115+
116+
// Skip if already in memory cache
117+
if self.memoryCache.object(forKey: url as NSURL) != nil {
118+
continue
119+
}
120+
112121
// Wait for a slot if we're at capacity
113122
if inProgress >= maxConcurrent {
114123
await group.next()
115124
inProgress -= 1
116125
}
117126

118127
group.addTask(priority: .utility) {
128+
guard !Task.isCancelled else { return }
119129
_ = await self.image(for: url, targetSize: targetSize)
120130
}
121131
inProgress += 1
122132
}
123-
// Wait for remaining tasks
133+
// Wait for remaining tasks (will be cancelled if parent is cancelled)
124134
await group.waitForAll()
125135
}
126136
}

Views/macOS/CachedAsyncImage.swift

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,16 @@ struct CachedAsyncImage<Content: View, Placeholder: View>: View {
3030
self.placeholder()
3131
}
3232
}
33+
.onChange(of: self.url) { _, _ in
34+
// Reset state when URL changes for proper UX
35+
self.image = nil
36+
self.isLoaded = false
37+
}
3338
.task(id: self.url) {
3439
guard let url else { return }
35-
self.image = await ImageCache.shared.image(for: url, targetSize: self.targetSize)
40+
let loadedImage = await ImageCache.shared.image(for: url, targetSize: self.targetSize)
41+
guard !Task.isCancelled else { return }
42+
self.image = loadedImage
3643
self.isLoaded = true
3744
}
3845
}

Views/macOS/HomeView.swift

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -71,10 +71,9 @@ struct HomeView: View {
7171
.staggeredAppearance(index: 0)
7272
}
7373

74-
// API sections
75-
ForEach(Array(self.viewModel.sections.enumerated()), id: \.element.id) { index, section in
74+
// API sections - use stable id without array enumeration
75+
ForEach(self.viewModel.sections) { section in
7676
self.sectionView(section)
77-
.staggeredAppearance(index: self.favoritesManager.isVisible ? index + 1 : index)
7877
.task {
7978
await self.prefetchImagesAsync(for: section)
8079
}
@@ -93,6 +92,7 @@ struct HomeView: View {
9392

9493
ScrollView(.horizontal, showsIndicators: false) {
9594
LazyHStack(spacing: 16) {
95+
// Use stable ID from items, avoid enumeration for non-chart sections
9696
if section.isChart {
9797
ForEach(Array(section.items.enumerated()), id: \.element.id) { index, item in
9898
HomeSectionItemCard(item: item, rank: index + 1) {
@@ -103,12 +103,12 @@ struct HomeView: View {
103103
}
104104
}
105105
} else {
106-
ForEach(Array(section.items.enumerated()), id: \.element.id) { index, item in
106+
ForEach(section.items) { item in
107107
HomeSectionItemCard(item: item) {
108-
self.playItem(item, in: section, at: index)
108+
self.playItem(item, in: section, at: 0)
109109
}
110110
.contextMenu {
111-
self.contextMenuItems(for: item, in: section, at: index)
111+
self.contextMenuItems(for: item, in: section, at: 0)
112112
}
113113
}
114114
}

Views/macOS/PlayerBar.swift

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,12 @@ struct PlayerBar: View {
2222
@State private var volumeValue: Double = 1.0
2323
@State private var isAdjustingVolume = false
2424

25+
/// Cached formatted progress string to avoid repeated formatting.
26+
@State private var formattedProgress: String = "0:00"
27+
@State private var formattedRemaining: String = "-0:00"
28+
/// Last integer second of progress to reduce string formatting frequency.
29+
@State private var lastProgressSecond: Int = -1
30+
2531
var body: some View {
2632
GlassEffectContainer(spacing: 0) {
2733
HStack(spacing: 0) {
@@ -102,6 +108,13 @@ struct PlayerBar: View {
102108
if !self.isSeeking, self.playerService.duration > 0 {
103109
self.seekValue = newValue / self.playerService.duration
104110
}
111+
// Only update formatted strings when the second changes to reduce Text view updates
112+
let currentSecond = Int(newValue)
113+
if currentSecond != self.lastProgressSecond {
114+
self.lastProgressSecond = currentSecond
115+
self.formattedProgress = self.formatTime(newValue)
116+
self.formattedRemaining = "-\(self.formatTime(self.playerService.duration - newValue))"
117+
}
105118
}
106119
.onChange(of: self.playerService.volume) { _, newValue in
107120
// Sync local volume value when not actively adjusting
@@ -175,11 +188,12 @@ struct PlayerBar: View {
175188

176189
private var seekBarView: some View {
177190
HStack(spacing: 10) {
178-
// Elapsed time - show seek position while dragging, actual progress otherwise
179-
Text(self.formatTime(self.isSeeking ? self.seekValue * self.playerService.duration : self.playerService.progress))
191+
// Elapsed time - use cached formatted string when not seeking
192+
Text(self.isSeeking ? self.formatTime(self.seekValue * self.playerService.duration) : self.formattedProgress)
180193
.font(.system(size: 11))
181194
.foregroundStyle(.secondary)
182195
.frame(minWidth: 45, alignment: .trailing)
196+
.monospacedDigit()
183197

184198
// Seek slider
185199
Slider(value: self.$seekValue, in: 0 ... 1) { editing in
@@ -193,11 +207,12 @@ struct PlayerBar: View {
193207
}
194208
.controlSize(.small)
195209

196-
// Remaining time
197-
Text("-\(self.formatTime(self.playerService.duration - (self.isSeeking ? self.seekValue * self.playerService.duration : self.playerService.progress)))")
210+
// Remaining time - use cached formatted string when not seeking
211+
Text(self.isSeeking ? "-\(self.formatTime(self.playerService.duration - self.seekValue * self.playerService.duration))" : self.formattedRemaining)
198212
.font(.system(size: 11))
199213
.foregroundStyle(.secondary)
200214
.frame(minWidth: 45, alignment: .leading)
215+
.monospacedDigit()
201216
}
202217
}
203218

Views/macOS/SearchView.swift

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -280,8 +280,8 @@ struct SearchView: View {
280280
private var resultsView: some View {
281281
ScrollView {
282282
LazyVStack(spacing: 0) {
283-
ForEach(Array(self.viewModel.filteredItems.enumerated()), id: \.element.id) { index, item in
284-
self.resultRow(item, index: index)
283+
ForEach(self.viewModel.filteredItems) { item in
284+
self.resultRow(item)
285285
Divider()
286286
.padding(.leading, 72)
287287
}
@@ -327,7 +327,7 @@ struct SearchView: View {
327327
}
328328
}
329329

330-
private func resultRow(_ item: SearchResultItem, index: Int) -> some View {
330+
private func resultRow(_ item: SearchResultItem) -> some View {
331331
Button {
332332
self.handleItemTap(item)
333333
} label: {
@@ -386,7 +386,6 @@ struct SearchView: View {
386386
.contentShape(Rectangle())
387387
}
388388
.buttonStyle(.interactiveRow(cornerRadius: 6))
389-
.staggeredAppearance(index: min(index, 10))
390389
.contextMenu {
391390
self.contextMenuItems(for: item)
392391
}

0 commit comments

Comments
 (0)