What is Inversion of Control?
Control inversion is to delegate traditional control logic to another class or framework for processing. The client only needs to implement specific tasks without caring about control logic.
For example, there are two categories: client and service provider. The client needs to call the service provider's functions to execute a certain logic. In traditional programming methods, the client directly calls the service party's functions according to his or her own needs to achieve the goal. Control inversion means handing over the control logic to the service provider. The service provider provides a framework for control flow, and the specific content needs to be filled by the customer, which means that the control of the process is reversed, and now the service provider calls the customer. It is said that there is a famous saying in Hollywood: Don't call us, we'll call you, which means almost this. The above service providers can also be library code or framework.
In iOS development, there is a very common implementation of control inversion. Many people may not realize that this is control inversion, that is completionHandler, or callback.
(completion: { data in handleData(data) })
In this example, the business party only needs to relate to what to do after getting the data, and does not care about the call timing of completion, and delegates the call to completion to the network library. This is control reversal.
Control inversion can separate the main tasks and control logic, improve the modularity and scalability of the code, loosely coupled the code, and make writing test code simple.
Common implementations of control inversion include:
- Service Locator
- Dependency injection
- Contextualized lookup
- Template method
- Strategy design pattern
This article only discusses this implementation of dependency injection, and will not discuss other implementations for the time being.
What is dependency injection?
Dependency injection is a concrete implementation of control inversion. It creates the dependency object of this class outside the class, and then provides the dependency object to the class in some way. Through dependency injection, the creation and binding of dependency objects are moved outside the class.
Let’s take a look at the following example:
class Car { var tyres: [Tyre] init() { let tyre1 = Tyre() let tyre2 = Tyre() let tyre3 = Tyre() let tyre4 = Tyre() tyres = [tyre1, tyre2, tyre3, tyre4] } }
In this example, a car object is built, and the construction of the car object requires assembling 4 tires. The disadvantage of this code is that the tire creation logic is coupled with the car itself. When we want to change to another tire, or if the Tyre class adjusts the implementation and adds a parameter when constructing, the code in the Car class must be changed.
This kind of problem can be solved by moving the creation and binding of dependency objects outside the class using dependency injection.
class Car { var tyres: [Tyre] init(types: [Tyre]) { = types } }
To give another example, the common network requests in App development -> Data processing -> Data rendering process, the traditional method is developed as follows:
// func loadData() { (id: 2222, completion: { data in (data) }) }
Such code cannot be tested because ViewModel is coupled with specific network requests. In order for the loadData method to be tested, an interface requested by the network should be abstracted and then the implementation of this interface should be injected from the outside. The following code:
protocol Networking { func requestData(id: Int, completion: (Data) -> Void) }
Let DataViewModel have a property object that needs to be injected:
class DataViewModel { let networking: Networking init(networking: Networking) { = networking } }
The loadData method is modified as follows:
func loadData(completion: (() -> Void)?) { (id: 2222, completion: { data in (data) }) }
In this way, the specific network request implementation is transferred to external injection, and a simulated network request can be simply implemented during testing, so that the test code can be written:
func testLoadData() { let networking = MockNetworking() let viewModel = DataViewModel(networking: networking) let expectation = XCTestExpectation() { () } wait(for: [expectation], timeout: .infinity) XCTAssertTrue() }
The biggest advantage of dependency injection is that it realizes decoupling between classes. What is decoupling? Decoupling means that although there are some dependencies between two classes, when the implementation of any of the classes changes, the implementation of the other class is completely unaffected. The decoupling itself is implemented through an abstract interface. Therefore, dependency injection also requires an abstract interface, and dependency injection transfers the creation of dependencies outside the client class, and decouples the creation logic of the dependency from the client class itself. In this way, no matter whether it is replacing the implementation class of the dependent object or replacing a part of the dependent object class implementation, the client class does not need to make any modifications.
Types of dependency injection
Dependency injection is mainly injected through initializer injection, attribute injection, method injection, interface injection, etc.
Initializer injection
Initializer injection provides dependencies to the object through the parameters of the initialization method. Initializer injection is the most commonly used injection method. It is simple and intuitive. When the life cycle of an object dependent on is the same as the object itself, using initializer injection is the best way.
class ClientObject { private var dependencyObject: DependencyObject init(dependencyObject: DependencyObject) { = dependencyObject } }
Attribute injection
Attribute injection provides dependencies to the object through the object's public properties, which can also be called setter injection. It is generally used when the initializer injection cannot be used (for example, Storyboard is used), or when the life cycle of the dependent object is smaller than that of the object.
public class ClientObject { public var dependencyObject: DependencyObject } let client = ClientObject() = DefaultDependencyObject()
Method Injection
The method of method injection is that the object needs to implement an interface, which declares methods that can provide dependencies to the object. The injector provides dependencies to the object by calling this method. Method injection can also be called interface injection.
protocol DependencyObjectProvider { func dependencyObject() -> DependencyObject }
Sometimes the client only needs to use dependencies under certain specific conditions. At this time, method injection can be used. The client will call the method to create the dependency object only when it needs to use the dependency, which avoids the problem that the client will also create the dependency object when it is not used. This can also be achieved by injecting a code block:
init(dependencyBuilder: () -> DependencyObject) { = dependencyBuilder }
Dependency injection container
Dependency injection requires that the dependencies of the object be created outside the object and injected into the object in some way. If you create an object's dependency every time you create an object, the code will become repetitive and complex. When the object's dependency is adjusted, changes will be required in every place. Therefore, when using dependency injection, a Dependency Injection Container is also required.
Dependency injection containers are used to uniformly manage the creation and life cycle of dependent objects, and can also inject dependencies into objects as needed.
The dependency injection container needs to provide the following functions:
- Register: The container needs to know how to build its dependencies for a specific type. The container will save the type-dependency mapping information and provide an interface to add this type information to the container. This operation is registration.
- Resolve: When using dependency injection to the container, there is no need to manually create the dependency, but let the container help us do this. The container needs to provide a method to obtain an object according to the type. The container will create all dependencies of this object. The caller can use this object directly without caring about the dependencies.
- Dispose: A container needs to manage the life cycle of a dependent object and dispose of it at the end of the life cycle of a dependent object.
Implement a simple dependency injection container
There are many third-party dependency injection frameworks that implement dependency injection functions, such as Swinject in the Swift language. We can also implement a dependency injection container ourselves.
According to the definition of the dependency injection container, and with the help of Swift's generics and protocols, the following protocols can be defined:
protocol DIContainer { func register<Component>(type: , component: Any) func resolve<Component>(type: ) -> Component? }
The specific implementation is as follows:
final class DefaultDIContainer: DIContainer { static let shared = DefaultDIContainer() private init() {} var components: [String: Any] = [:] func register<Component>(type: , component: Any) { components["\(type)"] = component } func resolve<Component>(type: ) -> Component? { return components["\(type)"] as? Component } }
With this DIContainer, you can choose two ways to use it, one is to resolve the object externally and inject it.
let object = () let viewModel = ViewModel(dependencyObject: object)
One is to resolve in the default parameter value of the initialization method:
class ViewModel { init(dependencyObject: DependencyObject = ()) = dependencyObject }
These two methods have some usage scenarios and can be selected according to the specific situation.
The above DIContainer is just a simple implementation. Combined with specific requirements, you can add functions such as thread safety, Storyboard injection, and automatic parsing. You can refer to Swinject.
Summarize
Dependency injection is a common design pattern in many fields, such as Java Spring, etc. No matter which direction of development is done, dependency injection must be mastered. Using dependency injection at the right time can decouple the code and improve the testability and scalability of the code.
References
- /ioc/ioc-con…
- /wiki/Invers…
- /wiki/Depend…
The above is the detailed explanation of swift dependency injection and dependency injection container. For more information about swift dependency injection dependency injection container, please pay attention to my other related articles!