From 907cc9220fcf7672a05ae2a0e09d45fa3f4c5490 Mon Sep 17 00:00:00 2001 From: Ilyas Hallak Date: Sun, 26 Oct 2025 21:22:15 +0100 Subject: [PATCH] perf: Optimize label loading for 1000+ labels Major performance improvements to prevent crashes and lag when working with large label collections: Main App: - Switch to Core Data as primary source for labels (instant loading) - Implement background API sync to keep labels up-to-date - Add LazyVStack for efficient rendering of large label lists - Use batch operations instead of individual queries (1 query vs 1000) - Generate unique IDs for local labels to prevent duplicate warnings Share Extension: - Convert getTags() to async with background context - Add saveTags() method with batch insert support - Load labels from Core Data first, then sync with API - Remove duplicate server reachability checks - Reduce memory usage and prevent UI freezes Technical Details: - Labels now load instantly from local cache - API sync happens in background without blocking UI - Batch fetch operations for optimal database performance - Proper error handling for offline scenarios - Fixed duplicate ID warnings in ForEach loops Fixes crashes and lag reported by users with 1000+ labels. --- URLShare/OfflineBookmarkManager.swift | 47 ++++++++++-- URLShare/ShareBookmarkViewModel.swift | 50 ++++++------- .../Data/Repository/LabelsRepository.swift | 72 +++++++++++++------ readeck/Resources/RELEASE_NOTES.md | 33 +++++++++ readeck/UI/Components/TagManagementView.swift | 2 +- 5 files changed, 152 insertions(+), 52 deletions(-) diff --git a/URLShare/OfflineBookmarkManager.swift b/URLShare/OfflineBookmarkManager.swift index 6b317b9..28eb785 100644 --- a/URLShare/OfflineBookmarkManager.swift +++ b/URLShare/OfflineBookmarkManager.swift @@ -49,19 +49,54 @@ class OfflineBookmarkManager: @unchecked Sendable { } } - func getTags() -> [String] { + func getTags() async -> [String] { + let backgroundContext = CoreDataManager.shared.newBackgroundContext() + do { - return try context.safePerform { [weak self] in - guard let self = self else { return [] } - + return try await backgroundContext.perform { let fetchRequest: NSFetchRequest = TagEntity.fetchRequest() - let tagEntities = try self.context.fetch(fetchRequest) - return tagEntities.compactMap { $0.name }.sorted() + fetchRequest.sortDescriptors = [NSSortDescriptor(key: "name", ascending: true)] + + let tagEntities = try backgroundContext.fetch(fetchRequest) + return tagEntities.compactMap { $0.name } } } catch { print("Failed to fetch tags: \(error)") return [] } } + + func saveTags(_ tags: [String]) async { + let backgroundContext = CoreDataManager.shared.newBackgroundContext() + + do { + try await backgroundContext.perform { + // Batch fetch existing tags + let fetchRequest: NSFetchRequest = TagEntity.fetchRequest() + fetchRequest.propertiesToFetch = ["name"] + + let existingEntities = try backgroundContext.fetch(fetchRequest) + let existingNames = Set(existingEntities.compactMap { $0.name }) + + // Only insert new tags + var insertCount = 0 + for tag in tags { + if !existingNames.contains(tag) { + let entity = TagEntity(context: backgroundContext) + entity.name = tag + insertCount += 1 + } + } + + // Only save if there are new tags + if insertCount > 0 { + try backgroundContext.save() + print("Saved \(insertCount) new tags to Core Data") + } + } + } catch { + print("Failed to save tags: \(error)") + } + } } diff --git a/URLShare/ShareBookmarkViewModel.swift b/URLShare/ShareBookmarkViewModel.swift index 2e65612..d01a9a8 100644 --- a/URLShare/ShareBookmarkViewModel.swift +++ b/URLShare/ShareBookmarkViewModel.swift @@ -51,22 +51,9 @@ class ShareBookmarkViewModel: ObservableObject { func onAppear() { logger.debug("ShareBookmarkViewModel appeared") - checkServerReachability() loadLabels() } - private func checkServerReachability() { - let measurement = PerformanceMeasurement(operation: "checkServerReachability", logger: logger) - Task { - let reachable = await serverCheck.checkServerReachability() - await MainActor.run { - self.isServerReachable = reachable - logger.info("Server reachability checked: \(reachable)") - measurement.end() - } - } - } - private func extractSharedContent() { logger.debug("Starting to extract shared content") guard let extensionContext = extensionContext else { @@ -137,30 +124,43 @@ class ShareBookmarkViewModel: ObservableObject { let measurement = PerformanceMeasurement(operation: "loadLabels", logger: logger) logger.debug("Starting to load labels") Task { + // 1. First, load from Core Data (instant response) + let localTags = await OfflineBookmarkManager.shared.getTags() + let localLabels = localTags.enumerated().map { index, tagName in + BookmarkLabelDto(name: tagName, count: 0, href: "local://\(index)") + } + + await MainActor.run { + self.labels = localLabels + self.logger.info("Loaded \(localLabels.count) labels from local cache") + } + + // 2. Then check server and sync in background let serverReachable = await serverCheck.checkServerReachability() + await MainActor.run { + self.isServerReachable = serverReachable + } logger.debug("Server reachable for labels: \(serverReachable)") if serverReachable { let loaded = await SimpleAPI.getBookmarkLabels { [weak self] message, error in - self?.statusMessage = (message, error, error ? "❌" : "✅") + if error { + self?.logger.error("Failed to sync labels from API: \(message)") + } } ?? [] + + // Save new labels to Core Data + let tagNames = loaded.map { $0.name } + await OfflineBookmarkManager.shared.saveTags(tagNames) + let sorted = loaded.sorted { $0.count > $1.count } await MainActor.run { self.labels = Array(sorted) - self.logger.info("Loaded \(loaded.count) labels from API") + self.logger.info("Synced \(loaded.count) labels from API and updated cache") measurement.end() } } else { - let localTags = OfflineBookmarkManager.shared.getTags() - let localLabels = localTags.enumerated().map { index, tagName in - BookmarkLabelDto(name: tagName, count: 0, href: "local://\(index)") - } - .sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } - await MainActor.run { - self.labels = localLabels - self.logger.info("Loaded \(localLabels.count) labels from local database") - measurement.end() - } + measurement.end() } } } diff --git a/readeck/Data/Repository/LabelsRepository.swift b/readeck/Data/Repository/LabelsRepository.swift index bb73e26..08e8427 100644 --- a/readeck/Data/Repository/LabelsRepository.swift +++ b/readeck/Data/Repository/LabelsRepository.swift @@ -11,34 +11,66 @@ class LabelsRepository: PLabelsRepository, @unchecked Sendable { } func getLabels() async throws -> [BookmarkLabel] { - let dtos = try await api.getBookmarkLabels() - try? await saveLabels(dtos) - return dtos.map { $0.toDomain() } + // First, load from Core Data (instant response) + let cachedLabels = try await loadLabelsFromCoreData() + + // Then sync with API in background (don't wait) + Task.detached(priority: .background) { [weak self] in + guard let self = self else { return } + do { + let dtos = try await self.api.getBookmarkLabels() + try? await self.saveLabels(dtos) + } catch { + // Silent fail - we already have cached data + } + } + + return cachedLabels + } + + private func loadLabelsFromCoreData() async throws -> [BookmarkLabel] { + let backgroundContext = coreDataManager.newBackgroundContext() + + return try await backgroundContext.perform { + let fetchRequest: NSFetchRequest = TagEntity.fetchRequest() + fetchRequest.sortDescriptors = [NSSortDescriptor(key: "name", ascending: true)] + + let entities = try backgroundContext.fetch(fetchRequest) + return entities.compactMap { entity -> BookmarkLabel? in + guard let name = entity.name, !name.isEmpty else { return nil } + return BookmarkLabel( + name: name, + count: 0, + href: name + ) + } + } } func saveLabels(_ dtos: [BookmarkLabelDto]) async throws { let backgroundContext = coreDataManager.newBackgroundContext() - - try await backgroundContext.perform { [weak self] in - guard let self = self else { return } + + try await backgroundContext.perform { + // Batch fetch all existing label names (much faster than individual queries) + let fetchRequest: NSFetchRequest = TagEntity.fetchRequest() + fetchRequest.propertiesToFetch = ["name"] + + let existingEntities = try backgroundContext.fetch(fetchRequest) + let existingNames = Set(existingEntities.compactMap { $0.name }) + + // Only insert new labels + var insertCount = 0 for dto in dtos { - if !self.tagExists(name: dto.name, in: backgroundContext) { + if !existingNames.contains(dto.name) { dto.toEntity(context: backgroundContext) + insertCount += 1 } } - try backgroundContext.save() - } - } - - private func tagExists(name: String, in context: NSManagedObjectContext) -> Bool { - let fetchRequest: NSFetchRequest = TagEntity.fetchRequest() - fetchRequest.predicate = NSPredicate(format: "name == %@", name) - - do { - let count = try context.count(for: fetchRequest) - return count > 0 - } catch { - return false + + // Only save if there are new labels + if insertCount > 0 { + try backgroundContext.save() + } } } } diff --git a/readeck/Resources/RELEASE_NOTES.md b/readeck/Resources/RELEASE_NOTES.md index f4dfada..9c2b081 100644 --- a/readeck/Resources/RELEASE_NOTES.md +++ b/readeck/Resources/RELEASE_NOTES.md @@ -4,6 +4,39 @@ Thanks for using the Readeck iOS app! Below are the release notes for each versi **AppStore:** The App is now in the App Store! [Get it here](https://apps.apple.com/de/app/readeck/id6748764703) for all TestFlight users. If you wish a more stable Version, please download it from there. Or you can continue using TestFlight for the latest features. +## Version 1.2 + +### Annotations & Highlighting + +- **Highlight important passages** directly in your articles +- Select text to bring up a beautiful color picker overlay +- Choose from four distinct colors: yellow, green, blue, and red +- Your highlights are saved and synced across devices +- Tap on annotations in the list to jump directly to that passage in the article +- Glass morphism design for a modern, elegant look + +### Performance Improvements + +- **Dramatically faster label loading** - especially with 1000+ labels +- Labels now load instantly from local cache, then sync in background +- Optimized label management to prevent crashes and lag +- Share Extension now loads labels without delay +- Reduced memory usage when working with large label collections +- Better offline support - labels always available even without internet + +### Fixes & Improvements + +- Centralized color management for consistent appearance +- Improved annotation creation workflow +- Better text selection handling in article view +- Implemented lazy loading for label lists +- Switched to Core Data as primary source for labels +- Batch operations for faster database queries +- Background sync to keep labels up-to-date without blocking the UI +- Fixed duplicate ID warnings in label lists + +--- + ## Version 1.1 There is a lot of feature reqeusts and improvements in this release which are based on your feedback. Thank you so much for that! If you like the new features, please consider leaving a review on the App Store to support further development. diff --git a/readeck/UI/Components/TagManagementView.swift b/readeck/UI/Components/TagManagementView.swift index b6cb1e4..a680faa 100644 --- a/readeck/UI/Components/TagManagementView.swift +++ b/readeck/UI/Components/TagManagementView.swift @@ -214,7 +214,7 @@ struct TagManagementView: View { @ViewBuilder private var labelsScrollView: some View { ScrollView(.horizontal, showsIndicators: false) { - VStack(alignment: .leading, spacing: 8) { + LazyVStack(alignment: .leading, spacing: 8) { ForEach(chunkedLabels, id: \.self) { rowLabels in HStack(alignment: .top, spacing: 8) { ForEach(rowLabels, id: \.id) { label in