Skip to content
Merged
8 changes: 7 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@

-----

## [Next]

#### Add
* Add opt-in `asyncCacheTypeCheck` for `KingfisherManager` retrieval to move cache-type probing off the caller thread while preserving cache-only behavior, callback queue delivery, forced cache extensions, and task cancellation for provider-backed or async request-modified loads. [#2521](https://github.com/onevcat/Kingfisher/pull/2521) @onevcat

---

## [8.8.1 - Fresh Cache](https://github.com/onevcat/Kingfisher/releases/tag/8.8.1) (2026-04-01)

#### Fix
Expand Down Expand Up @@ -2028,4 +2035,3 @@

First public release.


1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ Kingfisher is a powerful, pure-Swift library for downloading and caching images
- [x] Useful image processors and filters provided.
- [x] Multiple-layer hybrid cache for both memory and disk.
- [x] Fine control on cache behavior. Customizable expiration date and size limit.
- [x] Opt-in async cache probing in `KingfisherManager` to avoid blocking the caller thread on disk cache checks.
- [x] Cancelable downloading and auto-reusing previous downloaded content to improve performance.
- [x] Independent components. Use the downloader, caching system, and image processors separately as you need.
- [x] Prefetching images and showing them from the cache to boost your app.
Expand Down
476 changes: 333 additions & 143 deletions Sources/General/KingfisherManager.swift

Large diffs are not rendered by default.

24 changes: 22 additions & 2 deletions Sources/General/KingfisherOptionsInfo.swift
Original file line number Diff line number Diff line change
Expand Up @@ -231,15 +231,33 @@ public enum KingfisherOptionsInfoItem: Sendable {

/// When set, disk storage loading will occur in the same calling queue.
///
/// By default, disk storage file loading operates on its own queue with asynchronous dispatch behavior. While this
/// By default, disk storage file loading operates on its own queue with asynchronous dispatch behavior. While this
/// provides improved non-blocking disk loading performance, it can lead to flickering when you reload an image from
/// disk if the image view already has an image set.
///
/// Setting this option will eliminate that flickering by keeping all loading in the same queue (typically the UI
/// Setting this option will eliminate that flickering by keeping all loading in the same queue (typically the UI
/// queue if you are using Kingfisher's extension methods to set an image). However, this comes with a tradeoff in
/// loading performance.
case loadDiskFileSynchronously

/// When set, the cache existence probe that decides whether an image is already cached is dispatched onto the cache's
/// I/O queue instead of running synchronously on the caller thread.
///
/// By default, ``KingfisherManager`` calls ``ImageCache/imageCachedType(forKey:processorIdentifier:forcedExtension:)``
/// before deciding between a cache read and a network download. That call performs file-system `stat` syscalls on
/// whatever thread invoked `setImage`. When `setImage` is called from UIKit layout callbacks such as
/// `tableView(_:cellForRowAt:)` or `collectionView(_:cellForItemAt:)` on a device under disk pressure, those syscalls
/// can hang the main thread.
///
/// Opt in to this flag to move the probe onto the cache's I/O queue via
/// ``ImageCache/imageCachedTypeAsync(forKey:processorIdentifier:forcedExtension:callbackQueue:completionHandler:)``.
/// The ``DownloadTask`` returned from `setImage` is still delivered synchronously; if the probe discovers a cache
/// miss, the resulting network task is linked to the returned shell via ``DownloadTask/linkToTask(_:)``.
///
/// - Note: If ``KingfisherOptionsInfoItem/loadDiskFileSynchronously`` is set, that option continues to govern the
/// actual disk read. This flag only affects the existence probe that precedes the read.
case asyncCacheTypeCheck
Comment on lines +246 to +259
Copy link

Copilot AI Apr 18, 2026

Choose a reason for hiding this comment

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

The doc comment for asyncCacheTypeCheck states that KingfisherManager calls ImageCache.imageCachedType(forKey:processorIdentifier:forcedExtension:) by default, but the current sync probe in retrieveImageFromCache does not pass forcedExtension. Either update the manager probe to include forcedExtension (so the doc is accurate) or adjust this comment to reflect the actual behavior.

Copilot uses AI. Check for mistakes.

/// Options for controlling the data writing process to disk storage.
///
/// When set, these options will be passed to the store operation for new files.
Expand Down Expand Up @@ -405,6 +423,7 @@ public struct KingfisherParsedOptionsInfo: Sendable {
public var onFailureImage: Optional<KFCrossPlatformImage?> = .none
public var alsoPrefetchToMemory = false
public var loadDiskFileSynchronously = false
public var asyncCacheTypeCheck = false
public var diskStoreWriteOptions: Data.WritingOptions = []
public var memoryCacheExpiration: StorageExpiration? = nil
public var memoryCacheAccessExtendingExpiration: ExpirationExtending = .cacheTime
Expand Down Expand Up @@ -451,6 +470,7 @@ public struct KingfisherParsedOptionsInfo: Sendable {
case .onFailureImage(let value): onFailureImage = .some(value)
case .alsoPrefetchToMemory: alsoPrefetchToMemory = true
case .loadDiskFileSynchronously: loadDiskFileSynchronously = true
case .asyncCacheTypeCheck: asyncCacheTypeCheck = true
case .diskStoreWriteOptions(let options): diskStoreWriteOptions = options
case .memoryCacheExpiration(let expiration): memoryCacheExpiration = expiration
case .memoryCacheAccessExtendingExpiration(let expirationExtending): memoryCacheAccessExtendingExpiration = expirationExtending
Expand Down
17 changes: 11 additions & 6 deletions Sources/Networking/ImageDownloader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -85,12 +85,14 @@ public final class DownloadTask: @unchecked Sendable {
_providerTask = providerTask
}

private var _linkedTask: DownloadTask? = nil

private var _providerTask: Task<Void, Never>? = nil

/// The Swift concurrency `Task` driving an ``ImageDataProvider`` load, if this
/// `DownloadTask` represents a provider-backed load.
var providerTask: Task<Void, Never>? {
get { propertyQueue.sync { _providerTask } }
get { propertyQueue.sync { _providerTask ?? _linkedTask?.providerTask } }
set { propertyQueue.sync { _providerTask = newValue } }
}

Expand All @@ -103,7 +105,7 @@ public final class DownloadTask: @unchecked Sendable {
/// When you call ``DownloadTask/cancel()``, this ``SessionDataTask`` and its cancellation token will be passed
/// along. You can use them to identify the cancelled task.
public private(set) var sessionTask: SessionDataTask? {
get { propertyQueue.sync { _sessionTask } }
get { propertyQueue.sync { _sessionTask ?? _linkedTask?.sessionTask } }
set { propertyQueue.sync { _sessionTask = newValue } }
}

Expand All @@ -114,7 +116,7 @@ public final class DownloadTask: @unchecked Sendable {
/// This is solely for identifying the task when it is cancelled. To cancel a ``DownloadTask``, call
/// ``DownloadTask/cancelToken``.
public private(set) var cancelToken: SessionDataTask.CancelToken? {
get { propertyQueue.sync { _cancelToken } }
get { propertyQueue.sync { _cancelToken ?? _linkedTask?.cancelToken } }
set { propertyQueue.sync { _cancelToken = newValue } }
}

Expand Down Expand Up @@ -145,13 +147,16 @@ public final class DownloadTask: @unchecked Sendable {

public var isInitialized: Bool {
propertyQueue.sync {
_sessionTask != nil && _cancelToken != nil
(_sessionTask != nil && _cancelToken != nil) ||
_providerTask != nil ||
(_linkedTask?.isInitialized ?? false)
}
}

func linkToTask(_ task: DownloadTask) {
self.sessionTask = task.sessionTask
self.cancelToken = task.cancelToken
propertyQueue.sync {
_linkedTask = task
}
}
}

Expand Down
67 changes: 45 additions & 22 deletions Tests/KingfisherTests/ImageViewExtensionTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@
import XCTest
@testable import Kingfisher

class ImageViewExtensionTests: XCTestCase {
class ImageViewExtensionTests: XCTestCase, @unchecked Sendable {

var imageView: KFCrossPlatformImageView!

Expand Down Expand Up @@ -677,32 +677,55 @@ class ImageViewExtensionTests: XCTestCase {
@MainActor func testSetSameURLWithDifferentProcessors() {
let exp = expectation(description: #function)
let url = testURLs[0]

stub(url, data: testImageData)

let size1 = CGSize(width: 10, height: 10)
let p1 = ResizingImageProcessor(referenceSize: size1)

let size2 = CGSize(width: 20, height: 20)
let p2 = ResizingImageProcessor(referenceSize: size2)

let group = DispatchGroup()

group.enter()
imageView.kf.setImage(with: url, options: [.processor(p1), .cacheMemoryOnly]) { result in
XCTAssertNotNil(result.error)
XCTAssertTrue(result.error!.isNotCurrentTask)
group.leave()
}

group.enter()
imageView.kf.setImage(with: url, options: [.processor(p2), .cacheMemoryOnly]) { result in
XCTAssertNotNil(result.value)
XCTAssertEqual(result.value!.image.size, size2)
group.leave()
let coordinator = CoordinatingCacheSerializer()
let cache = KingfisherManager.shared.cache

cache.store(testImage, original: testImageData, forKey: url.cacheKey, toDisk: true) { _ in
Task { @MainActor in
cache.clearMemoryCache()

let completionGroup = DispatchGroup()

completionGroup.enter()
self.imageView.kf.setImage(
with: url,
options: [.processor(p1), .cacheSerializer(coordinator)]
) { result in
XCTAssertNotNil(result.error)
XCTAssertTrue(result.error!.isNotCurrentTask)
completionGroup.leave()
}

DispatchQueue.global().async {
coordinator.waitUntilFirstCallEntered()

DispatchQueue.main.async {
MainActor.assumeIsolated {
completionGroup.enter()
self.imageView.kf.setImage(
with: url,
options: [.processor(p2), .cacheSerializer(coordinator)]
) { result in
XCTAssertNotNil(result.value)
XCTAssertEqual(result.value!.image.size, size2)
completionGroup.leave()
}

coordinator.allowFirstCallToProceed()
}
}
}

completionGroup.notify(queue: .main) {
exp.fulfill()
}
}
}

group.notify(queue: .main) { exp.fulfill() }

waitForExpectations(timeout: 5, handler: nil)
}

Expand Down
Loading
Loading