make resolver configurable

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