8

Let's say I have a view model called AuthViewModel which handles all authentication-related activities and states of my SwiftUI app, and it requires the following dependencies:

  • A HTTPClient object to run HTTP requests (this is an abstraction over the URLSession logic)
  • A SecureStorage object to abstract keychain access logic for secrets
  • A CoreDataStorage object to abstract Core Data access logic

These classes should also ideally be passed down the view hierarchy so that other view models could reuse them too.

The problem is I've been trying different ways to inject these dependencies that are required in the init method of my AuthViewModel without success. You can imagine the init method looks like this:

class AuthViewModel: ObservableObject {
  @Published private(set) var authToken: String?
  
  init(httpClient: HTTPClient, secureStorage: SecureStorage, coreDataStorage: CoreDataStorage) {
  }
}

Here are a few things that I have tried and/or found unsatisfactory:

1. Turn all the dependencies listed above into ObservableObjects and use .environmentObject to pass them down the view hierarchy

This doesn't work because if I pluck off the @EnvironmentObject in the same View body that defines the authViewModel, the init method of the view model doesn't have access to self, and hence the rest of the environmentally-injected dependencies.

struct LoginView: View {
  @EnvironmentObject private var httpClient: HTTPClient
  @EnvironmentObject private var secureStorage: SecureStorage
  @EnvironmentObject private var coreDataStorage: CoreDataStorage
  
  // This won't work because at the current scope `self` is not ready
  @StateObject private var authViewModel = AuthViewModel(
    httpClient: httpClient,
    secureStorage: secureStorage,
    coreDataStorage: coreDataStorage
  )
}

2. Remove dependencies from the initializer and move them into settable properties

This is a plausible workaround but it generates too much work unwrapping the optional dependencies for its internal methods, not to mention that these dependencies are required for the functioning of the view model and should really have non-null values at all times. In a large application, we must also ensure that other developers do not forget to set the dependencies—if only there was a failsafe way of helping them not forget about required dependencies am I right?

3. Make the dependencies private environment properties within the view model itself

This defeats the purpose of Dependency Injection, because it's turning our view model from a blackbox into a whitebox object, meaning that we cannot successfully operate this object without explicit knowledge of its internals, aka the private environment dependencies inside of it. This complicates testing and requires the developer to always look inside of the private properties of the view model to make use of it properly.

Also, this is almost akin to using singletons of dependencies, which is the very thing we are trying to avoid here. At least, I'd like these singletons to be in the View body, but once we cross over into the view model's territory I'd like them to be injected at the very least.

4. Inject these dependencies at the top level of the application where the dependencies are defined:

There were suggestions floating around to define these outside the scope of the @main struct.

private let httpClient = ...
private let secureStorage = ...
private let coreDataStorage = ...
private let authViewModel = AuthViewModel(httpClient: httpClient, secureStorage: secureStorage, coreDataStorage: coreDataStorage)

@main
struct MyApp: App {
  @StateObject private var myAuthViewModel = authViewModel
  
  var body: some Scene {
    WindowScene {
      SplashScreen()
        .environmentObject(myAuthViewModel)
    }
  }
}

This unconventional approach has one downside: I don't just have this one view model in my app. There might be other view models down the line that requires the dependencies too, and there's no initializing them like this then.


To solve this, I am seeking a solution that:

  • Allows me to define a few dependencies at the @main struct of my app and pass them down such that other view models can be initialized with them
  • Does not sacrifice the testability of the view model, and maintains its decoupled nature
  • Allow multiple instances of the same dependency to co-exist. For example, I might want to have different HTTPClients for API requests and for downloading/uploading operations

PS: At the time of writing Xcode 13 is still in beta, but if there is a solution that requires Swift 5.5 I'm all ears. I'm using it anyway.

halfer
  • 19,824
  • 17
  • 99
  • 186
Mai Anh Vu
  • 89
  • 2
  • Check out https://github.com/Liftric/DIKit – Jake Aug 24 '21 at 02:07
  • Your problem is you are trying to create the `@StateObject` inside the consuming view. As you have found this doesn't work. You need to create the `@StateObject` in the view's parent and then either pass it via the environment or as an initialiser parameter to the view – Paulw11 Aug 24 '21 at 02:09
  • 1
    @Paulw11 Thanks for the suggestion, that indeed will work. However, the problem is that the view's parent will also need to get these dependencies somehow, and usually a view's parent is another view. How do I construct a `@StateObject` in this parent that will also require these dependencies? – Mai Anh Vu Aug 24 '21 at 04:55
  • It's turtles all the way down; You inject the parent's view model from its parent and so on. You put the dependencies in the environment as per point 1 in your question (or you can hold them as properties of a single object you put in the environment if you like) – Paulw11 Aug 24 '21 at 04:59
  • 1
    Does this answer your question https://stackoverflow.com/a/62636048/12299030? Please note: it does not fit all scenarios - read comments. – Asperi Aug 24 '21 at 05:22
  • @Asperi That's an anti pattern in the context of SwiftUI. All problems which try to init a state object or variable in the initialiser can be transformed to proper SwiftUI using Bindings, ObservedObjects or const values set by the parent, and thus this anti pattern can be completely avoided. – CouchDeveloper Aug 24 '21 at 06:57
  • @CouchDeveloper See the most recent comment on their answer. It was a question in a SwiftUI lounge, where the engineers say it is correct usage. Link (that was in that comment) [here](https://swiftui-lab.com/random-lessons/#data-10) to the engineer's answer. Although, as they state, the initializer input may change but the object doesn't if the view identity doesn't change. – George Aug 24 '21 at 10:43
  • @George Thanks for the link, I searched for a while for an official comment on this. But the "peculiar" usage is still the same: the state var will be affected only when the "conceptual view" will be created, and then has no effect anymore when the init will be called for a modification. For that reason, it was denoted "anti pattern" some time earlier. I would like to see this in the official docs. – CouchDeveloper Aug 24 '21 at 10:56
  • Thanks @George for the link! I learned a couple of other stuffs from that same page too. And yes Apple really had us for a while there – Mai Anh Vu Aug 27 '21 at 07:02
  • Did you ever find a solution to this? Did you try passing dependencies via view model function parameters as opposed to storing the dependencies as properties of the view model? – NFarrell Feb 03 '23 at 18:04

0 Answers0