Magic Dependency Injection in Swift
In Binge for iOS and tvOS we decided to use a custom Swift Property Wrapper to help us inject dependencies into classes and ensure even singleton-esque objects can be mocked in unit tests.
The Problem
Supporting multiple products that each have iOS and tvOS counterparts would quickly become a nightmare without clearly defined rules about how shared code (both internal and third party frameworks) can be used and mocked in unit tests. If you’re unfamiliar with a few standard ways to implement Dependency Injection, here’s a little refresher:
Types of Dependency Injection
No Injection
It can be tempting to make all your shared logic classes into Singletons — that is, store a static “shared” instance on the class that can be accessed easily by everyone:
class NetworkManager { static let shared = NetworkManager()
...}// Later
NetworkManager.shared.perform(...)
There are many issues with this approach, most notably the lack of mockability in unit tests. How could you write a test for a class that makes use of this shared instance without the tests actually accessing the internet?
Initializer Injection
Initializer injection is a reasonable solution. It involves passing instances of dependencies into a class’ init
.
protocol NetworkManagerInterface { ... }class NetworkManager: NetworkManagerInterface { ... }class SomeViewModel { let network: NetworkManagerInterface init(network: NetworkManagerInterface = NetworkManager()) {
self.network = network
}}
By using a protocol (e.g. NetworkManagerInterface
) that lists all the public function of a class, in a unit test we can conform it when creating mocks and then pass those into SomeViewModel
in a unit test. Now our tests won’t access the internet!
There is an issue with this approach: long initializers mean many lines of boilerplate code need to be written and maintained throughout the project, reducing legibility.
Using a Registry
At a high level, an advanced dependency injection solution must allow a developer to list out (aka “register”) object instances / initializers against a key and then provide the ability to look up (aka “resolve”) from the registry using only that key.
The most basic advanced dependency injection solution might be to store all possible object initializers (aka closures that are capable of creating an object) in a dictionary and then look them up in other classes:
var dependencyRegistry: [String: () -> Any] = [
"network": {
return NetworkManager()
},
"auth": {
return AuthManager()
},
...
]// Then later
let authManager = dependencyRegistry["auth"]!() as! AuthManager
Of course, this is less than ideal. At a minimum we should have type safety for both keys and values!
Our @Injected
property wrapper does use an underlying dictionary, however it manages to abstract away the dirty casting by making use of Generics.
Building our @Injected solution
Property Wrappers
Swift’s @propertyWrapper
attribute marks a struct or class as having the ability to be its own annotation for another class’ properties. This gives us the ability to hijack the annotated property’s get
and set
and provide our own implementation. In the case of @Injected
, we use the property wrapper to turn the wrapped variables into get-only computed properties.
As a simplistic example, here’s a custom a property wrapper that makes any wrapped Date
property always return Date()
:
@propertyWrapper
struct CurrentDate {
var wrappedValue: Date {
return Date()
}
}// Usage:
@CurrentDate var currentDate: Date
The wrappedValue
variable is a requirement of the @propertyWrapper
that is decorating the class. As we have not made wrappedValue
settable, any property annotated with @CurrentDate
will also not be settable.
The underlying implementation of @Injected
is based on this concept — except we look up the value to return from our “dependency store”.
Injected.swift
Brace yourself, there’s not much code here!
@propertyWrapper
public class Injected<T> {
private var storage: T?
private let dependencyStore: DependencyStore
public init() {
dependencyStore = DependencyStore.shared
}
public var wrappedValue: T {
if let storage = storage { return storage }
let object: T = dependencyStore.resolve()
storage = object
return object
}
}
What’s going on?
- The
storage
variable allows us to retain and return the instance retrieved from the dictionary. We don’t want to initialise a new instance of the registered object every time the wrapper property is referenced! - The
dependencyStore
property stores an instance of our registry. When@Injected
is initialised, we store a reference to the singleton instance of the registry (more about why later). - The
wrappedValue
property, as before, is get-only. Ifstorage
is not set then it asks the registry for something of typeT
via thedependencyStore.resolve()
function. More on this later.
The @Injected
property wrapper is generic so it can be applied to any type. We now have a developer-friendly way of looking up values in a registry!
Always start with the interface you want and work backwards into implementation from there.
Now let’s take a peek at how DependencyStore
works.
DependencyStore.swift
Actually, this is where the protein is:
class DependencyStore {
static var shared = DependencyStore()
init() {}
private var store: [String: () -> Any] = [:]
func register<T>(_ dependency: @escaping @autoclosure () -> T, for type: T.Type) {
let id = String(describing: T.self)
store[id] = dependency
}
func resolve<T>() -> T {
let id = String(describing: T.self)
guard let initializer = store[id] else {
fatalError("Could not resolve for \(T.self)")
}
guard let value = initializer() as? T else {
// Never happens due to the register function
// being generic - this is needed only because
// `store.value` is `Any`
fatalError("...")
}
return value
}
}
Again, what’s going on?
- First we have a singleton instance of the class stored in
shared
as we need to use the same dependency store throughout the app. This is avar
because of unit tests… more about why later (in reality we wrap it with#if DEBUG
and ensure its alet
when building for the AppStore, don’t worry!) - The
store
variable is our table of strings-to-initializers that we saw in the simplistic example earlier. - The
register(_:for:)
function is our gateway to inserting initializers intostore
. It convertsT.Type
from thefor:
argument to a string for the key, and marks thedependency
as an auto closure for our convenience later on. This makes everything type safe as its impossible to register a type with a wrong key! - The
resolve()
function infers its type from the value being returned. Again, it creates a string usingT.Type
and looks for it in thestore
. It then calls the (auto) closure to get the real instance and returns it (to thewrappedValue
of@Injected
).
And that is the basics of how it all fits together! In our app delegate we register all our dependencies using the register(_:for:)
function, and then we can look them up later using the @Injected
property wrapper:
let store = DependencyStore.shared
store.register(AuthManager(), for: AuthManagerProtocol.self)
store.register(NetworkManager(), for: NetworkManagerProtocol.self)// Later
@Injected var authManager: AuthManagerProtocol.self
@Injected var networkManager: NetworkManagerProtocol.self
Due to the first argument of register(_:for:)
being an auto closure, AuthManager
and NetworkManager
are lazy : they won’t be instantiated until the first time an @Injected
property with their protocol interface is accessed. This does mean that they will be released when the owner of that @Injected
property is released, so if we want singleton-like behavior for one of them we can simply create it first:
let singletonAuthManager = AuthManager()
store.register(singletonAuthManager, for: AuthManagerProtocol.self)
And of course, using a protocol in the for:
is not at all required — but it is highly recommended as it means that mock objects in unit tests can conform to the public protocol interfaces of objects rather than subclassing real objects.
Bells & Whistles
You may have noticed the fatalError
that occurs when when DependencyStore.resolve()
fails to retrieve an object from its store
(because the object hasn’t been registered). This is a serious developer error that we think is worthy of such drastic measures — but what if there was another way to ensure we always register(_:for:)
our dependencies before trying to @Injected
them?
In an Xcode project’s run script phase its possible to tell Xcode to display warnings and errors on specific lines of code. For example, to add an error due do something on the first line of your AppDelegate.swift, simply add the following Bash line in a run script:
echo "/Users/you/your-project/AppDelegate.swift:5: error: Hello!"
Combine this with a non-zero exit
code (to fail the build) and we can practically add language features using Bash! (well, no, but close enough for our purposes).
I’m not going to go into the nitty gritty of the Bash script (please visit the bottom of this article for a full copy) however at a high level it:
- Finds all Swift files containing
@Injected
and extracts their type (using Regex) - Checks if the type exists in a
register(_:for:)
function (again using Regex) - Echos an error if it does not (or if we register something and don’t use it)
Trust me, it’s not as simple as those three points (supporting injections in both local and remote Swift Packages was a nightmare). The result, however, is well worth the effort:
The top editor shows our list of registrations with an error due to line 41 being commented out in the bottom editor. The bottom editor is a typical class showing an error due to line 243 being commented out in the top editor. Our registration list looks like this:
extension DependencyStore {
func registerAll() {
register(SomeClass(), for: SomeClassProtocol.self)
...
}}
Then all our App Delegate needs to do is call DependencyStore.shared.registerAll()
immediately upon app launch. And, as mentioned before, this file can be easily read as the DEPENDENCY_REGISTRATION_FILE
environment variable in our script.
Mocking within Unit Tests
Now that we have dependency injection all set up, how can we change what @Injected
resolves when running in unit tests? Consider the following class:
class AuthManager {
@Injected var networkManager: NetworkManagerProtocol
...
As we saw in our previous examples, a real NetworkManager()
was registered against the NetworkManagerProtocol
. So, when setting up our unit test, we should first create a MockNetworkManager
that conforms to NetworkManagerProtocol
and then register it just before creating the AuthManager
instance that we want to test:
DependencyStore.shared.register(MockNetworkManager(), for: NetworkManagerProtocol.self)let authManagerWithMockNetworkInstance = AuthManager()
This works because now the @Injected
in AuthManager
will resolve the mock network when it looks up the network manager protocol. However, this isn’t a great solution as it means subsequent tests will be run with a DependencyStore.shared
that still has mocks hanging around from previously run tests. And what if tests are being run concurrently?
Our final solution was to create a new instance of DependencyStore
, register mocks against it and then change the DependencyStore.shared
instance just before creating a class. We made a handy function to encapsulate this logic:
extension DependencyStore {
static let syncingQueue = DispatchQueue(label: "injected-sync")
@discardableResult
func execute<T>(_ initializations: @escaping () -> T) -> T {
var anyReturnedValues: T! DependencyStore.syncingQueue.sync {
let defaultInstance = DependencyStore.shared
DependencyStore.shared = self
anyReturnedValues = initializations()
DependencyStore.shared = defaultInstance
} return anyReturnedValues
}
}
syncingQueue
is used to ensure the logic within theexecute(_:)
function isn’t run from two places at once.execute(_:)
takes a closureinitialisations
that returns a generic type and then returns that same generic type to its caller.- Within
execute(_:)
, we store the defaultDependencyStore.shared
instance, replace it withself
, run theinitialisations
closure and then restoreDependencyStore.shared
to its original value
Using this helpful function we can now re-write our previous test code without re-registering objects on DependencyStore.shared
:
let d = DependencyStore()d.register(MockNetworkManager(), for: NetworkManagerProtocol.self)let authManagerWithMockNetworkInstance = d.execute { AuthManager() }
- We create a new
DependencyStore
that is just for this test - We register our network mock on it
- We then move our AuthManager instantiation into the
execute(_:)
closure so that it is created whileDependencyStore.shared
was swapped to be our local instanced
That’s a wrapper!
After we started implementing our @Injected
property wrapper we came across this article (which ended up helping quite a bit with some implementation nuances, so do check it out). Overall, writing @Injected
was quite a difficult undertaking so hopefully it helps or inspires you in some way.
Thanks for tuning in to this first article from the Streamotion iOS team! Be sure to follow this publication if you’re interested in future snippets from Kayo, Binge and more!