Skip to content

Commit f1dda0f

Browse files
committed
Fix performance issues with search in Reader Subscriptions
1 parent 203abd5 commit f1dda0f

File tree

2 files changed

+124
-10
lines changed

2 files changed

+124
-10
lines changed

Modules/Sources/WordPressShared/Utility/StringRankedSearch.swift

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,53 @@ extension StringRankedSearch {
119119
.sorted { $0.1 > $1.1 }
120120
.map(\.0)
121121
}
122+
123+
/// Parallel version of search that computes scores concurrently for better performance.
124+
/// Divides work into chunks based on available CPU cores for optimal performance.
125+
///
126+
/// - parameter items: The items to search through
127+
/// - parameter minScore: Minimum score threshold (default 0.7)
128+
/// - parameter input: Function to extract searchable text from each item
129+
/// - returns: Array of matching items sorted by relevance score
130+
public func parallelSearch<S: Sequence>(
131+
in items: S,
132+
minScore: Double = 0.7,
133+
input: @escaping @Sendable (S.Element) -> String
134+
) async -> [S.Element] where S.Element: Sendable {
135+
let items = Array(items)
136+
137+
// Calculate optimal chunk size based on CPU cores (leave one core free)
138+
let maxConcurrency = max(1, ProcessInfo.processInfo.activeProcessorCount - 1)
139+
let chunkSize = max(1, items.count / maxConcurrency)
140+
141+
// Divide items into chunks
142+
let chunks = items.chunked(into: chunkSize)
143+
144+
// Process chunks in parallel using TaskGroup
145+
let results = await withTaskGroup(of: [(S.Element, Double)].self) { group in
146+
for chunk in chunks {
147+
group.addTask { [self] in
148+
chunk.compactMap { item -> (S.Element, Double)? in
149+
let itemScore = self.score(for: input(item))
150+
guard itemScore > minScore else { return nil }
151+
return (item, itemScore)
152+
}
153+
}
154+
}
155+
156+
// Collect all results from chunks
157+
var collected: [(S.Element, Double)] = []
158+
for await chunkResults in group {
159+
collected.append(contentsOf: chunkResults)
160+
}
161+
return collected
162+
}
163+
164+
// Sort by score and return items
165+
return results
166+
.sorted { $0.1 > $1.1 }
167+
.map(\.0)
168+
}
122169
}
123170

124171
public extension Sequence {
@@ -132,3 +179,12 @@ public extension Sequence {
132179
}
133180

134181
}
182+
183+
private extension Array {
184+
/// Divides the array into chunks of the specified size.
185+
func chunked(into chunkSize: Int) -> [[Element]] {
186+
return stride(from: 0, to: count, by: chunkSize).map {
187+
Array(self[$0..<Swift.min($0 + chunkSize, count)])
188+
}
189+
}
190+
}

WordPress/Classes/ViewRelated/Reader/Subscriptions/ReaderSubscriptionsView.swift

Lines changed: 68 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import SwiftUI
22
import WordPressData
33
import WordPressUI
44
import WordPressShared
5+
import CoreData
56

67
struct ReaderSubscriptionsView: View {
78
@FetchRequest(
@@ -13,14 +14,14 @@ struct ReaderSubscriptionsView: View {
1314
@State private var searchText = ""
1415
@State private var isShowingMainAddSubscriptonPopover = false
1516

16-
@State private var searchResults: [ReaderSiteTopic] = []
17+
@State private var searchResults: [ReaderSiteTopic]?
18+
@State private var searchTask: Task<Void, Never>?
19+
@State private var pendingSearchText: String?
1720

1821
@StateObject private var viewModel = ReaderSubscriptionsViewModel()
1922

2023
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
2124

22-
var isShowingSearchResuts: Bool { !searchText.isEmpty }
23-
2425
var onSelection: (_ subscription: ReaderSiteTopic) -> Void = { _ in }
2526

2627
var body: some View {
@@ -72,7 +73,7 @@ struct ReaderSubscriptionsView: View {
7273

7374
private var main: some View {
7475
List {
75-
if isShowingSearchResuts {
76+
if let searchResults {
7677
ForEach(searchResults, id: \.objectID, content: makeSubscriptionCell)
7778
.onDelete(perform: delete)
7879
} else {
@@ -84,11 +85,11 @@ struct ReaderSubscriptionsView: View {
8485
.searchable(text: $searchText)
8586
.onReceive(subscriptions.publisher) { _ in
8687
if !searchText.isEmpty {
87-
reloadSearchResults(searchText: searchText)
88+
performBackgroundSearch(searchText: searchText)
8889
}
8990
}
9091
.onChange(of: searchText) {
91-
reloadSearchResults(searchText: $0)
92+
performBackgroundSearch(searchText: $0)
9293
}
9394
}
9495

@@ -117,7 +118,7 @@ struct ReaderSubscriptionsView: View {
117118
}
118119

119120
private func getSubscription(at index: Int) -> ReaderSiteTopic {
120-
if isShowingSearchResuts {
121+
if let searchResults {
121122
searchResults[index]
122123
} else {
123124
subscriptions[index]
@@ -128,9 +129,66 @@ struct ReaderSubscriptionsView: View {
128129
ReaderSubscriptionHelper().unfollow(site)
129130
}
130131

131-
private func reloadSearchResults(searchText: String) {
132-
let ranking = StringRankedSearch(searchTerm: searchText)
133-
searchResults = ranking.search(in: subscriptions) { "\($0.title) \($0.siteURL)" }
132+
private func performBackgroundSearch(searchText: String) {
133+
struct SearchableSubscription: Sendable {
134+
let objectID: NSManagedObjectID
135+
let title: String
136+
let siteURL: String
137+
138+
var searchableText: String {
139+
"\(title) \(siteURL)"
140+
}
141+
142+
init(_ subscription: ReaderSiteTopic) {
143+
self.objectID = subscription.objectID
144+
self.title = subscription.title
145+
self.siteURL = subscription.siteURL
146+
}
147+
}
148+
149+
// Cancel any existing search task
150+
searchTask?.cancel()
151+
152+
// Clear results immediately if search text is empty
153+
if searchText.isEmpty {
154+
searchResults = nil
155+
pendingSearchText = nil
156+
return
157+
}
158+
159+
// Start new background search task
160+
searchTask = Task {
161+
// Store the search text we're processing
162+
let currentSearchText = searchText
163+
164+
// Create searchable data on main thread to avoid Core Data context issues
165+
let searchableData = subscriptions.map(SearchableSubscription.init)
166+
167+
// Perform the search on a background queue with parallel processing
168+
let resultObjectIDs = await StringRankedSearch(searchTerm: currentSearchText)
169+
.parallelSearch(in: searchableData) { $0.searchableText }
170+
.map(\.objectID)
171+
172+
// Check if we were cancelled or if search text changed during search
173+
guard !Task.isCancelled else { return }
174+
175+
// Update results on main thread
176+
await MainActor.run {
177+
// Only update if this search is still relevant
178+
if currentSearchText == searchText {
179+
searchResults = subscriptions.filter { resultObjectIDs.contains($0.objectID) }
180+
pendingSearchText = nil
181+
} else {
182+
// Search text changed during our search, mark that we need a new search
183+
pendingSearchText = searchText
184+
}
185+
186+
// If there's a pending search, start it now
187+
if let pendingSearchText {
188+
performBackgroundSearch(searchText: pendingSearchText)
189+
}
190+
}
191+
}
134192
}
135193
}
136194

0 commit comments

Comments
 (0)