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.
This commit is contained in:
Ilyas Hallak 2025-10-26 21:22:15 +01:00
parent c629894611
commit 907cc9220f
5 changed files with 152 additions and 52 deletions

View File

@ -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> = 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> = 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)")
}
}
}

View File

@ -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()
}
}
}

View File

@ -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> = 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> = 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> = 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()
}
}
}
}

View File

@ -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.

View File

@ -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