L'architecture d'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 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. Ensuite, UserRepository dépend d'une UserLocalDataSource et d'une 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 :
class UserRepository(
private val localDataSource: UserLocalDataSource,
private val remoteDataSource: UserRemoteDataSource
) { ... }
class UserLocalDataSource { ... }
class UserRemoteDataSource(
private val loginService: LoginRetrofitService
) { ... }
Dans Compose, ComponentActivity est le point d'entrée. Le câblage des dépendances se produit une fois dans onCreate, et l'UI est décrite par des composables appelés à partir de setContent :
class ApiService {
/* Your API implementation here */
}
class UserRepository(private val apiService: ApiService) {
/* Your implementation here */
}
class LoginActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Satisfy the dependencies of LoginViewModel recursively,
// then pass what the UI needs into setContent.
val apiService = ApiService()
val userRepository = UserRepository(apiService)
setContent {
LoginScreen(userRepository)
}
}
}
@Composable
fun LoginScreen(userRepository: UserRepository) {
val viewModel: LoginViewModel = viewModel(
factory = LoginViewModelFactory(userRepository)
)
// ...
}
Cette approche présente des problèmes :
- Les dépendances doivent être déclarées dans l'ordre. Vous devez instancier
UserRepositoryavantLoginViewModelpour le créer. - Il est difficile de réutiliser des objets. Si vous souhaitez réutiliser
UserRepositorydans 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 :
// Container of objects shared across the whole app
class AppContainer {
// apiService and userRepository aren't private and will be exposed
val apiService = ApiService()
val userRepository = UserRepository(apiService)
}
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.
// 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()
}
Avec Compose, le même AppContainer est toujours créé dans la sous-classe Application. Vous pouvez y accéder dans l'activité, avant d'appeler setContent, ou depuis un composable, à l'aide de LocalContext :
class LoginActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val appContainer = (application as MyApplication).appContainer
setContent {
LoginScreen(appContainer.userRepository)
}
}
}
// Alternatively, read AppContainer from inside a composable:
@Composable
fun LoginScreen() {
val context = LocalContext.current
val appContainer = (context.applicationContext as MyApplication).appContainer
val viewModel: LoginViewModel = viewModel(
factory = LoginViewModelFactory(appContainer.userRepository)
)
// ...
}
Nous vous recommandons de transmettre les dépendances en tant que paramètres composables plutôt que d'accéder à LocalContext depuis une position profonde dans l'arborescence. Cela permet de tester les composables et de rendre leurs entrées explicites. Résolvez le conteneur une fois à la racine de l'écran et transmettez ce qui est nécessaire vers le bas.
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 :
// 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)
}
}
Avec Compose, la mise à jour AppContainer expose toujours la fabrique. La fabrique est ensuite consommée par le composable viewModel afin que ViewModel soit limité au ViewModelStoreOwner le plus proche (généralement l'activité hôte ou, avec Navigation Compose, une entrée de navigation) :
// AppContainer exposing the factory (unchanged from the snippet above)
class AppContainer {
// ...
val userRepository = UserRepository(localDataSource, remoteDataSource)
val loginViewModelFactory = LoginViewModelFactory(userRepository)
}
// Compose entry point + screen composable
class LoginActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val appContainer = (application as MyApplication).appContainer
setContent {
LoginScreen(appContainer.loginViewModelFactory)
}
}
}
@Composable
fun LoginScreen(factory: LoginViewModelFactory) {
val viewModel: LoginViewModel = viewModel(factory = factory)
// ...
}
Bien que cette approche soit préférable à la précédente, vous devez encore prendre en compte les difficultés suivantes :
Vous devez gérer l'
AppContainervous-même en créant manuellement des instances pour toutes les dépendances.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 :
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 objetsFlowContainerdans l'AppContainer, comme illustré dans l'exemple de code suivant.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.
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.
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
}
Dans Compose, la durée de vie du conteneur de flux est liée à la composition plutôt qu'à l'Activity hôte. Vous n'avez pas besoin de modifier un AppContainer.loginContainer partagé, car les composables reçoivent leurs dépendances en tant que paramètres ou les lisent à partir d'un ViewModel hissé. Deux possibilités s'offrent à vous :
- Graphique imbriqué Navigation Compose (recommandé pour les flux multi-écrans)
Placez tous les écrans du flux de connexion sous un graphique de navigation imbriqué et définissez le conteneur sur le
NavBackStackEntryde ce graphique. Le conteneur est créé lorsque l'utilisateur entre dans le flux et effacé lorsque l'entrée de la pile "Retour" est supprimée, sans qu'aucun appel de cycle de vie manuel ne soit requis. Pour en savoir plus, consultez Concevoir votre graphique de navigation. rememberà la racine de l'écran (pour un flux à écran unique ou lorsque vous n'utilisez pas Navigation Compose). Construisez le conteneur à l'intérieur derememberafin qu'il soit créé une fois par entrée dans la composition et collecté par le garbage collector lorsque le composable quitte la composition :
@Composable
fun LoginFlow(appContainer: AppContainer) {
val loginContainer = remember(appContainer) {
LoginContainer(appContainer.userRepository)
}
val viewModel: LoginViewModel = viewModel(
factory = loginContainer.loginViewModelFactory
)
// Render the login flow using loginContainer.loginData and viewModel.
}
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.