-
-
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==" | |
)! | |
} |
This approach slightly messed up your nonisolated inits, but I think we can safely get rid of that? It seems ok on my end...
Nonisolated inits
The nonisolated inits were necessary to make it possible to use this inside the AppController which is an environment variable. If I go with your suggested changes, I end up getting warnings again:
extension EnvironmentValues {
@MainActor
@Entry var appController: AppController = .init()
// Call to main actor-isolated initializer 'init()' in a synchronous nonisolated context
// private struct __Key_appController: SwiftUICore.EnvironmentKey {
// typealias Value = AppController
// static var defaultValue: Value { .init() }
// }
}
I guess a broader question (that may or many not be a question for you, since it's off topic) is whether having this kind of master controller object in the environment is a good way to go. I'm still a bit fledgling on the topic of architecture, so this has been my solution to "how can I make specific data and its changes available everywhere". It's the top of my view model pyramid so it's been helpful to have it in the environment.
However, it's posing this challenge, and I suspect there might be potential downsides with too many view updates that I just haven't come across yet based on this approach. While I'm hesitant to throw it out just to make the compiler happy, if it's ultimately a future burden I'm creating for myself, I'm open to adopting a different approach that will coincidentally work with the topic we're covering here.
CGImage
I appreciate your input on this. I've done a decent amount of reading trying to figure out whether CGImage or SDK specific images was wiser, but everything I saw tended to come back to the SDK specific images.
Your comment
// you must remember that the world can change across awaits. So, you really need to control/update your state
// synchronously.// synchronously check our cache
if let image = cache.get(id: id) {
return image
}
I'm trying to understand a concrete example, to see if I'm getting it. Let's say the async cache get will return nothing, but during that waiting period, something does get added. We receive an answer as though the item doesn't exist, but it does. Then the function will try to add the item, but it's already there, so we're duplicating work (or in other scenarios might do more negative things like overwrite data, etc). Is that right?
CGImage
I'd look carefully at this. UIImage
is probably 99.9% of what you find, but NSImage
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.
Plus, I see only upsides to using CGImage
here. But maybe I'm missing something?
Then the function will try to add the item, but it's already there, so we're duplicating work (or in other scenarios might do more negative things like overwrite data, etc). Is that right?
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:
// the non-local state here...
await doStuff()
// ... may not be the same as after an await
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.
nonisolated inits
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:
@MainActor
final class ImageManager {
// initializing inline is a great way to avoid the problems with doing this within the nonisolated context
private let cache = CGImageCache()
nonisolated init(database: DatabaseActor) {
self.database = database
}
}
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.
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.
Ok initial pass, the core object. Here's some small changes I've make with comments along the way.
I opted to use static functions to really enforce that some of these methods should not touch instance state. This is stylistic and totally up to you.
I also switched to using CGImage, which is inherently cross-platform,
Sendable
, and a lot less problematic than NSImage which isn't always safe to use across threads. Something to think about.