Photo by Clément Hélardot on Unsplash
Dependency Injection (DI) in Android: Providing Interfaces (Day6)
Dependency injection is a powerful concept in Java, and Dagger2 has become one of the most popular frameworks for implementing it. One of the challenges developers often face is managing multiple implementations of a particular dependency. This article delves into how to provide interfaces in Java using Dagger2 principles to address this challenge.
The Problem:
Imagine a scenario where an application has two different types of NetworkSetup
classes (NetworkSetup
& NetworkSetupSecond
). The app needs to decide which version of the dependency to create and inject at different times. If we were to inject a new NetworkSetupSecond
class into our other classes (ComputeLayer
), we'd have to refactor all the code, replacing all references of the first class with the second. This approach is not scalable and makes swapping classes cumbersome.
The Solution: Using Interfaces
Interfaces act as a contract or promise. When a class implements an interface, it agrees to provide specific behaviors (methods) listed in the interface. This allows different classes to be treated similarly based on the methods they've agreed to implement.
To address our problem, we can:
- Create an Interface: We'll define an interface named
NetworkLayer
that lists the standard methods we plan to use in bothNetworkSetup
class types.
public interface NetworkLayer {
boolean sendDataToCloud(String data);
boolean saveDataToStorage(String data);
}
- Implement the Interface in Classes: Both
NetworkSetup
andNetworkSetupSecond
classes will implement theNetworkLayer
interface. This ensures that both classes adhere to the contract defined by the interface.
public class NetworkSetupSecond implements NetworkLayer{
String TAG = this.getClass().getCanonicalName();
StorageLayer storageLayer;
@Inject
public NetworkSetupSecond(StorageLayer storageLayer){
this.storageLayer = storageLayer;
try {
Log.e(TAG, "Initialising network second" );
Thread.sleep(3000);
Log.e(TAG,"Network Second initialization done");
} catch (InterruptedException e) {
e.printStackTrace();
}
Log.e(TAG, "Network Layer Second Created");
}
@Override
public boolean sendDataToCloud(String data) {
Log.e(TAG,"Sending Data to cloud from second :: " + data);
return true;
}
@Override
public boolean saveDataToStorage(String data) {
storageLayer.saveDataToStorage(data);
return true;
}
}
public class NetworkSetup implements NetworkLayer {
String TAG = this.getClass().getCanonicalName();
StorageLayer storageLayer;
@Inject
public NetworkSetup(StorageLayer storageLayer){
this.storageLayer = storageLayer;
try {
Log.e(TAG, "Initialising network" );
Thread.sleep(6000);
Log.e(TAG,"Network initialization done");
} catch (InterruptedException e) {
e.printStackTrace();
}
Log.e(TAG, "Network Layer Created");
}
@Override
public boolean sendDataToCloud(String data) {
Log.e(TAG,"Sending Data to cloud :: " + data);
return true;
}
@Override
public boolean saveDataToStorage(String data) {
storageLayer.saveDataToStorage(data);
return true;
}
}
- Injecting the Interface: Instead of injecting a specific class, we can now inject the
NetworkLayer
interface. This provides flexibility, allowing any class that implements theNetworkLayer
interface to be injected.
public class ComputeLayer {
String TAG = this.getClass().getCanonicalName();
NetworkLayer network;
@Inject
ComputeLayer(NetworkLayer networkLayer){
this.network = networkLayer;
Log.e(TAG, "Compute Layer Created");
}
//...
}
This approach allows us to create any number of variations for the NetworkLayer
class, as long as they implement the NetworkLayer
interface.
Handling Dagger2 Injection:
While this approach solves the problem of flexibility, Dagger2 will throw an error when trying to inject an interface. This is because Dagger2 doesn't know how to instantiate an interface.
To address this, we can use Dagger2's @Module
and @Provides
annotations:
- Create a Network Module: This module will contain provider methods that provide the implementations of the
NetworkLayer
interface to Dagger2.
@Module
public class NetworkModule {
@Provides
NetworkLayer provideNetworkSetup(StorageLayer storageLayer){
return new NetworkSetup(storageLayer);
}
@Provides
NetworkLayer provideNetworkSetupSecond(StorageLayer storageLayer){
return new NetworkSetupSecond(storageLayer);
}
}
- Update the Component: Inform Dagger2 about the module by updating the component class.
@Component (modules = NetworkModule.class)
public interface ComputeComponent {
ComputeLayer getComputeLayer();
void inject (MainActivity mainActivity);
}
However, Dagger2 will throw an error if multiple provider methods are available for the same interface. To resolve this, we can comment out one of the provider methods, depending on which implementation we want to inject.
Error Again!
Building the project above, throws an error again, this time it says that NetworkLayer
is bound multiple times. This error should be understandable because we have instantiated two different objects of the same superclass type NetworkLayer
using the provideNetworkSetup
& provideNetworkSetupSecond
provider method. Now dagger doesn't know how to decide which object should be injected when!
The solution to this problem is also pretty straightforward, all we need to do is comment out one of the provider methods, and dagger will automatically provide the other class object.
Let's try to comment out the provideNetworkSetupSecond
method, run the app and check the logs.
And sure enough, we have our NetworkLayer class instantiated.
Now let's try to comment out provideNetworkSetup
and uncomment the provideNetworkSetupSecond
method to check the logs again.
And just like that, we've now implemented a different NetworkLayer version to our project by changing just a couple of lines of code.
But!
If you are following this blog post till now, you might have guessed that there is one more way to optimize all of this implementation even more, by creating different modules for different NetworkLayer types.
Let's see how we can do this.
First of all, we need to create a second NetworkLayer
Module, let's call it NetworkLayerSecond
@Module
public class NetworkModuleSecond {
@Provides
NetworkLayer provideNetworkSetupSecond(StorageLayer storageLayer){
return new NetworkSetupSecond(storageLayer);
}
}
Now instead of having 2 provider methods in the same class, we'll have one provider for one NetworkLayer class type in one module.
This way, we can simply swap the modules in our component class without even commenting out any code, like this.
@Component (modules = NetworkModule.class)
// you can replace NetworkModule with NetworkModuleSecond
// and it'll simply swap the underlying implementation for you.
public interface ComputeComponent {
ComputeLayer getComputeLayer();
void inject (MainActivity mainActivity);
}
Please note, that we cannot provide both the NetworkModule
and NetworkModuleSecond
at the same time, because Dagger would again not be able to decide which object to inject in the dagger graph.
And that's it, now you have successfully injected an Interface implementation into your project.
Conclusion:
Using interfaces in conjunction with Dagger2 provides a flexible and scalable approach to dependency injection in Java. By defining a contract with interfaces and leveraging Dagger2's module and provider mechanisms, developers can easily manage and swap multiple implementations of a particular dependency.
This exploration underscores the versatility and depth of Dagger2 as a dependency injection framework. It also highlights the importance of continuous learning and adaptation in the ever-evolving world of software development.
As we wrap up this segment, rest assured that our journey with Dagger2 is far from over. In upcoming discussions, we will delve into even more advanced topics within dependency injection, ensuring that you remain at the forefront of best practices and innovative solutions. Stay tuned for more insights and discoveries on Dagger2 and beyond.