-
-
Save cameronmcefee/fc78db3e62276dd112780cae3f566e20 to your computer and use it in GitHub Desktop.
import SwiftUI | |
#if canImport(UIKit) | |
import UIKit | |
public typealias PlatformImage = UIImage | |
#elseif canImport(AppKit) | |
import AppKit | |
public typealias PlatformImage = NSImage | |
#endif | |
// -------------- // | |
// Relevant Class // | |
// -------------- // | |
// The cache is just a simple cache, a little sugar on top of NSCache. | |
actor PlatformImageCache { | |
private var cache: NSCache<NSString, PlatformImage> = .init() | |
init() {} | |
func key(_ id: UUID) -> NSString { | |
NSString(string: id.uuidString) | |
} | |
public func get(id: UUID) -> PlatformImage? { | |
cache.object(forKey: key(id)) | |
} | |
public func set(_ image: PlatformImage, as id: UUID) { | |
cache.setObject(image, forKey: key(id)) | |
} | |
} | |
// The ImageManager will handle actually acquiring the data needed. The | |
// .image(for: UUID) method is the external api that the image views will call to get their needed images. | |
@MainActor | |
@Observable | |
final class ImageManager { | |
let cache: PlatformImageCache | |
private let database: DatabaseActor | |
private var activeTasks: [UUID: Task<Void, Never>] = [:] | |
nonisolated init(cache: PlatformImageCache, database: DatabaseActor) { | |
self.cache = cache | |
self.database = database | |
} | |
func image(for id: UUID) async -> PlatformImage? { | |
if let image = await cache.get(id: id) { | |
return image | |
} | |
if let activeTask = activeTasks[id] { | |
_ = await activeTask.value | |
return await cache.get(id: id) | |
} | |
let task = Task { | |
guard let data = await database.getData(for: id) else { return } | |
guard let image = await decodeDataIntoImage(data: data) else { return } | |
await cache.set(image, as: id) | |
activeTasks.removeValue(forKey: id) | |
} | |
activeTasks[id] = task | |
_ = await task.value | |
return await cache.get(id: id) | |
} | |
private nonisolated func decodeDataIntoImage( | |
data: Data | |
) async -> sending PlatformImage? { | |
try? await Task.sleep(for: .seconds(1)) | |
return PlatformImage(data: data) | |
} | |
} | |
// This would be a @ModelActor I can fetch the actual data from | |
actor DatabaseActor { | |
func getData(for _: UUID) -> Data? { | |
Data.example | |
} | |
} | |
// -------------- // | |
// Usage Context // | |
// -------------- // | |
struct ContentView: View { | |
@State private var demoUUID = UUID() | |
var body: some View { | |
VStack { | |
ForEach(0 ..< 10, id: \.self) { index in | |
ImageView(id: demoUUID, index: index) | |
} | |
} | |
} | |
} | |
struct ImageView: View { | |
@Environment(\.appController) private var app | |
@State private var platformImage: PlatformImage? | |
let id: UUID | |
let index: Int | |
var body: some View { | |
ZStack { | |
Text(String(index)) | |
if let platformImage { | |
Image(platformImage: platformImage) | |
} else { | |
ProgressView() | |
} | |
} | |
.task { | |
platformImage = await app.imageManager.image(for: id) | |
} | |
} | |
} | |
@MainActor | |
@Observable | |
final class AppController { | |
let imageManager: ImageManager | |
nonisolated init() { | |
let database = DatabaseActor() | |
let cache = PlatformImageCache() | |
imageManager = ImageManager(cache: cache, database: database) | |
} | |
} | |
extension EnvironmentValues { | |
@MainActor | |
@Entry var appController: AppController = .init() | |
} | |
extension SwiftUI.Image { | |
init(platformImage: PlatformImage) { | |
#if os(macOS) | |
self.init(nsImage: platformImage) | |
#else | |
self.init(uiImage: platformImage) | |
#endif | |
} | |
} | |
#Preview { | |
@Previewable @State var appController = AppController() | |
ContentView() | |
.environment(\.appController, appController) | |
} | |
extension Data { | |
static let example = | |
Data( | |
base64Encoded: "iVBORw0KGgoAAAANSUhEUgAAAD4AAAA5CAYAAABuxJj8AAAAAXNSR0IArs4c6QAAAIRlWElmTU0AKgAAAAgABQESAAMAAAABAAEAAAEaAAUAAAABAAAASgEbAAUAAAABAAAAUgEoAAMAAAABAAIAAIdpAAQAAAABAAAAWgAAAAAAAACQAAAAAQAAAJAAAAABAAOgAQADAAAAAQABAACgAgAEAAAAAQAAAD6gAwAEAAAAAQAAADkAAAAAhTsXfgAAAAlwSFlzAAAWJQAAFiUBSVIk8AAAABxpRE9UAAAAAgAAAAAAAAAdAAAAKAAAAB0AAAAcAAABnsjR3d0AAAFqSURBVGgFYmAYBaMhMBoCNA4BVhqbP+iM1wO66BYQ/wHiJUDMAsTDHjACfXgNiP8j4bJh72ugB92RPAzz/GOgGChAhjWYC/QdzMPItPVw9jUz0HOvcXi8dzh73BGHp0Exf284e3wyHo+DPG84HD0PKrxAhRhyvkZnNw9Hj5sT8DQoEK4OR493EuFxkOfVh5vnbxPp8crh5HFdIj0NivHTw8nj9SR4HOR5ueHi+Yskejx/OHhcmURPg2L84HDweCkZHv8L1CM21D1/nAyPg2I9dSA8zgS0tACITwAxqLX1ggL8D6gX5BFS8VcK7AS59w4QbwRiCyAmGhQCVZLq0MGqHhSAksT6fNUw8jgoQtyI9XjSMPL4G6BfBIn1OEhdPxAP1uRLrLveAf1gB/IMqSAKqOEjEBNr0WBSB6pNlEj1MLJ6RSDnGBAPJk/hcwuoDdACxFQZsgYZAhokABmKz9KBlnsEdJ89EBMFAAAAAP//699psgAAAexJREFU7ZhPKwVRGMavP6GUWNgpK1dKKZIsFMJSysbCjg9g4RMoISQlJBs2FizvJ7BgxcLKRkRdIlmgRP783sXU3GmMc+bec+ao+9bTnZn7nud9nnfOzJmZVEo9eki9Bt8O4gBNdcBY1MK8D1wx/4qWSWNuQ4gnOPYCkmzAKfWbQ7QZP5Smwgmwbf6LmkugAiQWUnwRiBgbDbilzhBwJgZRkgUmzWfgr3fGsU/ImmHj7b5azmyWokSmockzPueMW5+QXsOmpaEXvnrObK5bMC7mO5xxjJAycAdMTnOPe8El4/2WTIv5S5eMb1o0LuY7XTAv0/zesnF5YEo8BlDgXX86v2+MO4459opxiccWCnQMS+45aAMlYBq8A12OLsYkFuVUfgA6onfIrw4olmtW1mgdnuUAh9VdeVlQFftM7niEuhr+29Pgkw8iMmMSiW2qqhiX9+YmRYXyni8fF1R4uxU5C5om0/xRQeAqOZWalVvIP1PgXtHkLUj6X9NcmjKcR6Uqxm6AqDN/kwd/7KHzEaIO+a8hNnPuwFF2n8BvDUjnppvfC1vGPik7A+ShppDRCNkRCDNvfVkbCQiRu2wfMBVyT5kFH8BrQJZtuSSsxxgVd8EUCK7NpsS0Qiw3NXkNVl0pTGkp8hY78B868AMXvm1f7P57MAAAAABJRU5ErkJggg==" | |
)! | |
} |
Ah right, yeah I made a mistake. Sorry about that. Sigh... this is going to be a pain to address if you still need to be able to inject the cache in the init. If not, you can get away with this:
I pass them in via the initializer for the sake of dependency injection. I've actually got a protocol that my image manager expects and my actual image cache, which conforms comes from another module.
With that said, I don't have to do it this way. I was trying to keep things separate, but I'm not sure it's worth the trouble in this case.
Ok, well in the process of working through this with you, and some additional exploration on other adjacent topics, I've started to question whether some of my core approaches are even providing me benefits for all the complexities they add. I think now is a good time for me to consider throwing the baby out with the bathwater on some things. I want to spend some time on that, and then come back to this to see if these concerns are even relevant in the new world.
However, the cache itself, the use of sending
, and the exploration of reentrancy has been invaluable and will certainly live on in my future code in some form. I really appreciate your help on this.
Well, I'm very happy to help!
I think what you are saying here is very wise. Decomposing components into little parts that are assembled together is a super powerful pattern. But, tht flexibiility does definitely, sometimes, come with trade-offs. I think this particular trade-off is silly, because you're fighting against an isolation mismatch that I don't think makes sense. But, we cannot change the framework.
I'd look carefully at this.
UIImage
is probably 99.9% of what you find, butNSImage
has unique, poorly-documented thread-safefty issues. I have been warned by people I trust that you should not use it across threads unless you have no choice.https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/Multithreading/ThreadSafetySummary/ThreadSafetySummary.html#//apple_ref/doc/uid/10000057i-CH12-126728
Plus, I see only upsides to using
CGImage
here. But maybe I'm missing something?Yes you got it. In this specific example, we are wasting work. But, in the general case, things could be much worse. The core idea is this:
So you want to do all of your non-local (ie instance variable) mutations synchronously. And if you cannot do that, you have to look into other techniques.
Ah right, yeah I made a mistake. Sorry about that. Sigh... this is going to be a pain to address if you still need to be able to inject the cache in the init. If not, you can get away with this:
Architecturally, I think this is totally fine, though I would not consider myself a SwiftUI expert. I just find the environment + MainActor types a complete pain to work with.