Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add ImagePrefetcher (and swift-collection) and integrate in Reader #23928

Merged
merged 2 commits into from
Dec 30, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any particular reason of using a global actor, instead of making ImagePrefetcher an actor?

Copy link
Contributor Author

@kean kean Jan 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's much easier to reason about code when every part of the system – image downloader in that case – is synchronized on the same actor. The main reason is that you can avoid a lot of async calls because when you are on an actor (@ImageDownloaderActor), you don't need async to invoke methods on other classes synchronized on the same actor.

It's also beneficial for performance as you can avoid unnecessary context switches. For example, when ImagePrefetcher calls _ = try? await downloader.image(for: request), there is no context switch as the code already runs on @ImageDownloaderActor.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure if there are any situation where a new actor would be preferable to a global actor or another form of synchronization.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmmm, I'm kind of on the opposite side of this ⬆️ . I would always define actor types, rather than define global actors. I see global actors as a way to synchronize multiple types, which I rarely need.

It's also beneficial for performance as you can avoid unnecessary context switches. For example, when ImagePrefetcher calls _ = try? await downloader.image(for: request), there is no context switch as the code already runs on @ImageDownloaderActor.

Ah, I missed that ImageDownloaderActor is used by other types. Now it makes sense to me why ImageDownloaderActor global actor is used here.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree, I think creating a new actor is a good starting point if there is no synchronization needed across different types/methods.

private let downloader: ImageDownloader
private let maxConcurrentTasks: Int
private var queue = OrderedDictionary<PrefetchKey, PrefetchTask>()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel like OperationQueue might be a better suit here. It manages two key things here: concurrency and queue.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

concurrency

This part we don't really need because the entire systems already runs in the background.

If you need an async Operation, I think these have outlived their usefulness in the Swift Concurrency world. There needs to be a new primitive in the standard library. But it's relatively easy to limit a maximum number of tasks even without one.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For me personally, I probably would choose OperationQueue over implementing task orchestration (which is the core implementation here) myself, even though it does not feel like a "modern tech". 😆

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should find a good open-source implementation that's sound and covered with tests. If you have something in mind, please let me know. I also recently took a stab at it https://github.com/kean/Nuke/blob/task-queue/Sources/Nuke/Internal/WorkQueue.swift, but it's not finished yet, and I'm not sure if there is a way to make it general-purpose by allowing you to customize it with an actor.

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()
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function looks a bit weird to me. Although the function signature says it's non-isolated, semantically, the implementation is isolated.

IMHO, it makes more sense to remove nonisolated and make callers create Task { await prefetcher.startPrefectching(...) } instead.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, this doesn't feel quite right, but in this case, you will have to write Task { @ImageDownloaderActor in in 100% of the places where you use it, so I opted to make it nonisolated and callable from any isolation domain, including the main actor. It's easier and cleaner from the caller point of view.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would you need the @ImageDownloaderActor part? I thought the func in Task { await actor.func() } would just run in the actor, regardless actor is an actor type or bound to an global actor.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The rules for isolation inheritance are a bit tricky. In this case, since that method that creates a Task is nonisolated, so is the task.

nonisolated func startPrefetching(...) {
    Task { 
        // The closure is **not** isolated because the containing method is
        // ``nonisolated``. It will require an `await` (async invocation).
        // await startPrefetching(for: request)
    }
}

nonisolated func startPrefetching(...) {
    Task { @ImageDownloaderActor in
         // OK since the closure is explicitly isolated to `@ImageDownloaderActor`
        startPrefetching(for: request)
    }
}

func startPrefetching(...) {
    Task { 
        // The closure is isolated to the containing isolation domain (of the containing type)
        startPrefetching(for: request) 
    }
}


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()
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should maxConcurrentTasks value be updated in stopPrefetching and stopAll?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's probably worth writing a few unit tests for this class.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Yeah, it needs some tests 😩


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
1 change: 1 addition & 0 deletions RELEASE-NOTES.txt
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
25.7
-----
* [**] Add new lightbox screen for images with modern transitions and enhanced performance [#23922]
* [*] Add prefetching to Reader streams [#23928]

25.6
-----
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