Injection manuelle de dépendances (Views)

Concepts et implémentation de Jetpack Compose

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 conduit à un plus grand nombre de classes plus petites qui doivent être connectées pour répondre aux dépendances les unes des autres.

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 ligne pointe de A vers B pour représenter 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 deRepository à 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 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. Ensuite UserRepository dépend d'un UserLocalDataSource et d'un UserRemoteDataSource, qui à leur tour dépendent d'un Retrofit service.

LoginActivity est le point d'entrée du flux de connexion, et l'utilisateur interagit avec l'activité. LoginActivity doit donc 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);
}

Étant donné que 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 obtenir l'instance partagée de 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 plusieurs endroits de l'application, il est judicieux de disposer d'un emplacement centralisé où vous créez 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<LoginViewModel> {
   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<LoginViewModel> {

   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 être constitué du nom d'utilisateur et du mot de passe utilisés uniquement dans le flux de connexion), vous ne souhaitez pas conserver les données d'un ancien flux de connexion d'un autre utilisateur. Vous souhaitez 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. Vous devez vous souvenir 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 souhaitez pouvoir créer plusieurs instances de LoginContainer dans l'application. Au lieu d'en faire un Singleton, créez-en une classe avec les dépendances dont le flux de connexion a besoin à partir de l'AppContainer.

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 partagée LoginUserData.

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.