Complete Guide to Dependency Injection in Swift
Aug 21, 2024Dependency injection sounds complex, but it's actually a straightforward concept. Imagine a piece of code that needs another to function. Instead of creating the second piece internally, we provide it from outside. This is called "injecting" the dependency.
Dependency Injection solves many common challenges often faced in larger projects, like overlay complex initializers with numerous dependencies, difficulties in mocking data for testing, reliance on force unwrapping and many more.
Table of Contents
- What is Dependency Injection in Swift
- Types of Dependency Injection in SwiftUI
- Injecting Dependency Containers Using Swinject
What is Dependency Injection
Dependency Injection (DI) is a design technique where an object receives its required dependencies from an external source, rather than creating them internally.
Example without Dependency Injection
// Without dependency injection
class Profile {
let name: Name = Name(firstName: "John", lastName: "Doe")
}
In the above example, a new instance of Name
is directly created.
Example with Dependency Injection
// With dependency injection
class Profile {
let name: Name
init(name: Name) {
self.name = name
}
}
Profile
receives a Name
object through its initializer.
Types of Dependency Injection in SwiftUI
There are primarily two main approaches to Dependency Injection in SwiftUI:
- Constructor Injection
- Environment Injection
Constructor Injection
In Constructor Injection, the dependencies are passed to a view's initializer. It is also known as Initializer Injection. Let's take an example of a NetworkManager
class which fetches data from a URL.
Without Dependency Injection:
class NetworkManager: ObservableObject {
func fetchData() {
// Code to fetch data from a hardcoded URL
let url = URL(string: "https://example.com/api/data")!
let session = URLSession.shared
// ... (rest of the network fetching code)
print("Network fetch completed..")
}
}
The hardcoded URL in NetworkManager
limits its testability and reusability. Modifying the URL requires changes to the class itself, which can potentially impact multiple usage points unnecessarily.
Let's take a simple example where we use the NetworkManager
to fetch data.
struct ContentView: View {
@StateObject var networkManager: NetworkManager = NetworkManager()
var body: some View {
VStack {
Button("Fetch Data") {
networkManager.fetchData()
}
}
.padding()
}
}
Here's how you can implement constructor dependency injection.
With Dependency Injection:
class NetworkManager: ObservableObject {
private let urlProvider: URLProvider
init(urlProvider: URLProvider) {
self.urlProvider = urlProvider
}
func fetchData() { ... }
}
In the above code NetworkManager
class relies on a URLProvider protocol to supply URLs.
protocol URLProvider {
var url: URL { get }
}
This protocol defines a single property url
of type URL
. This acts as a contract, ensuring that any concrete implementation of the protocol provides a valid URL
.
With this approach, the NetworkManager
class is no longer directly coupled with the specifics of URL generation and it also gives you the flexibility to extend it with different URL providers.
Now, let's create the dependencies that the NetworkManager
relies on, which would provide the actual production URL.
class RealURLProvider: URLProvider {
var url: URL { return URL(string: "https://example.com/api/data")! }
}
This is how you can use it:
let realProvider = RealURLProvider()
let networkManager = NetworkManager(urlProvider: realProvider)
ContentView(networkManager: networkManager)
This creates a NetworkManager
instance with a RealURLProvider
, which means the fetchData
call will use the production URL.
Let's make it a bit more simple by refactoring the ContentView
.
extension ContentView {
init(urlProvider: URLProvider) {
_networkManager = StateObject(wrappedValue: NetworkManager(urlProvider: urlProvider))
}
}
struct ContentView: View {
@StateObject var networkManager: NetworkManager
var body: some View { ... }
}
In the above code, the custom initializer for ContentView
allows injecting a URLProvider
instance. The initializer creates a StateObject
of NetworkManager
using the provided URLProvider
.
Now, when creating a ContentView
instance, you just need to provide a URLProvider
to the custom initializer.
ContentView(urlProvider: RealURLProvider())
We can pass any object that confirms to URLProvider
as a dependency for NetworkManager
. For example,
class MockURLProvider: URLProvider {
var url: URL { return URL(string: "https://mock.com/data")! }
}
// Usage example
ContentView(urlProvider: MockURLProvider())
The NetworkManager
instance is created using the MockURLProvider
. The fetchData
call will use the mock URL for testing purposes.
The constructor dependency injection approach allows switching between production and mock URLs based on the injected URLProvider implementation. It promotes cleaner code separation and reduces reliance on hardcoded URLs within the NetworkManager
class.
Click here to download the complete project
Although constructor injection is beneficial, this method can lead to a chain of dependency passing in complex view hierarchies.
Environment Injection
This approach involves using an @EnvironmentObject
property wrapper to provide dependencies to views. It allows dependencies to be shared across multiple views in the hierarchy without passing them explicitly.
Here's the implementation of the previous example using @EnvironmentObject
.
struct ContentView: View {
@EnvironmentObject var networkManager: NetworkManager
var body: some View {
VStack {
Button("Fetch Data") {
networkManager.fetchData()
}
}
}
}
To make the dependency accessible, you need to add it to the environment of the ancestor view:
@main
struct SampleApp: App {
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(NetworkManager(urlProvider: RealURLProvider()))
}
}
}
This NetworkManager
class can be accessed from multiple views within the same hierarchy.
This method is more suitable for smaller projects or when the dependencies are relatively simple. However, it can become cumbersome when dealing with complex dependencies or when you need to mock dependencies for testing. To mitigate these challenges and enhance code maintainability, testability, and scalability, we often use Dependency Injection (DI) Containers. DI containers automate the process of managing object creation and their connections. Let's see how DI containers work.
Injecting Dependency Containers Using Swinject
What is Dependency Injection Container
DI containers are external libraries or frameworks that manage the creation and lifecycle of objects and their dependencies. To illustrate this concept further, let's see how a DI container operates with the analogy of in a restaurant kitchen.
A DI container acts as a centralized kitchen, managing and providing ingredients (dependencies) to various recipes (classes). It registers how to create and manage these ingredients, and when a recipe is requested, it assembles the required ingredients to create the final dish (object).
Before diving into implementation, let's understand its fundamental structure. A DI container typically comprises of three core components:
- Registration: Defines how to create and manage instances of dependencies.
- Resolution: Retrieves instances of registered dependencies from the container when requested.
- Scope: Determines the lifecycle of a dependency instance.
When to use a DI Container
While SwiftUI doesn't have a built-in container, you can use third-party DI containers, if needed. However, for most SwiftUI applications the built-in mechanisms are sufficient. You might consider using a DI container in complex SwiftUI applications with a large number of dependencies and intricate dependency graphs. In such cases, a container can help manage dependencies more effectively and improve code organization.
If you decide to use a DI container, one popular option for Swift is Swinject. Swinject is a lightweight dependency injection framework for Swift that also supports features like property injection and lifecycle management. Let's learn how to use Swinject to streamline dependency management in our project.
Using Swinject: A Practical Guide
Swinject can be installed through Carthage, CocoaPods, or Swift Package Manager( Swinject installation)
Usage
- Import Swinject:
swift import Swinject
-
Create a container:
let container = Container()
-
Register Dependencies:
container.register(URLProvider.self) { _ in RealURLProvider() } container.register(NetworkService.self) { resolver in NetworkService(urlProvider: resolver.resolve(URLProvider.self)!) }
URLProvider.self
: This registers the implementation ofRealURLProvider
NetworkService.self:
This registersNetworkService
and injects theURLProvider
dependency resolved from the container.
-
Resolve Dependencies:
let networkService = container.resolve(NetworkService.self)! networkService.getData() // Uses the provided URL from RealURLProvider
container.resolve(NetworkService.self)!
: Retrieves an instance ofNetworkService
from the container, injecting the requiredURLProvider
dependency.
Example: A Simple Use Case
Let's create the Container
in a separate class.
import Foundation
import Swinject
final class Injection {
static let shared = Injection()
var container: Container {
get {
return _container ?? buildContainer()
}
set {
_container = newValue
}
}
private var _container: Container?
private func buildContainer() -> Container {
let container = Container()
container.register(URLProvider.self) { _ in RealURLProvider() }
return container
}
}
In the above code:
- The
Injection
class provides a single point of access to the SwinjectContainer
. - The
container
property ensures that the container is created only when needed, promoting lazy initialization. - The
buildContainer
function creates a newContainer
instance. By registering these dependencies, you can later resolve them from the container to use wherever required. Likewise, you can register as many global dependencies as you want.
Now, inside the NetworkManager
class, instead of using the constructor injection, we can use the Injection
class.
class NetworkManager: ObservableObject {
var urlProvider: URLProvider = Injection.shared.container.resolve(URLProvider.self)!
func fetchData() { ... }
}
This works perfectly, but doesn't look good. Let's make the syntax better.
Let's create a property wrapper for it say, Injected
.
@propertyWrapper struct Injected<Dependency> {
var wrappedValue: Dependency
init() {
wrappedValue = Injection.shared.container.resolve(Dependency.self)!
}
}
The generic type Dependency
allows for injecting different types of dependencies. Now, we can use the Injected
property wrapper in our NetworkManager
class.
class NetworkManager: ObservableObject {
@Injected var urlProvider: URLProvider
func fetchData() { ... }
}
This is much cleaner and easier to read.
Click here to download the complete project
Failing to register a dependency before using it will result in a runtime crash. So you don't have to worry about missing out on a dependency somewhere where you have not tested. The code will break at the run time itself.
Conclusion
Dependency injection is a valuable tool for building well-structured, testable, and maintainable SwiftUI applications. This article discusses how to implement dependency injection in SwiftUI using direct injection and environment objects. We also explored creating a simple DI container and using it to register and resolve dependencies, as well as creating a property wrapper for cleaner code. By decoupling view models from their dependencies, you can create more modular, flexible, and testable code. Understanding these mechanisms is crucial, and the optimal choice depends on your project's specific needs.
Where to go next?
By mastering Dependency Injection you've taken a significant step towards modularising your codebase. To further enhance your SwiftUI codebase, we strongly recommend delving into core mobile engineering concepts like MVVM in SwiftUI and Unit testing in SwiftUI.