hmlongco / Resolver

Swift Ultralight Dependency Injection / Service Locator framework
MIT License
2.15k stars 190 forks source link

using InjectedObject as a StateObject #151

Closed murad1981 closed 2 years ago

murad1981 commented 2 years ago

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 an ObservedObject.

is there a way to inject an observable object in a SwiftUI view to act as a @StateObject ?

hmlongco commented 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.

murad1981 commented 2 years ago

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.

hmlongco commented 2 years ago

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

murad1981 commented 2 years ago

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 ?

hmlongco commented 2 years ago
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

murad1981 commented 2 years ago

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 @ObservedObjectwhich 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 ?

hmlongco commented 2 years ago

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.

murad1981 commented 2 years ago

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 ?

hmlongco commented 2 years ago

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.

murad1981 commented 2 years ago

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);
hmlongco commented 2 years ago

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.

murad1981 commented 2 years ago

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.

hmlongco commented 2 years ago

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.