Skip to content

Commit

Permalink
Add ImagePrefetcher
Browse files Browse the repository at this point in the history
  • Loading branch information
kean committed Dec 30, 2024
1 parent e039957 commit a4a876a
Show file tree
Hide file tree
Showing 7 changed files with 165 additions and 13 deletions.
5 changes: 4 additions & 1 deletion Modules/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down Expand Up @@ -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")]),
Expand Down
108 changes: 108 additions & 0 deletions Modules/Sources/WordPressMedia/ImagePrefetcher.swift
Original file line number Diff line number Diff line change
@@ -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<PrefetchKey, PrefetchTask>()
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<Void, Error>?
}
}
2 changes: 1 addition & 1 deletion Modules/Sources/WordPressMedia/ImageRequest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?

Expand Down
11 changes: 10 additions & 1 deletion WordPress.xcworkspace/xcshareddata/swiftpm/Package.resolved
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"originHash" : "2325eaeb036deffbb1d475c9c1b62fef474fe61fdbea5d4335dd314f4bd5cab6",
"originHash" : "e79c26721ac0bbd7fe1003896d175bc4293a42c53ed03372aca8310d5da175ed",
"pins" : [
{
"identity" : "alamofire",
Expand Down Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
20 changes: 10 additions & 10 deletions WordPress/Classes/ViewRelated/Reader/Cards/ReaderPostCell.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import Foundation
import SVProgressHUD
import WordPressShared
import WordPressFlux
import WordPressMedia
import UIKit
import Combine
import WordPressUI
Expand Down Expand Up @@ -88,6 +89,8 @@ import AutomatticTracks
/// Configuration of cells
private let cellConfiguration = ReaderCellConfiguration()

private let prefetcher = ImagePrefetcher()

enum NavigationItemTag: Int {
case notifications
case share
Expand Down Expand Up @@ -477,6 +480,7 @@ import AutomatticTracks
tableViewController.didMove(toParent: self)
tableConfiguration.setup(tableView)
tableView.delegate = self
tableView.prefetchDataSource = self
}

@objc func configureRefreshControl() {
Expand Down Expand Up @@ -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 {
Expand Down

0 comments on commit a4a876a

Please sign in to comment.