Injection manuelle de dépendances

L'architecture de l'application recommandée d'Android vous encourage à diviser votre code en classes pour bénéficier de la séparation des tâches, un principe selon lequel chaque classe de la hiérarchie a une seule responsabilité définie. Cela génère davantage de petites classes qui doivent être connectées entre elles pour satisfaire leurs dépendances respectives.

Les applications Android sont généralement constituées de nombreuses classes, dont certaines dépendent les unes des autres.
Figure 1. Modèle de graphique d'application d'une application Android

Les dépendances entre les classes peuvent être représentées sous forme de graphique, dans lequel chaque classe est connectée aux classes dont elle dépend. Le graphique d'application représente toutes vos classes et leurs dépendances. La figure 1 illustre l'abstraction du graphique de l'application. Lorsque la classe A (ViewModel) dépend de la classe B (Repository), une flèche reliant A à B représente cette dépendance.

L'injection de dépendances permet d'établir ces connexions et de remplacer les implémentations à des fins de test. Par exemple, lorsque vous testez un ViewModel qui dépend d'un dépôt, vous pouvez transmettre différentes implémentations de Repository à l'aide de données fictives ou de simulations pour tester les différents cas.

Principes de base de l'injection de dépendances manuelle

Cette section explique comment appliquer l'injection de dépendances manuelle dans un scénario d'application Android réel. Elle décrit une approche itérative permettant de commencer à utiliser l'injection de dépendances dans votre application. L'approche s'améliore jusqu'à atteindre un point très semblable à ce que Dagger générerait automatiquement pour vous. Pour en savoir plus sur Dagger, consultez la section Principes de base de Dagger.

Un flux est un groupe d'écrans de l'application qui correspond à une fonctionnalité. La connexion, l'enregistrement et le règlement sont des exemples de flux.

Lorsqu'elle couvre un flux de connexion pour une application Android standard, la LoginActivity dépend de LoginViewModel, qui à son tour dépend de UserRepository. UserRepository dépend ensuite d'un UserLocalDataSource et d'un UserRemoteDataSource, qui à leur tour dépendent d'un service Retrofit.

LoginActivity est le point d'entrée du flux de connexion. L'utilisateur interagit avec l'activité. Ainsi, LoginActivity doit créer le LoginViewModel avec toutes ses dépendances.

Les classes Repository et DataSource du flux se présentent comme suit :

Kotlin

class UserRepository(
    private val localDataSource: UserLocalDataSource,
    private val remoteDataSource: UserRemoteDataSource
) { ... }

class UserLocalDataSource { ... }
class UserRemoteDataSource(
    private val loginService: LoginRetrofitService
) { ... }

Java

class UserLocalDataSource {
    public UserLocalDataSource() { }
    ...
}

class UserRemoteDataSource {

    private final Retrofit retrofit;

    public UserRemoteDataSource(Retrofit retrofit) {
        this.retrofit = retrofit;
    }

    ...
}

class UserRepository {

    private final UserLocalDataSource userLocalDataSource;
    private final UserRemoteDataSource userRemoteDataSource;

    public UserRepository(UserLocalDataSource userLocalDataSource, UserRemoteDataSource userRemoteDataSource) {
        this.userLocalDataSource = userLocalDataSource;
        this.userRemoteDataSource = userRemoteDataSource;
    }

    ...
}

Voici à quoi ressemble LoginActivity :

Kotlin

class LoginActivity: Activity() {

    private lateinit var loginViewModel: LoginViewModel

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        // In order to satisfy the dependencies of LoginViewModel, you have to also
        // satisfy the dependencies of all of its dependencies recursively.
        // First, create retrofit which is the dependency of UserRemoteDataSource
        val retrofit = Retrofit.Builder()
            .baseUrl("https://example.com")
            .build()
            .create(LoginService::class.java)

        // Then, satisfy the dependencies of UserRepository
        val remoteDataSource = UserRemoteDataSource(retrofit)
        val localDataSource = UserLocalDataSource()

        // Now you can create an instance of UserRepository that LoginViewModel needs
        val userRepository = UserRepository(localDataSource, remoteDataSource)

        // Lastly, create an instance of LoginViewModel with userRepository
        loginViewModel = LoginViewModel(userRepository)
    }
}

Java

public class MainActivity extends Activity {

    private LoginViewModel loginViewModel;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        // In order to satisfy the dependencies of LoginViewModel, you have to also
        // satisfy the dependencies of all of its dependencies recursively.
        // First, create retrofit which is the dependency of UserRemoteDataSource
        Retrofit retrofit = new Retrofit.Builder()
                .baseUrl("https://example.com")
                .build()
                .create(LoginService.class);

        // Then, satisfy the dependencies of UserRepository
        UserRemoteDataSource remoteDataSource = new UserRemoteDataSource(retrofit);
        UserLocalDataSource localDataSource = new UserLocalDataSource();

        // Now you can create an instance of UserRepository that LoginViewModel needs
        UserRepository userRepository = new UserRepository(localDataSource, remoteDataSource);

        // Lastly, create an instance of LoginViewModel with userRepository
        loginViewModel = new LoginViewModel(userRepository);
    }
}

Cette approche présente des problèmes :

  1. Il y a beaucoup de code récurrent. Si vous souhaitez créer une autre instance de LoginViewModel dans une autre partie du code, le code sera dupliqué.

  2. Les dépendances doivent être déclarées dans l'ordre. Vous devez instancier UserRepository avant LoginViewModel pour le créer.

  3. Il est difficile de réutiliser des objets. Si vous souhaitez réutiliser UserRepository dans plusieurs fonctionnalités, vous devez appliquer le modèle Singleton. Le modèle Singleton rend les tests plus difficiles, car tous les tests partagent la même instance Singleton.

Gérer les dépendances avec un conteneur

Pour résoudre le problème lié à la réutilisation des objets, vous pouvez créer votre propre classe de conteneur de dépendances utilisée pour obtenir des dépendances. Toutes les instances fournies par ce conteneur peuvent être publiques. Dans l'exemple, comme vous n'avez besoin que d'une instance de UserRepository, vous pouvez rendre ses dépendances privées, avec la possibilité de les rendre publiques à l'avenir si elles doivent être fournies :

Kotlin

// Container of objects shared across the whole app
class AppContainer {

    // Since you want to expose userRepository out of the container, you need to satisfy
    // its dependencies as you did before
    private val retrofit = Retrofit.Builder()
                            .baseUrl("https://example.com")
                            .build()
                            .create(LoginService::class.java)

    private val remoteDataSource = UserRemoteDataSource(retrofit)
    private val localDataSource = UserLocalDataSource()

    // userRepository is not private; it'll be exposed
    val userRepository = UserRepository(localDataSource, remoteDataSource)
}

Java

// Container of objects shared across the whole app
public class AppContainer {

    // Since you want to expose userRepository out of the container, you need to satisfy
    // its dependencies as you did before
    private Retrofit retrofit = new Retrofit.Builder()
            .baseUrl("https://example.com")
            .build()
            .create(LoginService.class);

    private UserRemoteDataSource remoteDataSource = new UserRemoteDataSource(retrofit);
    private UserLocalDataSource localDataSource = new UserLocalDataSource();

    // userRepository is not private; it'll be exposed
    public UserRepository userRepository = new UserRepository(localDataSource, remoteDataSource);
}

Comme ces dépendances sont utilisées dans l'ensemble de l'application, elles doivent être placées dans un emplacement commun que toutes les activités peuvent utiliser : la classe Application. Créez une classe Application personnalisée contenant une instance AppContainer.

Kotlin

// Custom Application class that needs to be specified
// in the AndroidManifest.xml file
class MyApplication : Application() {

    // Instance of AppContainer that will be used by all the Activities of the app
    val appContainer = AppContainer()
}

Java

// Custom Application class that needs to be specified
// in the AndroidManifest.xml file
public class MyApplication extends Application {

    // Instance of AppContainer that will be used by all the Activities of the app
    public AppContainer appContainer = new AppContainer();
}

Vous pouvez maintenant obtenir l'instance de l'AppContainer, à partir de l'application, et l'instance partagée du UserRepository :

Kotlin

class LoginActivity: Activity() {

    private lateinit var loginViewModel: LoginViewModel

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        // Gets userRepository from the instance of AppContainer in Application
        val appContainer = (application as MyApplication).appContainer
        loginViewModel = LoginViewModel(appContainer.userRepository)
    }
}

Java

public class MainActivity extends Activity {

    private LoginViewModel loginViewModel;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        // Gets userRepository from the instance of AppContainer in Application
        AppContainer appContainer = ((MyApplication) getApplication()).appContainer;
        loginViewModel = new LoginViewModel(appContainer.userRepository);
    }
}

Ainsi, vous ne disposez pas d'un UserRepository Singleton. Vous disposez plutôt d'un AppContainer partagé dans toutes les activités qui contient des objets du graphique et crée des instances de ces objets que d'autres classes peuvent utiliser.

Si LoginViewModel est nécessaire dans un plus grand nombre d'emplacements de l'application, il est judicieux de disposer d'un emplacement centralisé où créer des instances de LoginViewModel. Vous pouvez déplacer la création de LoginViewModel vers le conteneur et fournir de nouveaux objets de ce type avec une fabrique. Le code d'une LoginViewModelFactory se présente comme suit :

Kotlin

// Definition of a Factory interface with a function to create objects of a type
interface Factory<T> {
    fun create(): T
}

// Factory for LoginViewModel.
// Since LoginViewModel depends on UserRepository, in order to create instances of
// LoginViewModel, you need an instance of UserRepository that you pass as a parameter.
class LoginViewModelFactory(private val userRepository: UserRepository) : Factory {
    override fun create(): LoginViewModel {
        return LoginViewModel(userRepository)
    }
}

Java

// Definition of a Factory interface with a function to create objects of a type
public interface Factory<T> {
    T create();
}

// Factory for LoginViewModel.
// Since LoginViewModel depends on UserRepository, in order to create instances of
// LoginViewModel, you need an instance of UserRepository that you pass as a parameter.
class LoginViewModelFactory implements Factory {

    private final UserRepository userRepository;

    public LoginViewModelFactory(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    @Override
    public LoginViewModel create() {
        return new LoginViewModel(userRepository);
    }
}

Vous pouvez inclure la LoginViewModelFactory dans l'AppContainer et faire en sorte que LoginActivity l'utilise :

Kotlin

// AppContainer can now provide instances of LoginViewModel with LoginViewModelFactory
class AppContainer {
    ...
    val userRepository = UserRepository(localDataSource, remoteDataSource)

    val loginViewModelFactory = LoginViewModelFactory(userRepository)
}

class LoginActivity: Activity() {

    private lateinit var loginViewModel: LoginViewModel

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        // Gets LoginViewModelFactory from the application instance of AppContainer
        // to create a new LoginViewModel instance
        val appContainer = (application as MyApplication).appContainer
        loginViewModel = appContainer.loginViewModelFactory.create()
    }
}

Java

// AppContainer can now provide instances of LoginViewModel with LoginViewModelFactory
public class AppContainer {
    ...

    public UserRepository userRepository = new UserRepository(localDataSource, remoteDataSource);

    public LoginViewModelFactory loginViewModelFactory = new LoginViewModelFactory(userRepository);
}

public class MainActivity extends Activity {

    private LoginViewModel loginViewModel;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        // Gets LoginViewModelFactory from the application instance of AppContainer
        // to create a new LoginViewModel instance
        AppContainer appContainer = ((MyApplication) getApplication()).appContainer;
        loginViewModel = appContainer.loginViewModelFactory.create();
    }
}

Bien que cette approche soit préférable à la précédente, vous devez encore prendre en compte les difficultés suivantes :

  1. Vous devez gérer l'AppContainer vous-même en créant manuellement des instances pour toutes les dépendances.

  2. Il y a encore beaucoup de code récurrent. Vous devez créer des fabriques ou des paramètres manuellement selon que vous souhaitez réutiliser un objet ou non.

Gérer les dépendances dans les flux d'application

AppContainer se complexifie si vous souhaitez inclure davantage de fonctionnalités dans le projet. Les problèmes se multiplient lorsque votre application prend de l'ampleur et que vous commencez à introduire différents flux de fonctionnalités :

  1. Lorsque vous disposez de différents flux, vous devrez peut-être limiter les objets au champ d'application de ce flux. Par exemple, lorsque vous créez LoginUserData (qui peut comprendre le nom d'utilisateur et le mot de passe utilisés uniquement dans le flux de connexion), vous ne devez pas conserver les données d'un ancien flux de connexion provenant d'un autre utilisateur. Vous avez besoin d'une nouvelle instance pour chaque nouveau flux. Pour ce faire, vous pouvez créer des objets FlowContainer dans l'AppContainer, comme illustré dans l'exemple de code suivant.

  2. L'optimisation du graphique d'application et des conteneurs de flux peut également s'avérer difficile. N'oubliez pas de supprimer les instances dont vous n'avez pas besoin, en fonction du flux dans lequel vous vous trouvez.

Imaginez que vous disposez d'un flux de connexion composé d'une activité (LoginActivity) et de plusieurs fragments (LoginUsernameFragment et LoginPasswordFragment). Ces vues cherchent à :

  1. Accéder à la même instance LoginUserData qui doit être partagée jusqu'à la fin du flux de connexion.

  2. Créer une instance de LoginUserData lorsque le flux redémarre.

Pour ce faire, vous pouvez utiliser un conteneur de flux de connexion. Ce conteneur doit être créé au début du flux de connexion et supprimé de la mémoire à la fin du flux.

Ajoutons un LoginContainer à l'exemple de code. Vous devez pouvoir créer plusieurs instances de LoginContainer dans l'application. Par conséquent, au lieu de le transformer en Singleton, transformez-le en classe avec les dépendances de l'AppContainer nécessaires au flux de connexion.

Kotlin

class LoginContainer(val userRepository: UserRepository) {

    val loginData = LoginUserData()

    val loginViewModelFactory = LoginViewModelFactory(userRepository)
}

// AppContainer contains LoginContainer now
class AppContainer {
    ...
    val userRepository = UserRepository(localDataSource, remoteDataSource)

    // LoginContainer will be null when the user is NOT in the login flow
    var loginContainer: LoginContainer? = null
}

Java

// Container with Login-specific dependencies
class LoginContainer {

    private final UserRepository userRepository;

    public LoginContainer(UserRepository userRepository) {
        this.userRepository = userRepository;
        loginViewModelFactory = new LoginViewModelFactory(userRepository);
    }

    public LoginUserData loginData = new LoginUserData();

    public LoginViewModelFactory loginViewModelFactory;
}

// AppContainer contains LoginContainer now
public class AppContainer {
    ...
    public UserRepository userRepository = new UserRepository(localDataSource, remoteDataSource);

    // LoginContainer will be null when the user is NOT in the login flow
    public LoginContainer loginContainer;
}

Une fois que vous disposez d'un conteneur spécifique à un flux, vous devez décider quand créer et supprimer l'instance de conteneur. Étant donné que votre flux de connexion est autonome dans une activité (LoginActivity), c'est l'activité qui gère le cycle de vie de ce conteneur. LoginActivity peut créer l'instance dans onCreate() et la supprimer dans onDestroy().

Kotlin

class LoginActivity: Activity() {

    private lateinit var loginViewModel: LoginViewModel
    private lateinit var loginData: LoginUserData
    private lateinit var appContainer: AppContainer


    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        appContainer = (application as MyApplication).appContainer

        // Login flow has started. Populate loginContainer in AppContainer
        appContainer.loginContainer = LoginContainer(appContainer.userRepository)

        loginViewModel = appContainer.loginContainer.loginViewModelFactory.create()
        loginData = appContainer.loginContainer.loginData
    }

    override fun onDestroy() {
        // Login flow is finishing
        // Removing the instance of loginContainer in the AppContainer
        appContainer.loginContainer = null
        super.onDestroy()
    }
}

Java

public class LoginActivity extends Activity {

    private LoginViewModel loginViewModel;
    private LoginData loginData;
    private AppContainer appContainer;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        appContainer = ((MyApplication) getApplication()).appContainer;

        // Login flow has started. Populate loginContainer in AppContainer
        appContainer.loginContainer = new LoginContainer(appContainer.userRepository);

        loginViewModel = appContainer.loginContainer.loginViewModelFactory.create();
        loginData = appContainer.loginContainer.loginData;
    }

    @Override
    protected void onDestroy() {
        // Login flow is finishing
        // Removing the instance of loginContainer in the AppContainer
        appContainer.loginContainer = null;

        super.onDestroy();
    }
}

Tout comme LoginActivity, les fragments de connexion peuvent accéder au LoginContainer à partir de l'AppContainer et utiliser l'instance LoginUserData partagée.

Dans ce cas, comme il s'agit d'une logique de cycle de vie de la vue, il est judicieux d'utiliser l'observation du cycle de vie.

Conclusion

L'injection de dépendances est une technique efficace pour créer des applications Android évolutives et testables. Utilisez les conteneurs pour partager des instances de classes dans différentes parties de votre application, mais également pour centraliser la création d'instances de classes à l'aide de fabriques.

Lorsque votre application prend de l'ampleur, vous commencez à constater que vous écrivez beaucoup de code récurrent (comme des fabriques), qui peut être source d'erreurs. Vous devez également gérer vous-même le champ d'application et le cycle de vie des conteneurs, en optimisant et en supprimant les conteneurs qui ne sont plus nécessaires afin de libérer de la mémoire. Si vous ne le faites pas correctement, vous risquez d'entraîner des bugs et des fuites de mémoire subtiles dans votre application.

Dans la section "Dagger", vous apprendrez à utiliser Dagger pour automatiser ce processus et générer le même code que celui que vous auriez écrit manuellement.