Skip to content

Instantly share code, notes, and snippets.

@cameronmcefee
Last active October 31, 2024 11:28
Show Gist options
  • Save cameronmcefee/fc78db3e62276dd112780cae3f566e20 to your computer and use it in GitHub Desktop.
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=="
)!
}
@mattmassicotte
Copy link

This approach slightly messed up your nonisolated inits, but I think we can safely get rid of that? It seems ok on my end...

@cameronmcefee
Copy link
Author

cameronmcefee commented Oct 30, 2024

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?

@mattmassicotte
Copy link

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.

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?

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.

@cameronmcefee
Copy link
Author

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.

@mattmassicotte
Copy link

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment