From a4a876ac17135c3046dff338be2bc57e9bbbe00d Mon Sep 17 00:00:00 2001 From: kean Date: Mon, 30 Dec 2024 11:45:52 -0500 Subject: [PATCH] Add ImagePrefetcher --- Modules/Package.swift | 5 +- .../WordPressMedia/ImagePrefetcher.swift | 108 ++++++++++++++++++ .../Sources/WordPressMedia/ImageRequest.swift | 2 +- .../xcshareddata/swiftpm/Package.resolved | 11 +- .../Media/ImageDownloader+Extensions.swift | 6 + .../Reader/Cards/ReaderPostCell.swift | 20 ++-- .../ReaderStreamViewController.swift | 26 +++++ 7 files changed, 165 insertions(+), 13 deletions(-) create mode 100644 Modules/Sources/WordPressMedia/ImagePrefetcher.swift diff --git a/Modules/Package.swift b/Modules/Package.swift index 8c475f26cfd0..36669a34ba12 100644 --- a/Modules/Package.swift +++ b/Modules/Package.swift @@ -19,6 +19,7 @@ let package = Package( .package(url: "https://github.com/airbnb/lottie-ios", from: "4.4.0"), .package(url: "https://github.com/Alamofire/Alamofire", from: "5.9.1"), .package(url: "https://github.com/AliSoftware/OHHTTPStubs", from: "9.1.0"), + .package(url: "https://github.com/apple/swift-collections", from: "1.0.0"), .package(url: "https://github.com/Automattic/Automattic-Tracks-iOS", from: "3.4.2"), .package(url: "https://github.com/Automattic/AutomatticAbout-swift", from: "1.1.4"), .package(url: "https://github.com/Automattic/Gravatar-SDK-iOS", from: "3.1.0"), @@ -58,7 +59,9 @@ let package = Package( .product(name: "XCUITestHelpers", package: "XCUITestHelpers"), ], swiftSettings: [.swiftLanguageMode(.v5)]), .target(name: "WordPressFlux", swiftSettings: [.swiftLanguageMode(.v5)]), - .target(name: "WordPressMedia"), + .target(name: "WordPressMedia", dependencies: [ + .product(name: "Collections", package: "swift-collections"), + ]), .target(name: "WordPressSharedObjC", resources: [.process("Resources")], swiftSettings: [.swiftLanguageMode(.v5)]), .target(name: "WordPressShared", dependencies: [.target(name: "WordPressSharedObjC")], resources: [.process("Resources")], swiftSettings: [.swiftLanguageMode(.v5)]), .target(name: "WordPressTesting", resources: [.process("Resources")]), diff --git a/Modules/Sources/WordPressMedia/ImagePrefetcher.swift b/Modules/Sources/WordPressMedia/ImagePrefetcher.swift new file mode 100644 index 000000000000..d40ace3039ce --- /dev/null +++ b/Modules/Sources/WordPressMedia/ImagePrefetcher.swift @@ -0,0 +1,108 @@ +import UIKit +import Collections + +@ImageDownloaderActor +public final class ImagePrefetcher { + private let downloader: ImageDownloader + private let maxConcurrentTasks: Int + private var queue = OrderedDictionary() + private var numberOfActiveTasks = 0 + + deinit { + let tasks = queue.values.compactMap(\.task) + for task in tasks { + task.cancel() + } + } + + public nonisolated init(downloader: ImageDownloader, maxConcurrentTasks: Int = 2) { + self.downloader = downloader + self.maxConcurrentTasks = maxConcurrentTasks + } + + public nonisolated func startPrefetching(for requests: [ImageRequest]) { + Task { @ImageDownloaderActor in + for request in requests { + startPrefetching(for: request) + } + performPendingTasks() + } + } + + private func startPrefetching(for request: ImageRequest) { + let key = PrefetchKey(request: request) + guard queue[key] == nil else { + return + } + queue[key] = PrefetchTask() + } + + private func performPendingTasks() { + var index = 0 + func nextPendingTask() -> (PrefetchKey, PrefetchTask)? { + while index < queue.count { + if queue.elements[index].value.task == nil { + return queue.elements[index] + } + index += 1 + } + return nil + } + while numberOfActiveTasks < maxConcurrentTasks, let (key, task) = nextPendingTask() { + task.task = Task { + await self.actuallyPrefetchImage(for: key.request) + } + numberOfActiveTasks += 1 + } + } + + private func actuallyPrefetchImage(for request: ImageRequest) async { + _ = try? await downloader.image(for: request) + + numberOfActiveTasks -= 1 + queue[PrefetchKey(request: request)] = nil + performPendingTasks() + } + + public nonisolated func stopPrefetching(for requests: [ImageRequest]) { + Task { @ImageDownloaderActor in + for request in requests { + stopPrefetching(for: request) + } + performPendingTasks() + } + } + + private func stopPrefetching(for request: ImageRequest) { + let key = PrefetchKey(request: request) + if let task = queue.removeValue(forKey: key) { + task.task?.cancel() + } + } + + public nonisolated func stopAll() { + Task { @ImageDownloaderActor in + for (_, value) in queue { + value.task?.cancel() + } + queue.removeAll() + } + } + + private struct PrefetchKey: Hashable, Sendable { + let request: ImageRequest + + func hash(into hasher: inout Hasher) { + request.source.url?.hash(into: &hasher) + } + + static func == (lhs: PrefetchKey, rhs: PrefetchKey) -> Bool { + let (lhs, rhs) = (lhs.request, rhs.request) + return (lhs.source.url, lhs.options) == (rhs.source.url, rhs.options) + } + } + + private final class PrefetchTask: @unchecked Sendable { + var task: Task? + } +} diff --git a/Modules/Sources/WordPressMedia/ImageRequest.swift b/Modules/Sources/WordPressMedia/ImageRequest.swift index 3c77b28fe0cb..e5c811381183 100644 --- a/Modules/Sources/WordPressMedia/ImageRequest.swift +++ b/Modules/Sources/WordPressMedia/ImageRequest.swift @@ -27,7 +27,7 @@ public final class ImageRequest: Sendable { } } -public struct ImageRequestOptions: Sendable { +public struct ImageRequestOptions: Hashable, Sendable { /// Resize the thumbnail to the given size (in pixels). By default, `nil`. public var size: CGSize? diff --git a/WordPress.xcworkspace/xcshareddata/swiftpm/Package.resolved b/WordPress.xcworkspace/xcshareddata/swiftpm/Package.resolved index e3f213f8d1e8..eb6f17315f2a 100644 --- a/WordPress.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/WordPress.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "2325eaeb036deffbb1d475c9c1b62fef474fe61fdbea5d4335dd314f4bd5cab6", + "originHash" : "e79c26721ac0bbd7fe1003896d175bc4293a42c53ed03372aca8310d5da175ed", "pins" : [ { "identity" : "alamofire", @@ -306,6 +306,15 @@ "version" : "2.3.1" } }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections", + "state" : { + "revision" : "671108c96644956dddcd89dd59c203dcdb36cec7", + "version" : "1.1.4" + } + }, { "identity" : "swift-log", "kind" : "remoteSourceControl", diff --git a/WordPress/Classes/Utility/Media/ImageDownloader+Extensions.swift b/WordPress/Classes/Utility/Media/ImageDownloader+Extensions.swift index a35dadacf692..3cc28ddf4250 100644 --- a/WordPress/Classes/Utility/Media/ImageDownloader+Extensions.swift +++ b/WordPress/Classes/Utility/Media/ImageDownloader+Extensions.swift @@ -5,6 +5,12 @@ extension ImageDownloader { nonisolated static let shared = ImageDownloader(authenticator: MediaRequestAuthenticator()) } +extension ImagePrefetcher { + convenience nonisolated init() { + self.init(downloader: .shared) + } +} + // MARK: - ImageDownloader (Closures) extension ImageDownloader { diff --git a/WordPress/Classes/ViewRelated/Reader/Cards/ReaderPostCell.swift b/WordPress/Classes/ViewRelated/Reader/Cards/ReaderPostCell.swift index c1b28ec48686..844d35587976 100644 --- a/WordPress/Classes/ViewRelated/Reader/Cards/ReaderPostCell.swift +++ b/WordPress/Classes/ViewRelated/Reader/Cards/ReaderPostCell.swift @@ -50,6 +50,15 @@ final class ReaderPostCell: ReaderStreamBaseCell { contentViewConstraints = view.pinEdges(.horizontal, to: isCompact ? contentView : contentView.readableContentGuide) super.updateConstraints() } + + static func preferredCoverSize(in window: UIWindow, isCompact: Bool) -> CGSize { + var coverWidth = ReaderPostCell.regularCoverWidth + if isCompact { + coverWidth = min(window.bounds.width, window.bounds.height) - ReaderStreamBaseCell.insets.left * 2 + } + return CGSize(width: coverWidth, height: coverWidth) + .scaled(by: min(2, window.traitCollection.displayScale)) + } } private final class ReaderPostCellView: UIView { @@ -307,16 +316,7 @@ private final class ReaderPostCellView: UIView { private var preferredCoverSize: CGSize? { guard let window = window ?? UIApplication.shared.mainWindow else { return nil } - return Self.preferredCoverSize(in: window, isCompact: isCompact) - } - - static func preferredCoverSize(in window: UIWindow, isCompact: Bool) -> CGSize { - var coverWidth = ReaderPostCell.regularCoverWidth - if isCompact { - coverWidth = min(window.bounds.width, window.bounds.height) - ReaderStreamBaseCell.insets.left * 2 - } - return CGSize(width: coverWidth, height: coverWidth) - .scaled(by: min(2, window.traitCollection.displayScale)) + return ReaderPostCell.preferredCoverSize(in: window, isCompact: isCompact) } private func configureToolbar(with viewModel: ReaderPostToolbarViewModel) { diff --git a/WordPress/Classes/ViewRelated/Reader/Controllers/ReaderStreamViewController.swift b/WordPress/Classes/ViewRelated/Reader/Controllers/ReaderStreamViewController.swift index ae6bca99e855..5b8b266697c2 100644 --- a/WordPress/Classes/ViewRelated/Reader/Controllers/ReaderStreamViewController.swift +++ b/WordPress/Classes/ViewRelated/Reader/Controllers/ReaderStreamViewController.swift @@ -2,6 +2,7 @@ import Foundation import SVProgressHUD import WordPressShared import WordPressFlux +import WordPressMedia import UIKit import Combine import WordPressUI @@ -88,6 +89,8 @@ import AutomatticTracks /// Configuration of cells private let cellConfiguration = ReaderCellConfiguration() + private let prefetcher = ImagePrefetcher() + enum NavigationItemTag: Int { case notifications case share @@ -477,6 +480,7 @@ import AutomatticTracks tableViewController.didMove(toParent: self) tableConfiguration.setup(tableView) tableView.delegate = self + tableView.prefetchDataSource = self } @objc func configureRefreshControl() { @@ -1494,6 +1498,28 @@ extension ReaderStreamViewController: WPTableViewHandlerDelegate { } } +extension ReaderStreamViewController: UITableViewDataSourcePrefetching { + func tableView(_ tableView: UITableView, prefetchRowsAt indexPaths: [IndexPath]) { + prefetcher.startPrefetching(for: makeImageRequests(for: indexPaths)) + } + + func tableView(_ tableView: UITableView, cancelPrefetchingForRowsAt indexPaths: [IndexPath]) { + prefetcher.stopPrefetching(for: makeImageRequests(for: indexPaths)) + + } + + private func makeImageRequests(for indexPaths: [IndexPath]) -> [ImageRequest] { + guard let window = view.window else { return [] } + let targetSize = ReaderPostCell.preferredCoverSize(in: window, isCompact: isCompact) + return indexPaths.compactMap { + guard let imageURL = getPost(at: $0)?.featuredImageURLForDisplay() else { + return nil + } + return ImageRequest(url: imageURL, options: ImageRequestOptions(size: targetSize)) + } + } +} + // MARK: - SearchableActivity Conformance extension ReaderStreamViewController: SearchableActivityConvertable {