diff options
| author | Fuwn <[email protected]> | 2026-02-18 12:33:44 -0800 |
|---|---|---|
| committer | Fuwn <[email protected]> | 2026-02-18 12:33:50 -0800 |
| commit | 0587c64598267ecace6259241198425cdc284f3a (patch) | |
| tree | ba90872e872e90ae98096c1da81ceb5a66d5c476 /Sora | |
| parent | refactor: share folder menu hierarchy across views (diff) | |
| download | sora-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.swift | 77 | ||||
| -rw-r--r-- | Sora/Views/Post/Details/PostDetailsImageView.swift | 25 | ||||
| -rw-r--r-- | Sora/Views/SearchSuggestionsView.swift | 68 |
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() + } } } |