summaryrefslogtreecommitdiff
path: root/Sora
diff options
context:
space:
mode:
authorFuwn <[email protected]>2026-02-18 12:33:44 -0800
committerFuwn <[email protected]>2026-02-18 12:33:50 -0800
commit0587c64598267ecace6259241198425cdc284f3a (patch)
treeba90872e872e90ae98096c1da81ceb5a66d5c476 /Sora
parentrefactor: share folder menu hierarchy across views (diff)
downloadsora-testing-0587c64598267ecace6259241198425cdc284f3a.tar.xz
sora-testing-0587c64598267ecace6259241198425cdc284f3a.zip
perf: reduce suggestion and image handling hot-path overhead
Diffstat (limited to 'Sora')
-rw-r--r--Sora/Data/ImageCacheManager.swift77
-rw-r--r--Sora/Views/Post/Details/PostDetailsImageView.swift25
-rw-r--r--Sora/Views/SearchSuggestionsView.swift68
3 files changed, 115 insertions, 55 deletions
diff --git a/Sora/Data/ImageCacheManager.swift b/Sora/Data/ImageCacheManager.swift
index 8a14198..5561f24 100644
--- a/Sora/Data/ImageCacheManager.swift
+++ b/Sora/Data/ImageCacheManager.swift
@@ -1,4 +1,3 @@
-import Combine
@preconcurrency import SwiftUI
@MainActor
@@ -13,14 +12,13 @@ final class ImageCacheManager {
directory: FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first?
.appendingPathComponent("SoraImageCache", isDirectory: true)
)
- private var cancellables = Set<AnyCancellable>()
private let downloadQueue = OperationQueue()
private var preloadingURLs = Set<URL>()
private var memoryWarningObserver: NSObjectProtocol?
// MARK: - Initialisation
private init() {
- downloadQueue.maxConcurrentOperationCount = 5
+ downloadQueue.maxConcurrentOperationCount = 3
downloadQueue.qualityOfService = .utility
#if os(iOS)
@@ -38,46 +36,85 @@ final class ImageCacheManager {
// MARK: - Public Methods
func preloadImages(_ urls: [URL]) {
- let newURLs = urls.filter { !preloadingURLs.contains($0) }
+ let newURLs = urls.filter { url in
+ !preloadingURLs.contains(url) && cache.cachedResponse(for: URLRequest(url: url)) == nil
+ }
for url in newURLs {
preloadingURLs.insert(url)
- downloadQueue.addOperation {
- let cancellable = URLSession.shared.dataTaskPublisher(for: url)
- .map { CachedURLResponse(response: $0.response, data: $0.data) }
- .sink(
- receiveCompletion: { _ in
- DispatchQueue.main.async { [weak self] in
- self?.preloadingURLs.remove(url)
- }
- },
- receiveValue: { [weak self] cachedResponse in
- self?.cache.storeCachedResponse(cachedResponse, for: URLRequest(url: url))
+ downloadQueue.addOperation { [weak self] in
+ guard let self else { return }
+
+ let completionSignal = DispatchSemaphore(value: 0)
+
+ URLSession.shared.dataTask(with: url) { data, response, _ in
+ Task { @MainActor [weak self] in
+ guard let self else {
+ completionSignal.signal()
+ return
+ }
+
+ defer {
+ preloadingURLs.remove(url)
+ completionSignal.signal()
}
- )
- DispatchQueue.main.async { [weak self] in
- self?.cancellables.insert(cancellable)
+ guard let data, let response else { return }
+
+ storeCachedResponse(
+ data: data,
+ response: response,
+ for: url
+ )
+ }
}
+ .resume()
+
+ completionSignal.wait()
}
}
}
func clearCache() {
cache.removeAllCachedResponses()
- cancellables.removeAll()
preloadingURLs.removeAll()
}
+ func storeCachedResponse(data: Data, response: URLResponse, for url: URL) {
+ cache.storeCachedResponse(
+ CachedURLResponse(response: response, data: data),
+ for: URLRequest(url: url)
+ )
+ }
+
func getCachedResponse(for url: URL) -> CachedURLResponse? {
cache.cachedResponse(for: URLRequest(url: url))
}
+ func loadImageData(for url: URL) async -> Data? {
+ if let cachedResponse = getCachedResponse(for: url) {
+ return cachedResponse.data
+ }
+
+ do {
+ let (data, response) = try await URLSession.shared.data(from: url)
+
+ storeCachedResponse(
+ data: data,
+ response: response,
+ for: url
+ )
+
+ return data
+ } catch {
+ return nil
+ }
+ }
+
private func handleMemoryPressure() {
cache.removeAllCachedResponses()
downloadQueue.cancelAllOperations()
- cancellables.removeAll()
preloadingURLs.removeAll()
}
diff --git a/Sora/Views/Post/Details/PostDetailsImageView.swift b/Sora/Views/Post/Details/PostDetailsImageView.swift
index f9e49db..c33f7db 100644
--- a/Sora/Views/Post/Details/PostDetailsImageView.swift
+++ b/Sora/Views/Post/Details/PostDetailsImageView.swift
@@ -51,14 +51,17 @@ struct PostDetailsImageView<Placeholder: View>: View {
#if os(iOS)
Button {
- guard let url else { return }
+ guard let imageURL = url else { return }
- URLSession.shared.dataTask(with: url) { data, _, _ in
- guard let data, let uiImage = UIImage(data: data) else { return }
+ Task(priority: .userInitiated) {
+ guard let imageData = await ImageCacheManager.shared.loadImageData(for: imageURL),
+ let uiImage = UIImage(data: imageData)
+ else { return }
- UIImageWriteToSavedPhotosAlbum(uiImage, nil, nil, nil)
+ await MainActor.run {
+ UIImageWriteToSavedPhotosAlbum(uiImage, nil, nil, nil)
+ }
}
- .resume()
} label: {
Label("Save to Photos", systemImage: "square.and.arrow.down")
}
@@ -75,12 +78,14 @@ struct PostDetailsImageView<Placeholder: View>: View {
Button {
#if os(iOS)
- let url = url
-
Task(priority: .userInitiated) {
- // swiftlint:disable:next legacy_objc_type
- if let data = NSData(contentsOf: url ?? URL(string: "")!) {
- UIPasteboard.general.image = UIImage(data: data as Data)
+ guard let imageURL = url else { return }
+ guard let imageData = await ImageCacheManager.shared.loadImageData(for: imageURL),
+ let uiImage = UIImage(data: imageData)
+ else { return }
+
+ await MainActor.run {
+ UIPasteboard.general.image = uiImage
}
}
#else
diff --git a/Sora/Views/SearchSuggestionsView.swift b/Sora/Views/SearchSuggestionsView.swift
index 03af4c6..e9593ec 100644
--- a/Sora/Views/SearchSuggestionsView.swift
+++ b/Sora/Views/SearchSuggestionsView.swift
@@ -9,13 +9,21 @@ struct SearchSuggestionsView: View {
var items: [Either<BooruTag, BooruSearchQuery>]
@Binding var searchText: String
@Binding var suppressNextSearchSubmit: Bool
+ @State private var cachedTags: [CachedTag] = []
private var lastSearchTag: String {
String(searchText.split(separator: " ").last ?? "").lowercased()
}
- private var cachedTags: [CachedTag] {
- items.map { item in
+ private var itemsCacheKey: Int {
+ items.reduce(into: Hasher()) { hasher, item in
+ hasher.combine(item)
+ }
+ .finalize()
+ }
+
+ private func refreshCachedTags() {
+ cachedTags = items.map { item in
switch item {
case .left(let tag):
return CachedTag(original: item, names: [tag.name.lowercased()])
@@ -53,36 +61,46 @@ struct SearchSuggestionsView: View {
}
var body: some View {
- ForEach(filteredItems, id: \.self) { item in
- switch item {
- case .left(let tag):
- Button {
- let previousTags = searchText.split(separator: " ").dropLast()
-
- suppressNextSearchSubmit = true
- searchText = (previousTags.map(String.init) + [tag.name]).joined(separator: " ")
- } label: {
- Text(tag.name)
- }
-
- case .right(let query):
- Button {
- if let matchingTag = query.tags.first(where: { $0.lowercased().contains(lastSearchTag) })
- {
+ Group {
+ ForEach(filteredItems, id: \.self) { item in
+ switch item {
+ case .left(let tag):
+ Button {
let previousTags = searchText.split(separator: " ").dropLast()
suppressNextSearchSubmit = true
- searchText = (previousTags.map(String.init) + [matchingTag]).joined(separator: " ")
+ searchText = (previousTags.map(String.init) + [tag.name]).joined(separator: " ")
+ } label: {
+ Text(tag.name)
}
- } label: {
- if let matchingTag = query.tags.first(where: { $0.lowercased().contains(lastSearchTag) })
- {
- Text(matchingTag)
- } else {
- Text(query.tags.first ?? "")
+
+ case .right(let query):
+ let matchingTag = query.tags.first { tag in
+ tag.lowercased().contains(lastSearchTag)
+ }
+
+ Button {
+ if let matchingTag {
+ let previousTags = searchText.split(separator: " ").dropLast()
+
+ suppressNextSearchSubmit = true
+ searchText = (previousTags.map(String.init) + [matchingTag]).joined(separator: " ")
+ }
+ } label: {
+ if let matchingTag {
+ Text(matchingTag)
+ } else {
+ Text(query.tags.first ?? "")
+ }
}
}
}
}
+ .onAppear {
+ refreshCachedTags()
+ }
+ .onChange(of: itemsCacheKey) {
+ refreshCachedTags()
+ }
}
}