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:
parent
c629894611
commit
907cc9220f
@ -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)")
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user