From a07f3c2ba4a2bb97f47838218e267caca25eddf1 Mon Sep 17 00:00:00 2001 From: mertalev <101130780+mertalev@users.noreply.github.com> Date: Thu, 11 Sep 2025 15:15:20 -0400 Subject: [PATCH] make resolver configurable --- .../Resolvers/Assets/AssetResolver.swift | 90 ++++++++++--------- .../Resolvers/Images/ThumbnailResolver.swift | 61 +++++++------ 2 files changed, 80 insertions(+), 71 deletions(-) diff --git a/mobile/ios/Runner/Resolvers/Assets/AssetResolver.swift b/mobile/ios/Runner/Resolvers/Assets/AssetResolver.swift index 3f53f1db8c..119fb6f7ba 100644 --- a/mobile/ios/Runner/Resolvers/Assets/AssetResolver.swift +++ b/mobile/ios/Runner/Resolvers/Assets/AssetResolver.swift @@ -3,7 +3,7 @@ import Photos class AssetRequest: Request { let assetId: String var completion: (PHAsset?) -> Void - + init(cancellationToken: CancellationToken, assetId: String, completion: @escaping (PHAsset?) -> Void) { self.assetId = assetId self.completion = completion @@ -12,61 +12,65 @@ class AssetRequest: Request { } class AssetResolver { - private static let requestQueue = DispatchQueue(label: "assets.requests", qos: .userInitiated) - private static let processingQueue = DispatchQueue(label: "assets.processing", qos: .userInitiated) - - private static var batchTimer: DispatchWorkItem? - private static let batchLock = NSLock() - private static let batchTimeout: TimeInterval = 0.00025 // 250μs - - private static let fetchOptions = { - let fetchOptions = PHFetchOptions() - fetchOptions.wantsIncrementalChangeDetails = false - return fetchOptions - }() - private static var assetRequests = [AssetRequest]() - private static let assetCache = { - let assetCache = NSCache() - assetCache.countLimit = 10000 - return assetCache - }() - - static func requestAsset(request: AssetRequest) { + private let requestQueue: DispatchQueue + private let processingQueue: DispatchQueue + + private var batchTimer: DispatchWorkItem? + private let batchLock = NSLock() + private let batchTimeout: TimeInterval + + private let fetchOptions: PHFetchOptions + private var assetRequests = [AssetRequest]() + private let assetCache: NSCache + + init(fetchOptions: PHFetchOptions, batchTimeout: TimeInterval = 0.00025, cacheSize: Int = 10000, qos: DispatchQoS = .unspecified) { + self.fetchOptions = fetchOptions + self.batchTimeout = batchTimeout + self.assetCache = { + let assetCache = NSCache() + assetCache.countLimit = cacheSize + return assetCache + }() + self.requestQueue = DispatchQueue(label: "assets.requests", qos: qos) + self.processingQueue = DispatchQueue(label: "assets.processing", qos: qos) + } + + func requestAsset(request: AssetRequest) { requestQueue.async { if (request.isCancelled) { request.completion(nil) return } - - if let cachedAsset = assetCache.object(forKey: request.assetId as NSString) { + + if let cachedAsset = self.assetCache.object(forKey: request.assetId as NSString) { request.completion(cachedAsset) return } - - batchLock.lock() + + self.batchLock.lock() if (request.isCancelled) { - batchLock.unlock() + self.batchLock.unlock() request.completion(nil) return } - - assetRequests.append(request) - - batchTimer?.cancel() - let timer = DispatchWorkItem(block: processBatch) - batchTimer = timer - batchLock.unlock() - processingQueue.asyncAfter(deadline: .now() + batchTimeout, execute: timer) + + self.assetRequests.append(request) + + self.batchTimer?.cancel() + let timer = DispatchWorkItem(block: self.processBatch) + self.batchTimer = timer + self.batchLock.unlock() + self.processingQueue.asyncAfter(deadline: .now() + self.batchTimeout, execute: timer) } } - - private static func processBatch() { + + private func processBatch() { batchLock.lock() if assetRequests.isEmpty { batchLock.unlock() return } - + var completionMap = [String: [(PHAsset?) -> Void]]() var activeAssetIds = [String]() completionMap.reserveCapacity(assetRequests.count) @@ -76,7 +80,7 @@ class AssetResolver { request.completion(nil) continue } - + if var completions = completionMap[request.assetId] { completions.append(request.completion) } else { @@ -87,18 +91,18 @@ class AssetResolver { assetRequests.removeAll(keepingCapacity: true) batchTimer = nil batchLock.unlock() - + guard !activeAssetIds.isEmpty else { return } - - let assets = PHAsset.fetchAssets(withLocalIdentifiers: activeAssetIds, options: Self.fetchOptions) + + let assets = PHAsset.fetchAssets(withLocalIdentifiers: activeAssetIds, options: self.fetchOptions) assets.enumerateObjects { asset, _, _ in let assetId = asset.localIdentifier for completion in completionMap.removeValue(forKey: assetId)! { completion(asset) } - requestQueue.async { assetCache.setObject(asset, forKey: assetId as NSString) } + self.requestQueue.async { self.assetCache.setObject(asset, forKey: assetId as NSString) } } - + for completions in completionMap.values { for completion in completions { completion(nil) diff --git a/mobile/ios/Runner/Resolvers/Images/ThumbnailResolver.swift b/mobile/ios/Runner/Resolvers/Images/ThumbnailResolver.swift index 39c54819bd..b50e4d0f37 100644 --- a/mobile/ios/Runner/Resolvers/Images/ThumbnailResolver.swift +++ b/mobile/ios/Runner/Resolvers/Images/ThumbnailResolver.swift @@ -6,7 +6,7 @@ import Photos class ThumbnailRequest: Request { weak var workItem: DispatchWorkItem? let completion: (Result<[String: Int64], any Error>) -> Void - + init(cancellationToken: CancellationToken, completion: @escaping (Result<[String: Int64], any Error>) -> Void) { self.completion = completion super.init(cancellationToken: cancellationToken) @@ -15,6 +15,11 @@ class ThumbnailRequest: Request { class ThumbnailResolver: ThumbnailApi { private static let imageManager = PHImageManager.default() + private static let assetResolver = AssetResolver(fetchOptions: { + let fetchOptions = PHFetchOptions() + fetchOptions.wantsIncrementalChangeDetails = false + return fetchOptions + }(), qos: .userInitiated) private static let requestOptions = { let requestOptions = PHImageRequestOptions() requestOptions.isNetworkAccessAllowed = true @@ -24,18 +29,18 @@ class ThumbnailResolver: ThumbnailApi { requestOptions.version = .current return requestOptions }() - + private static let requestQueue = DispatchQueue(label: "thumbnail.requests", qos: .userInitiated) private static let cancelQueue = DispatchQueue(label: "thumbnail.cancellation", qos: .default) private static let processingQueue = DispatchQueue(label: "thumbnail.processing", qos: .userInteractive, attributes: .concurrent) - + private static let rgbColorSpace = CGColorSpaceCreateDeviceRGB() private static let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue).rawValue private static var requests = [Int64: ThumbnailRequest]() private static let cancelledResult = Result<[String: Int64], any Error>.success([:]) private static let thumbnailConcurrencySemaphore = DispatchSemaphore(value: ProcessInfo.processInfo.activeProcessorCount / 2 + 1) private static let activitySemaphore = DispatchSemaphore(value: 1) - + private static let willResignActiveObserver = NotificationCenter.default.addObserver( forName: UIApplication.willResignActiveNotification, object: nil, @@ -52,31 +57,31 @@ class ThumbnailResolver: ThumbnailApi { processingQueue.resume() activitySemaphore.signal() } - + func getThumbhash(thumbhash: String, completion: @escaping (Result<[String : Int64], any Error>) -> Void) { Self.processingQueue.async { guard let data = Data(base64Encoded: thumbhash) else { return completion(.failure(PigeonError(code: "", message: "Invalid base64 string: \(thumbhash)", details: nil)))} - + let (width, height, pointer) = thumbHashToRGBA(hash: data) self.waitForActiveState() completion(.success(["pointer": Int64(Int(bitPattern: pointer.baseAddress)), "width": Int64(width), "height": Int64(height)])) } } - + func requestImage(assetId: String, requestId: Int64, width: Int64, height: Int64, isVideo: Bool, completion: @escaping (Result<[String: Int64], any Error>) -> Void) { let cancellationToken = CancellationToken() let thumbnailRequest = ThumbnailRequest(cancellationToken: cancellationToken, completion: completion) - AssetResolver.requestAsset(request: AssetRequest(cancellationToken: cancellationToken, assetId: assetId) { asset in + Self.assetResolver.requestAsset(request: AssetRequest(cancellationToken: cancellationToken, assetId: assetId) { asset in if cancellationToken.isCancelled { return completion(Self.cancelledResult) } - + let item = DispatchWorkItem { if cancellationToken.isCancelled { return completion(Self.cancelledResult) } - + guard let asset = asset else { if cancellationToken.isCancelled { return completion(Self.cancelledResult) @@ -85,14 +90,14 @@ class ThumbnailResolver: ThumbnailApi { completion(.failure(PigeonError(code: "", message: "Could not get asset data for \(assetId)", details: nil))) return } - + Self.thumbnailConcurrencySemaphore.wait() defer { Self.thumbnailConcurrencySemaphore.signal() } - + if cancellationToken.isCancelled { return completion(Self.cancelledResult) } - + var image: UIImage? Self.imageManager.requestImage( for: asset, @@ -103,27 +108,27 @@ class ThumbnailResolver: ThumbnailApi { image = _image } ) - + if cancellationToken.isCancelled { return completion(Self.cancelledResult) } - + guard let image = image, let cgImage = image.cgImage else { Self.removeRequest(requestId: requestId) return completion(.failure(PigeonError(code: "", message: "Could not get pixel data for \(assetId)", details: nil))) } - + let pointer = UnsafeMutableRawPointer.allocate( byteCount: Int(cgImage.width) * Int(cgImage.height) * 4, alignment: MemoryLayout.alignment ) - + if cancellationToken.isCancelled { pointer.deallocate() return completion(Self.cancelledResult) } - + guard let context = CGContext( data: pointer, width: cgImage.width, @@ -137,20 +142,20 @@ class ThumbnailResolver: ThumbnailApi { Self.removeRequest(requestId: requestId) return completion(.failure(PigeonError(code: "", message: "Could not create context for \(assetId)", details: nil))) } - + if cancellationToken.isCancelled { pointer.deallocate() return completion(Self.cancelledResult) } - + context.interpolationQuality = .none context.draw(cgImage, in: CGRect(x: 0, y: 0, width: cgImage.width, height: cgImage.height)) - + if cancellationToken.isCancelled { pointer.deallocate() return completion(Self.cancelledResult) } - + self.waitForActiveState() completion(.success(["pointer": Int64(Int(bitPattern: pointer)), "width": Int64(cgImage.width), "height": Int64(cgImage.height)])) Self.removeRequest(requestId: requestId) @@ -158,22 +163,22 @@ class ThumbnailResolver: ThumbnailApi { thumbnailRequest.workItem = item Self.processingQueue.async(execute: item) }) - + Self.addRequest(requestId: requestId, request: thumbnailRequest) } - + func cancelImageRequest(requestId: Int64) { Self.cancelRequest(requestId: requestId) } - + private static func addRequest(requestId: Int64, request: ThumbnailRequest) -> Void { requestQueue.sync { requests[requestId] = request } } - + private static func removeRequest(requestId: Int64) -> Void { requestQueue.sync { requests[requestId] = nil } } - + private static func cancelRequest(requestId: Int64) -> Void { requestQueue.async { guard let request = requests.removeValue(forKey: requestId) else { return } @@ -185,7 +190,7 @@ class ThumbnailResolver: ThumbnailApi { } } } - + func waitForActiveState() { Self.activitySemaphore.wait() Self.activitySemaphore.signal()