diff --git a/mobile/ios/Runner/Images/ThumbnailsImpl.swift b/mobile/ios/Runner/Images/ThumbnailsImpl.swift index 12ddd193f8..e451a8189e 100644 --- a/mobile/ios/Runner/Images/ThumbnailsImpl.swift +++ b/mobile/ios/Runner/Images/ThumbnailsImpl.swift @@ -3,18 +3,22 @@ import Flutter import MobileCoreServices import Photos -struct Request { - var managerId: Int32? +class Request { weak var workItem: DispatchWorkItem? var isCancelled = false let callback: (Result<[String: Int64], any Error>) -> Void + + init(callback: @escaping (Result<[String: Int64], any Error>) -> Void) { + self.callback = callback + } } class ThumbnailApiImpl: ThumbnailApi { - private static let cacheManager = PHImageManager.default() + private static let imageManager = PHImageManager.default() private static let fetchOptions = { let fetchOptions = PHFetchOptions() fetchOptions.fetchLimit = 1 + fetchOptions.wantsIncrementalChangeDetails = false return fetchOptions }() private static let requestOptions = { @@ -26,93 +30,148 @@ class ThumbnailApiImpl: ThumbnailApi { requestOptions.version = .current 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 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: Request]() private static let cancelledResult = Result<[String: Int64], any Error>.success([:]) + private static let concurrencySemaphore = DispatchSemaphore(value: ProcessInfo.processInfo.activeProcessorCount * 2) + private static let assetCache = { + let assetCache = NSCache() + assetCache.countLimit = 10000 + return assetCache + }() func requestImage(assetId: String, requestId: Int64, width: Int64, height: Int64, completion: @escaping (Result<[String: Int64], any Error>) -> Void) { - var request = Request(callback: completion) + let request = Request(callback: completion) let item = DispatchWorkItem { - guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [assetId], options: Self.fetchOptions).firstObject + if request.isCancelled { + return completion(Self.cancelledResult) + } + + Self.concurrencySemaphore.wait() + defer { + Self.concurrencySemaphore.signal() + } + + if request.isCancelled { + return completion(Self.cancelledResult) + } + + guard let asset = Self.requestAsset(assetId: assetId) else { - Self.requests[requestId] = nil + Self.removeRequest(requestId: requestId) completion(.failure(PigeonError(code: "", message: "Could not get asset data for \(assetId)", details: nil))) return } - request.managerId = Self.cacheManager.requestImage( + if request.isCancelled { + return completion(Self.cancelledResult) + } + + var image: UIImage? + Self.imageManager.requestImage( for: asset, targetSize: CGSize(width: Double(width), height: Double(height)), contentMode: .aspectFit, options: Self.requestOptions, - resultHandler: { (image, info) -> Void in - defer { Self.requests[requestId] = nil } - guard let image = image, - let cgImage = image.cgImage else { - return completion(.failure(PigeonError(code: "", message: "Could not get pixel data for \(assetId)", details: nil))) - } - - if request.isCancelled { - return completion(Self.cancelledResult) - } - - let pointer = UnsafeMutableRawPointer.allocate( - byteCount: Int(cgImage.width) * Int(cgImage.height) * 4, - alignment: MemoryLayout.alignment - ) - - if request.isCancelled { - pointer.deallocate() - return completion(Self.cancelledResult) - } - - guard let context = CGContext( - data: pointer, - width: cgImage.width, - height: cgImage.height, - bitsPerComponent: 8, - bytesPerRow: cgImage.width * 4, - space: Self.rgbColorSpace, - bitmapInfo: Self.bitmapInfo - ) else { - pointer.deallocate() - return completion(.failure(PigeonError(code: "", message: "Could not create context for \(assetId)", details: nil))) - } - - if request.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 request.isCancelled { - pointer.deallocate() - return completion(Self.cancelledResult) - } - - completion(.success(["pointer": Int64(Int(bitPattern: pointer)), "width": Int64(cgImage.width), "height": Int64(cgImage.height)])) + resultHandler: { (_image, info) -> Void in + image = _image } ) + + if request.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 request.isCancelled { + pointer.deallocate() + return completion(Self.cancelledResult) + } + + guard let context = CGContext( + data: pointer, + width: cgImage.width, + height: cgImage.height, + bitsPerComponent: 8, + bytesPerRow: cgImage.width * 4, + space: Self.rgbColorSpace, + bitmapInfo: Self.bitmapInfo + ) else { + pointer.deallocate() + Self.removeRequest(requestId: requestId) + return completion(.failure(PigeonError(code: "", message: "Could not create context for \(assetId)", details: nil))) + } + + if request.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 request.isCancelled { + pointer.deallocate() + return completion(Self.cancelledResult) + } + + completion(.success(["pointer": Int64(Int(bitPattern: pointer)), "width": Int64(cgImage.width), "height": Int64(cgImage.height)])) + Self.removeRequest(requestId: requestId) } request.workItem = item - Self.requests[requestId] = request + Self.addRequest(requestId: requestId, request: request) Self.processingQueue.async(execute: item) } func cancelImageRequest(requestId: Int64) { - guard var request = Self.requests.removeValue(forKey: requestId) else { return } - request.isCancelled = true - guard let item = request.workItem else { return } - item.cancel() - if item.isCancelled { - request.callback(Self.cancelledResult) - } else if let managerId = request.managerId { - Self.cacheManager.cancelImageRequest(managerId) + Self.cancelRequest(requestId: requestId) + } + + private static func addRequest(requestId: Int64, request: Request) -> 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 } + request.isCancelled = true + guard let item = request.workItem else { return } + if item.isCancelled { + request.callback(Self.cancelledResult) + } } } + + private static func requestAsset(assetId: String) -> PHAsset? { + var asset: PHAsset? + assetQueue.sync { asset = assetCache.object(forKey: assetId as NSString) } + if asset != nil { return asset } + + guard let asset = PHAsset.fetchAssets(withLocalIdentifiers: [assetId], options: Self.fetchOptions).firstObject + else { return nil } + assetQueue.async { assetCache.setObject(asset, forKey: assetId as NSString) } + return asset + } }