refactor(mobile): introduce image request registry on iOS (#27486)

* refactor: replace DispatchQueue + DispatchSemaphore with OperationQueue for image processing

* implement RequestRegistry and UnfairLock for managing cancellable requests

* implement requests registry for local and remote image processing

* remove Cancellable protocol and cancel method from request registry

* use mutex

---------

Co-authored-by: mertalev <101130780+mertalev@users.noreply.github.com>
This commit is contained in:
Luis Nachtigall
2026-04-06 00:55:43 +02:00
committed by GitHub
parent f29c06799f
commit cd86a83c33
5 changed files with 125 additions and 61 deletions

View File

@@ -3,7 +3,7 @@
archiveVersion = 1;
classes = {
};
objectVersion = 54;
objectVersion = 77;
objects = {
/* Begin PBXBuildFile section */
@@ -16,6 +16,7 @@
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
A01DD69B2F7F43B40049AB63 /* ImageRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = A01DD6982F7F43B40049AB63 /* ImageRequest.swift */; };
B21E34AA2E5AFD2B0031FDB9 /* BackgroundWorkerApiImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = B21E34A92E5AFD210031FDB9 /* BackgroundWorkerApiImpl.swift */; };
B21E34AC2E5B09190031FDB9 /* BackgroundWorker.swift in Sources */ = {isa = PBXBuildFile; fileRef = B21E34AB2E5B09100031FDB9 /* BackgroundWorker.swift */; };
B25D377A2E72CA15008B6CA7 /* Connectivity.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = B25D37782E72CA15008B6CA7 /* Connectivity.g.swift */; };
@@ -102,6 +103,7 @@
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
A01DD6982F7F43B40049AB63 /* ImageRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageRequest.swift; sourceTree = "<group>"; };
B1FBA9EE014DE20271B0FE77 /* Pods-ShareExtension.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-ShareExtension.profile.xcconfig"; path = "Target Support Files/Pods-ShareExtension/Pods-ShareExtension.profile.xcconfig"; sourceTree = "<group>"; };
B21E34A92E5AFD210031FDB9 /* BackgroundWorkerApiImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundWorkerApiImpl.swift; sourceTree = "<group>"; };
B21E34AB2E5B09100031FDB9 /* BackgroundWorker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackgroundWorker.swift; sourceTree = "<group>"; };
@@ -137,20 +139,23 @@
);
target = F0B57D372DF764BD00DC5BCC /* WidgetExtension */;
};
FE1BB4572F83196E0087DBF9 /* Exceptions for "Utility" folder in "Runner" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Mutex.swift,
);
target = 97C146ED1CF9000F007C117D /* Runner */;
};
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
B231F52D2E93A44A00BC45D1 /* Core */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
path = Core;
sourceTree = "<group>";
};
B2CF7F8C2DDE4EBB00744BF6 /* Sync */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
);
path = Sync;
sourceTree = "<group>";
};
@@ -162,10 +167,16 @@
path = WidgetExtension;
sourceTree = "<group>";
};
FEE084F22EC172080045228E /* Schemas */ = {
FE1BB4562F8319560087DBF9 /* Utility */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
FE1BB4572F83196E0087DBF9 /* Exceptions for "Utility" folder in "Runner" target */,
);
path = Utility;
sourceTree = "<group>";
};
FEE084F22EC172080045228E /* Schemas */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = Schemas;
sourceTree = "<group>";
};
@@ -273,6 +284,7 @@
97C146F01CF9000F007C117D /* Runner */ = {
isa = PBXGroup;
children = (
FE1BB4562F8319560087DBF9 /* Utility */,
FEE084F22EC172080045228E /* Schemas */,
B231F52D2E93A44A00BC45D1 /* Core */,
B25D37792E72CA15008B6CA7 /* Connectivity */,
@@ -327,6 +339,7 @@
FED3B1952E253E9B0030FD97 /* Images */ = {
isa = PBXGroup;
children = (
A01DD6982F7F43B40049AB63 /* ImageRequest.swift */,
FE5FE4AD2F30FBC000A71243 /* ImageProcessing.swift */,
FE5499F72F1198DE006016CB /* RemoteImagesImpl.swift */,
FE5499F52F11980E006016CB /* LocalImagesImpl.swift */,
@@ -558,10 +571,14 @@
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
);
inputPaths = (
);
name = "[CP] Copy Pods Resources";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
@@ -590,10 +607,14 @@
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
);
inputPaths = (
);
name = "[CP] Embed Pods Frameworks";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
@@ -608,6 +629,7 @@
files = (
65F32F31299BD2F800CE9261 /* BackgroundServicePlugin.swift in Sources */,
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
A01DD69B2F7F43B40049AB63 /* ImageRequest.swift in Sources */,
B21E34AC2E5B09190031FDB9 /* BackgroundWorker.swift in Sources */,
FE5499F32F1197D8006016CB /* LocalImages.g.swift in Sources */,
FE5499F62F11980E006016CB /* LocalImagesImpl.swift in Sources */,

View File

@@ -0,0 +1,14 @@
import Foundation
struct RequestRegistry<T: AnyObject & Sendable>: ~Copyable, Sendable {
private let requests = Mutex<[Int64: T]>([:])
func add(requestId: Int64, request: T) {
requests.withLock { $0[requestId] = request }
}
@discardableResult
func remove(requestId: Int64) -> T? {
requests.withLock { $0.removeValue(forKey: requestId) }
}
}

View File

@@ -11,6 +11,11 @@ class LocalImageRequest {
init(callback: @escaping (Result<[String: Int64]?, any Error>) -> Void) {
self.callback = callback
}
func cancel() {
isCancelled = true
operation?.cancel()
}
}
class LocalImageApiImpl: LocalImageApi {
@@ -31,9 +36,7 @@ class LocalImageApiImpl: LocalImageApi {
return requestOptions
}()
private static let assetQueue = DispatchQueue(label: "thumbnail.assets", qos: .userInitiated)
private static let requestQueue = DispatchQueue(label: "thumbnail.requests", qos: .userInitiated)
private static let cancelQueue = DispatchQueue(label: "thumbnail.cancellation", qos: .default)
private static let registry = RequestRegistry<LocalImageRequest>()
private static var rgbaFormat = vImage_CGImageFormat(
bitsPerComponent: 8,
@@ -42,7 +45,6 @@ class LocalImageApiImpl: LocalImageApi {
bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue),
renderingIntent: .defaultIntent
)!
private static var requests = [Int64: LocalImageRequest]()
private static let assetCache = {
let assetCache = NSCache<NSString, PHAsset>()
assetCache.countLimit = 10000
@@ -73,7 +75,7 @@ class LocalImageApiImpl: LocalImageApi {
guard let asset = Self.requestAsset(assetId: assetId)
else {
Self.remove(requestId: requestId)
Self.registry.remove(requestId: requestId)
completion(.failure(PigeonError(code: "", message: "Could not get asset data for \(assetId)", details: nil)))
return
}
@@ -98,12 +100,11 @@ class LocalImageApiImpl: LocalImageApi {
)
if request.isCancelled {
Self.remove(requestId: requestId)
return completion(ImageProcessing.cancelledResult)
}
guard let data = imageData else {
Self.remove(requestId: requestId)
Self.registry.remove(requestId: requestId)
return completion(.failure(PigeonError(code: "", message: "Could not get image data for \(assetId)", details: nil)))
}
@@ -113,7 +114,6 @@ class LocalImageApiImpl: LocalImageApi {
if request.isCancelled {
free(pointer)
Self.remove(requestId: requestId)
return completion(ImageProcessing.cancelledResult)
}
@@ -121,7 +121,7 @@ class LocalImageApiImpl: LocalImageApi {
"pointer": Int64(Int(bitPattern: pointer)),
"length": Int64(length),
]))
Self.remove(requestId: requestId)
Self.registry.remove(requestId: requestId)
return
}
@@ -142,7 +142,7 @@ class LocalImageApiImpl: LocalImageApi {
guard let image = image,
let cgImage = image.cgImage else {
Self.remove(requestId: requestId)
Self.registry.remove(requestId: requestId)
return completion(.failure(PigeonError(code: "", message: "Could not get pixel data for \(assetId)", details: nil)))
}
@@ -162,51 +162,32 @@ class LocalImageApiImpl: LocalImageApi {
"pointer": Int64(Int(bitPattern: buffer.data)),
"width": Int64(buffer.width),
"height": Int64(buffer.height),
"rowBytes": Int64(buffer.rowBytes)
"rowBytes": Int64(buffer.rowBytes),
]))
Self.remove(requestId: requestId)
Self.registry.remove(requestId: requestId)
} catch {
Self.remove(requestId: requestId)
Self.registry.remove(requestId: requestId)
return completion(.failure(PigeonError(code: "", message: "Failed to convert image for \(assetId): \(error)", details: nil)))
}
}
request.operation = operation
Self.add(requestId: requestId, request: request)
Self.registry.add(requestId: requestId, request: request)
ImageProcessing.queue.addOperation(operation)
}
func cancelRequest(requestId: Int64) {
Self.cancel(requestId: requestId)
}
private static func add(requestId: Int64, request: LocalImageRequest) -> Void {
requestQueue.sync { requests[requestId] = request }
}
private static func remove(requestId: Int64) -> Void {
requestQueue.sync { requests[requestId] = nil }
}
private static func cancel(requestId: Int64) -> Void {
requestQueue.async {
guard let request = requests.removeValue(forKey: requestId) else { return }
request.isCancelled = true
guard let operation = request.operation else { return }
if operation.isCancelled {
cancelQueue.async { request.callback(ImageProcessing.cancelledResult) }
}
}
Self.registry.remove(requestId: requestId)?.cancel()
}
private static func requestAsset(assetId: String) -> PHAsset? {
var asset: PHAsset?
assetQueue.sync { asset = assetCache.object(forKey: assetId as NSString) }
if asset != nil { return asset }
if let cached = assetCache.object(forKey: assetId as NSString) {
return cached
}
guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [assetId], options: Self.fetchOptions).firstObject
else { return nil }
assetQueue.async { assetCache.setObject(asset, forKey: assetId as NSString) }
assetCache.setObject(asset, forKey: assetId as NSString)
return asset
}
}

View File

@@ -14,11 +14,15 @@ class RemoteImageRequest {
self.task = task
self.completion = completion
}
func cancel() {
isCancelled = true
task?.cancel()
}
}
class RemoteImageApiImpl: NSObject, RemoteImageApi {
private static var lock = os_unfair_lock()
private static var requests = [Int64: RemoteImageRequest]()
private static let registry = RequestRegistry<RemoteImageRequest>()
private static var rgbaFormat = vImage_CGImageFormat(
bitsPerComponent: 8,
bitsPerPixel: 32,
@@ -43,20 +47,15 @@ class RemoteImageApiImpl: NSObject, RemoteImageApi {
let request = RemoteImageRequest(id: requestId, task: task, completion: completion)
os_unfair_lock_lock(&Self.lock)
Self.requests[requestId] = request
os_unfair_lock_unlock(&Self.lock)
Self.registry.add(requestId: requestId, request: request)
task.resume()
}
private static func handleCompletion(requestId: Int64, encoded: Bool, data: Data?, response: URLResponse?, error: Error?) {
os_unfair_lock_lock(&Self.lock)
guard let request = requests[requestId] else {
return os_unfair_lock_unlock(&Self.lock)
guard let request = registry.remove(requestId: requestId) else {
return
}
requests[requestId] = nil
os_unfair_lock_unlock(&Self.lock)
if let error = error {
if request.isCancelled || (error as NSError).code == NSURLErrorCancelled {
@@ -127,13 +126,7 @@ class RemoteImageApiImpl: NSObject, RemoteImageApi {
}
func cancelRequest(requestId: Int64) {
os_unfair_lock_lock(&Self.lock)
let request = Self.requests[requestId]
os_unfair_lock_unlock(&Self.lock)
guard let request = request else { return }
request.isCancelled = true
request.task?.cancel()
Self.registry.remove(requestId: requestId)?.cancel()
}
func clearCache(completion: @escaping (Result<Int64, any Error>) -> Void) {

View File

@@ -0,0 +1,54 @@
import Darwin
// Can be replaced with std Mutex when the deployment target is iOS 18+
struct Mutex<Value: ~Copyable>: ~Copyable, @unchecked Sendable {
struct _Buffer: ~Copyable {
var lock: os_unfair_lock = .init()
var value: Value
init(value: consuming Value) {
self.value = value
}
deinit {}
}
let _buffer: UnsafeMutablePointer<_Buffer>
init(_ initialValue: consuming sending Value) {
_buffer = .allocate(capacity: 1)
_buffer.initialize(to: _Buffer(value: initialValue))
}
deinit {
_buffer.deinitialize(count: 1)
_buffer.deallocate()
}
@discardableResult
borrowing func withLock<Result: ~Copyable, E: Error>(
_ body: (inout sending Value) throws(E) -> sending Result
) throws(E) -> sending Result {
os_unfair_lock_lock(&_buffer.pointee.lock)
defer { os_unfair_lock_unlock(&_buffer.pointee.lock) }
return try body(&_buffer.pointee.value)
}
}
// Can be replaced with OSAllocatedUnfairLock when the deployment target is iOS 16+
typealias UnfairLock = Mutex<Void>
extension Mutex where Value == Void {
init() {
self.init(())
}
@discardableResult
borrowing func withLock<Result: ~Copyable, E: Error>(
_ body: () throws(E) -> sending Result
) throws(E) -> sending Result {
os_unfair_lock_lock(&_buffer.pointee.lock)
defer { os_unfair_lock_unlock(&_buffer.pointee.lock) }
return try body()
}
}