hmlongco / Factory

A new approach to Container-Based Dependency Injection for Swift and SwiftUI.

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

Add @InjectedObject property for ObservableObject compatibility

bdrelling opened this issue · comments

Hello, I've used Resolver for a while and just switched over to Factory. Both libraries help to circumvent common issues with EnvironmentObject, but both libraries also run into the same issue wherein you lose the ability to trigger SwiftUI views to update when a property on the object changes by default. This usually requires a bunch of boilerplate code and a required ViewModel, so there's easy way to make lightweight views the way one might with EnvironmentObject, which creates an inverse problem to the one presented by SwiftUI's EnvironmentObject.

Curious what the expectation is for working around this, or if there are plans for something like an @InjectedObject (as in Resolver) property to help account for this? I saw issue #1, but it doesn't really go into detail, and the provided snippet doesn't actually support re-rendering views (see example below), so it's not really a valid workaround for achieving the same behavior by itself.

This library seems like an extremely easy-to-use and easy-to-onboard dependency injection tool, but I would argue it's not really SwiftUI-compatible (or at least not SwiftUI-optimized) without functionality like this in place, and/or some comprehensive documentation in the README for what the expectations/workarounds are for dealing with this.

Problem

Say we want to pull AppSettings into a view, which is just a struct that has an environment property. Ideally, this would be all it takes to achieve that:

struct MyView: View {
    @Injected(Container.appSettings) var appSettings

    var body: some View {
        Text(self.appSettings.environment)
    }
}

However, when appSettings.environment is updated, the view does not reload -- which is a known limitation of this library.

If I take the snippet provided in issue #1, it doesn't resolve the problem -- the view still doesn't reload.

struct MyView: View {
    @StateObject var viewModel = MyViewModel()

    var body: some View {
        Text(self.viewModel.appSettings.environment)
    }
}

final class MyViewModel: ObservableObject {
    @Injected(Container.appSettings) var appSettings
}

Instead, I have to do something like this:

final class MyViewModel: ObservableObject {
    @Injected(Container.appSettings) var appSettings

    private var subscriptions = Set<AnyCancellable>()
    
    init() {
        // without this block, MyView will not trigger a state refresh.
        self.appSettings.objectWillChange.sink { _ in
            self.objectWillChange.send()
        }
        .store(in: &self.subscriptions)
    }
}

or this

final class MyViewModel: ObservableObject {
    @Injected(Container.appSettings) var appSettings
    @Published var environment: Environment

    private var subscriptions = Set<AnyCancellable>()
    
    init() {
        // no different than the example above unless I optimize further
        self.appSettings.$environment
            .assign(to: &self.$environment)
    }
}

Proposed Solution

A reimplementation of Resolver.InjectedObject to be included in this library:

@propertyWrapper public struct InjectedObject<T>: DynamicProperty where T: ObservableObject {
    @ObservedObject private var dependency: T
    
    public init(_ factory: Factory<T>) {
        self.dependency = factory()
    }
    
    public var wrappedValue: T {
        get { return dependency }
        mutating set { dependency = newValue }
    }
    
    public var projectedValue: ObservedObject<T>.Wrapper {
        return self.$dependency
    }
}

I don't think we need to provide an implementation for StateObject explicitly since we're not worried about a view being the owner of the StateObject -- similar to how there is no EnvironmentStateObject and EnvironmentObservedObject, for example.

That said, if we needed to (or it would help optimize or anything), we could also add InjectedStateObject for explicit conformance.

You don't show AppSettings but indicated right off the bat that it's a struct, so I'm not clear on how it's supposed to trigger an update since a struct can't be an observable object.

At any rate, and regarding InjectedObject, I've resisted doing this, simply because all we're doing is wrapping Apple's implementation in yet another level of indirection, and because it's simply too easy to do something like example 1 or 2...

struct StateObjectView1: View {
    @StateObject var vm = Container.vm()
    var body: some View {
        Text(vm.text)
    }
}

struct StateObjectView2: View {
    @ObservedObject var vm = Container.scopedVM()
    var body: some View {
        Text(vm.text)
    }
}

Which uses the standard Apple property wrappers and as such makes it clear exactly what behavior is occurring.

Resolver's InjectedObject also hid the fact that we were using ObservedObject and as such from my perspective it didn't make it clear that your registration was required to be scoped. Failure to do so would cause a new instance to be created each and every time the view tree was evaluated prior to a redraw or screen refresh.

Happy to continue the discussion.

I kind of mentioned this, but the InjectedObject wrapper as implemented above will call the Factory to get an instance of the object every single time the view is evaluated. This obviously means the factory must be scoped but StateObject will do a much better job of determining when one instance should go out of scope and a new one created.

Bottom line is that using the injection system for ViewModels in SwiftUI is a bad idea. It's really designed to provide the VM and other services with the dependencies that they need. Especially since those services have no access to the environment.

@hmlongco thank you for the explanation -- and I misspoke when talking about AppSettings being a struct, I meant to imply it was an ObservableObject but was rewriting the snippet a few times so must have missed this.

I think I understand everything you're saying, but can you elaborate on this part?

as implemented above will call the Factory to get an instance of the object every single time the view is evaluated.

I've haven't worked with PropertyWrappers much still, but my understanding is that it resolves the dependency immediately upon initialization. Is it the DynamicProperty usage that would make it re-instantiate every time, or am I missing something?

Your examples here are exactly what I'm referring to as missing from your documentation:

@StateObject var vm = Container.scopedVM()
@ObservedObject var vm = Container.scopedVM()

This seems simple, but honestly it never occurred to me to use Factory in this way. I definitely, definitely think you should put this example along with a brief explanation in your README.

I had a version of it. See the example just before Mocking and Testing.

"Finally, note that it's possible to bypass the property wrapper and talk to the factory yourself in a Service Locator pattern."

class ContentViewModel: ObservableObject {
    // dependencies
    private let myService = Container.myService()
    private let eventLogger = Container.eventLogger()
    ...
}

I'll look at adding that it's needed if you want to use Factory with SwiftUI's wrappers.

As to elaborating, please take a look at my article, Deep Inside Views, State and Performance in SwiftUI.

The 60 second version goes like this: In SwiftUI Views are not views, they're lightweight definitions of views. When state changes the view structs are constantly being recreated, evaluated, and compared in order to determine if the portion of the UI they define needs to be updated and redrawn.

Each time a view is recreated its property wrappers are recreated, and then in our case the initialization function would call the factory to get the required dependency.

Again, I highly suggest you read the above article if you're working with SwiftUI, as understanding the mechanisms involved are critical to using it successfully.

Right, I saw that example, I'm talking about examples for use with SwiftUI explicitly. I understand it's the same functionality, but that's why I think there's a benefit to explicitly discussing it within your README.

Will check out the article, thanks for sharing! Keep up the good work. 👍🏻

Updated README with section on SwiftUI.