This does seem to be Apple trying to push for more of a View-Model architecture when using this though. @Observable can be used to create a ViewModel, but it seems like you don't quite get the nice bindings via Combine with @Observable as you do ObservedObject, so you'll need to consider how to handle the Cancellables coming from assign(). Just something to be aware of.
I think they have been pushing in that direction for a while, their code samples have been using View-Model for a while. Personally, I think some people were abusing the MVVM pattern in places where it was not beneficial to decouple a view from its model.
That said, SwiftData should simplify things enough that half of the things I use a ViewModel for no longer apply. The main issue is more that I have a handful of micro-services that get used to handle caching of images/media, sync, and a handful of other tasks. So if you want to compose those things, it's either putting code in your view, or building a ViewModel to do composition. It really looks like Apple wants you to do the former using something like environment objects for injection. Which can work, but can lead to needing to think carefully about composition.
The way I've handled the image caching in the apps I've worked with (the specific use case was loading and caching images from URLs) was by creating a
CachedImageView
as a wrapper around SwiftUI's
Image
(mimicking the
AsyncImage
API, basically) and then creating a shared
ImageCacher
actor that handled the caching itself, with no ViewModel in the middle.
I did have to put a couple lines of code on the View itself (
.task { self.loadedImage = try ImageCacher.loadImage(from: imageURL) }
+ some error handling), but the important stuff happens on the (shared)
ImageCacher
actor itself. And you naturally get some nice things from setting things up this way: the
.task { ... }
automatically cancels if the view is removed from the UI.
The
ImageCacher
is an actor to ensure that, in the case that several views request the same image (the same URL) at the same time, only one URL request is made and then the result is propagated to all callers. I borrowed the idea from a WWDC '22 talk [
Protect mutable state with Swift actors], and then put some caching logic on top of it. I didn't even need an
environmentObject
to pass the reference to the
ImageCacher
instance, since it was reasonable to use a singleton there as we only wanted a single image caching system in the app, to avoid cache duplication. For cases where it makes sense to have separate systems (for example: data that is window-dependent in a multi-window app), yes, it looks like Apple favors
environmentObject
. I don't like those very much, as the compiler doesn't enforce their presence and it's IMHO to easy to forget one of them and cause a crash.
So basically I follow the architecture proposed in
Swift concurrency: Update a sample app + a few ViewModels here and there for Views that manage entire screens (or several different UI elements), which usually do have view-specific logic that is also non-trivial. For self-contained UI components, I rarely add a ViewModel.