Closed murad1981 closed 2 years ago
If you're using an @InjectedObject property wrapper on a SwiftUI View you need to set the scope of ViewModel you're injecting to shared.
register { MyViewModel() }
.scope(.shared)
This allows a given ViewModel to persist across view updates, but frees it when the last reference is released.
Checkout the documentation on scopes.
yeah this makes sense, however, in the last struct
in Resolver package, at the line: @ObservedObject private var service: Service
i guess it's possible to replace @ObservedObject
with @StateObject
to get its goodies.
As is the system works with iOS 13. Utilizing @StateObject in they way would make the minimum iOS 14.
To me they better choice is to simply not use @InjectedObject for view models and just use @StateObject. Use @Injected in the view model for any view model specific dependencies.
See: https://betterprogramming.pub/swiftui-view-models-are-not-protocols-8c415c0325b1
I've read the cool article, which I almost done the same in my codebase, i'd also prefer using @StateObject
for the view model when I reference it in the view struct, and I use @Injected
for view model's dependencies such as data loaders. What mixes things up with me is a common setup like this one:
import SwiftUI
import Resolver
struct Category {
var id: Int
var name: String
}
protocol DataLoader {
func loadData(categoryId: Int) async
}
struct ContentView: View {
var categories = [Category(id: 1, name: "cat1"),
Category(id: 2, name: "cat2")]
var body: some View {
List {
ForEach(categories, id: \.id) { category in
CategoryDetailsView(vm: ViewModel(categoryId: category.id))
}
}
}
}
struct CategoryDetailsView: View {
@ObservedObject var vm: ViewModel
var body: some View {
VStack {
// ... display data returned by viewModel's getData method
}
.task {
await vm.getData()
}
}
}
class ViewModel: ObservableObject {
@Injected private var loader: DataLoader
var categoryId: Int
init(categoryId: Int) {
self.categoryId = categoryId
}
func getData() async {
await loader.loadData(categoryId: categoryId)
}
}
when the view model depends on external dependencies, such as an id
, in this case I can't do something like:
@StateObject private var vm = ViewModel()
or even @StateObject var vm: ViewModel
because Apple recommends that @StateObject
should be instantiated and owned by the view itself in opposite to @ObservedObject
which's fine to be injected from outside the view like in the above case.
what should be the best choice for injecting the view model in the above case ?
struct ContentView: View {
...
ForEach(categories, id: \.id) { category in
CategoryDetailsView(categoryId: category.id)
}
...
}
struct CategoryDetailsView: View {
let categoryId: Int
@StateObject var vm = ViewModel()
var body: some View {
VStack {
// ... display data returned by viewModel's getData method
}
.task {
await vm.getData(categoryId)
}
}
}
class ViewModel: ObservableObject {
@Injected private var loader: DataLoader
func getData(_ categoryId: Int) async {
await loader.loadData(categoryId: categoryId)
// then do something with data
}
}
Constructing the VM in the initializer is a bad thing. Using them as ObservedObjects is also a bad thing. Especially if something can trigger your content view to recompute state (even, say, a device rotation or dark mode).
At the risk of boring you to tears ;) I might suggest another article in order to understand why.
https://medium.com/swlh/deep-inside-views-state-and-performance-in-swiftui-d23a3a44b79
I just finished reading that excellent long article. I would emphasize on the following points mentioned in the article, some of those points are new to me:
1) Views are not Views but rather: view descriptors (declarative nature).
2) SwiftUI generates (for ex.) UINavigationController for a NavigationView even before the body is called, using Type Inference.
3) view modifiers are not modifiers, but rather new wrapper Views.
4) Views should be lightweight and performant, especially in their init method and its body, I even prefer to totally avoid using the init
method, because we don't know when SwiftUI needs to regenerate the view at some point of time. By the way, we can overcome the problem of calling View's init
for every ListView
row, by using something like a LazyView
:
struct LazyView<Content: View>: View {
let build: () -> Content
init(_ build: @autoclosure @escaping () -> Content) {
self.build = build
}
var body: Content {
build()
}
}
5) Avoid ,where possible, using root dependencies because they affect the entire view graph
6) @State vars are persisted during Views
update, in contrast to @ObservedObject
which will be deinited
and generate new instances on view update cycles.
7) regarding the piece of code you put in the: Welcome @StateObject
section:
NavigationLink(
destination: DetailView(model: DetailViewModel(item: item))
) {
ItemDateView(item: item)
}
8) I reviewed the SwiftUI micro services
article quickly, it's a great method to reuse components between different views and projects, but I IMHO the app in general, can't go 100% micro-services, because there are views need to have view models to control them, and those view models can only service those particular views, like a view model for a detail view which fetches the details from a loader and formats various detail elements, this view model can't be micro-serviced and shared between other detail views or other projects.
9) bonus point: a native alternative to tracker
is: Self._printChanges()
, place it at the beginning of the body
.
you passed the StateObject
from outside the view which's not recommended (and yes you also stated this point in the drawbacks of using StateObjects
), not only because you exposed the StateObject to another view, but also because Apple engineers said (in one of the past WWDC sessions) that StateObjects
should be private to the view which owns them, otherwise, you may encounter unexpected view behavior, and this is my main concern in my code in the previous comment, which that I can't make StateObject private and owned by the detail view by writing something like:
@StateObject private var viewModel = ViewModel()
simply because this view model has a dependency (which's id
in my example or item
in your article), and that can't be set from inside view itself, and this opens a new question related to Resolver
: if I decided to use @InjectedObject
for the view model that expects a parameter (like an id
or item
in the above example), how can I register it in registerAllServices
method ? and do I need to pass it from the NavigationLink
like what you did in the piece of code above ?
Glad you liked the article. Keep in mind that it's almost two years old and that my view of "best practices" has changed somewhat since that article was written.
Note when I refactored your code I changed the view like so.
struct CategoryDetailsView: View {
let categoryId: Int
@StateObject private var vm = ViewModel()
var body: some View {
VStack {
// ... display data returned by viewModel's getData method
}
.task {
await vm.getData(categoryId)
}
}
}
Here we create the view model in the state object. (I just added the private.) I can do this because I no longer create the VM with categoryId in the initializer, but instead use the task modifier to pass it in to the one function that needs it. The getData function's signature changed accordingly.
func getData(_ categoryId: Int) async {
await loader.loadData(categoryId: categoryId)
// then do something with data
}
This keeps all of the initializers lightweight, as the @StateObject isn't actually created until the view body is invoked. And we don't want to start working on the load until the view is actually presented.
It also lets SwiftUI be SwiftUI.
This is a good workaround, but in a larger scenario I think we may stick with injecting the parameters using the initializer.
Is there an answer from Resolver
to my last question in last paragraph in the previous comment, which was:
... and this opens a new question related to Resolver
: if I decided to use @InjectedObject
for the view model that expects a parameter (like anid
or item
in the above example), how can I register it in registerAllServices
method ? and do I need to pass it from the NavigationLink
like what you did in the piece of code above ?
If you're going to pass the parameter in the initializer as mentioned then there's no need to register it as it can't be injected. There's no way to get the id into the process.
Just pass it into a StateObject.
I've worked recently on Flutter, they have a package called: Get
which's responsible for injection, it's possible to register a class for the view model at the launch of the app even if it takes another dependency, like this:
GetIt.instance.registerFactoryParam<MyViewModel, SomeModel, void>(
(param1, _) => MyViewModel(model: param1));
where param1
or paramN
is a reserved keyword in the package for this scenario.
when the user taps a row in the list to open its details, we use this line:
ItemScreen(model: item)
and in the ItemScreen
's init
method we do the following:
_viewModel = GetIt.instance<MyViewModel>(param1: model);
Just noting that StateObject's thunk initializer captures the parameter in an autoclosure, so the VM won't be created until the view body is accessed for the first time.
If you have a long form that consists of more than one screen: Step1View, Step2View, Step3View, SummaryView
do you recommend creating one StateObject
for the FormViewModel
? that FormViewModel
handles user input validation, UI rendering logic and storing user input into a Codable
model to be submitted to the API at the end.
That's getting outside of Resolver and my answer would be "it depends" on the complexity of the individual views. Alternative is a specific VM for each screen (if needed) and a common repository/manager for the overall logic.
I couldn't find a way to use the
@InjectedObjected
property wrapper to act as a@StateObject
in the view, after I checked the docs it turned out that@InjectedObject
is actually anObservedObject
.is there a way to inject an observable object in a
SwiftUI
view to act as a@StateObject
?