Découvrez les avantages d'Android Clean Architecture
Publié: 2022-03-11Que préféreriez-vous : ajouter une nouvelle fonctionnalité à une application qui fonctionne très bien avec une architecture épouvantable, ou corriger un bogue dans l'application Android bien architecturée, mais boguée ? Personnellement, je choisirais certainement la deuxième option. L'ajout d'une nouvelle fonctionnalité, même simple, peut devenir très laborieux dans une application, compte tenu de toutes les dépendances de tout dans chaque classe. Je me souviens d'un de mes projets Android, où un chef de projet m'a demandé d'ajouter une petite fonctionnalité, comme télécharger des données et les afficher sur un nouvel écran. C'était une application écrite par un de mes collègues qui a trouvé un nouveau travail. La fonctionnalité ne devrait pas prendre plus d'une demi-journée de travail. J'étais très optimiste...
Après sept heures d'enquête sur le fonctionnement de l'application, les modules disponibles et la manière dont ils communiquent entre eux, j'ai effectué quelques implémentations d'essai de la fonctionnalité. C'était l'enfer. Un petit changement dans le modèle de données a forcé un grand changement dans l'écran de connexion. L'ajout d'une requête réseau a nécessité des changements d'implémentation de presque tous les écrans et de la classe GodOnlyKnowsWhatThisClassDoes . Les changements de couleur des boutons provoquaient un comportement étrange lors de l'enregistrement des données dans la base de données ou un blocage total de l'application. Au milieu de la journée suivante, j'ai dit à mon chef de projet : « Nous avons deux façons d'implémenter la fonctionnalité. Tout d'abord, je peux passer trois jours de plus dessus et je vais finalement l'implémenter de manière très sale, et le temps d'implémentation de chaque fonctionnalité ou correction de bogue suivante augmentera de façon exponentielle. Ou, je peux réécrire l'application. Cela me prendra deux ou trois semaines, mais nous gagnerons du temps pour les futurs changements d'application. » » Heureusement, il a accepté la deuxième option. Si j'ai déjà eu des doutes sur l'importance d'une bonne architecture logicielle dans une application (même très petite), cette application les a totalement dissipés. Mais quel modèle d'architecture Android devrions-nous utiliser pour éviter de tels problèmes ?
Dans cet article, j'aimerais vous montrer un exemple d'architecture propre dans une application Android. Les idées principales de ce modèle peuvent cependant être adaptées à chaque plate-forme et langue. Une bonne architecture doit être indépendante des détails tels que la plate-forme, le langage, le système de base de données, l'entrée ou la sortie.
Exemple d'application
Nous allons créer une application Android simple pour enregistrer notre emplacement avec les fonctionnalités suivantes :
- L'utilisateur peut créer un compte avec un nom.
- L'utilisateur peut modifier le nom du compte.
- L'utilisateur peut supprimer le compte.
- L'utilisateur peut sélectionner le compte actif.
- L'utilisateur peut enregistrer l'emplacement.
- L'utilisateur peut voir la liste des emplacements d'un utilisateur.
- L'utilisateur peut voir une liste d'utilisateurs.
Architecture épurée
Les couches sont le noyau principal d'une architecture propre. Dans notre application, nous utiliserons trois couches : présentation, domaine et modèle. Chaque couche doit être séparée et ne doit pas avoir besoin de connaître les autres couches. Il devrait exister dans son propre monde et, tout au plus, partager une petite interface pour communiquer.
Responsabilités de la couche :
- Domaine : Contient les règles métier de notre application. Il doit fournir des cas d'utilisation qui reflètent les fonctionnalités de notre application.
- Présentation : présente les données à l'utilisateur et collecte également les données nécessaires telles que le nom d'utilisateur. C'est une sorte d'entrée/sortie.
- Modèle : Fournit des données pour notre application. Il est chargé d'obtenir des données de sources externes et de les enregistrer dans la base de données, le serveur cloud, etc.
Quelles couches doivent connaître les autres ? La façon la plus simple d'obtenir la réponse est de penser aux changements. Prenons la couche de présentation - nous allons présenter quelque chose à l'utilisateur. Si nous changeons quelque chose dans la présentation, devrions-nous également modifier une couche de modèle ? Imaginez que nous ayons un écran "Utilisateur" avec le nom de l'utilisateur et son dernier emplacement. Si nous voulons présenter les deux derniers emplacements de l'utilisateur au lieu d'un seul, notre modèle ne devrait pas être affecté. Donc, nous avons le premier principe : la couche de présentation ne connaît pas la couche de modèle.
Et, au contraire, la couche de modèle doit-elle connaître la couche de présentation ? Encore une fois, non, car si nous changeons, par exemple, la source de données d'une base de données vers un réseau, cela ne devrait rien changer à l'interface utilisateur (si vous avez pensé à ajouter un chargeur ici, oui, mais nous pouvons également avoir un chargeur d'interface utilisateur lors de l'utilisation d'une base de données). Les deux couches sont donc complètement séparées. Génial!
Qu'en est-il de la couche de domaine ? C'est le plus important car il contient toute la logique métier principale. C'est là que nous voulons traiter nos données avant de les transmettre à la couche modèle ou de les présenter à l'utilisateur. Il doit être indépendant de toute autre couche - il ne sait rien de la base de données, du réseau ou de l'interface utilisateur. Comme il s'agit du noyau, les autres couches ne communiqueront qu'avec celle-ci. Pourquoi voulons-nous que cela soit complètement indépendant? Les règles métier changeront probablement moins souvent que les conceptions d'interface utilisateur ou quelque chose dans la base de données ou le stockage réseau. Nous communiquerons avec cette couche via certaines interfaces fournies. Il n'utilise aucun modèle concret ou implémentation d'interface utilisateur. Ce sont des détails, et rappelez-vous que les détails changent. Une bonne architecture n'est pas liée aux détails.
Assez de théorie pour le moment. Commençons à coder ! Cet article tourne autour du code, donc, pour une meilleure compréhension, vous devez télécharger le code depuis GitHub et vérifier ce qu'il contient. Trois balises Git ont été créées : architecture_v1, architecture_v2 et architecture_v3, qui correspondent aux parties de l'article.
Technologie d'application
Dans l'application, j'utilise Kotlin et Dagger 2 pour l'injection de dépendances. Ni Kotlin ni Dagger 2 ne sont nécessaires ici, mais cela rend les choses beaucoup plus faciles. Vous pourriez être surpris que je n'utilise pas RxJava (ni RxKotlin), mais je ne l'ai pas trouvé utilisable ici, et je n'aime pas utiliser une bibliothèque uniquement parce qu'elle est au top et que quelqu'un dit que c'est un must. Comme je l'ai dit, le langage et les bibliothèques sont des détails, vous pouvez donc utiliser ce que vous voulez. Certaines bibliothèques de tests unitaires Android sont également utilisées : JUnit, Robolectric et Mockito.
Domaine
La couche la plus importante dans la conception de notre architecture d'application Android est la couche de domaine. Commençons par ça. C'est là que se trouveront notre logique métier et les interfaces pour communiquer avec les autres couches. Le noyau principal est constitué des UseCase s, qui reflètent ce que l'utilisateur peut faire avec notre application. Préparons-leur une abstraction :
abstract class UseCase<out Type, in Params> { private var job: Deferred<OneOf<Failure, Type>>? = null abstract suspend fun run(params: Params): OneOf<Failure, Type> fun execute(params: Params, onResult: (OneOf<Failure, Type>) -> Unit) { job?.cancel() job = async(CommonPool) { run(params) } launch(UI) { val result = job!!.await() onResult(result) } } open fun cancel() { job?.cancel() } open class NoParams } J'ai décidé d'utiliser les coroutines de Kotlin ici. Chaque UseCase doit implémenter une méthode d'exécution pour fournir les données. Cette méthode est appelée sur un thread d'arrière-plan, et après réception d'un résultat, il est livré sur le thread d'interface utilisateur. Le type renvoyé est OneOf<F, T> — nous pouvons renvoyer une erreur ou un succès avec data :
sealed class OneOf<out E, out S> { data class Error<out E>(val error: E) : OneOf<E, Nothing>() data class Success<out S>(val data: S) : OneOf<Nothing, S>() val isSuccess get() = this is Success<S> val isError get() = this is Error<E> fun <E> error(error: E) = Error(error) fun <S> success(data: S) = Success(data) fun oneOf(onError: (E) -> Any, onSuccess: (S) -> Any): Any = when (this) { is Error -> onError(error) is Success -> onSuccess(data) } } La couche de domaine a besoin de ses propres entités, l'étape suivante consiste donc à les définir. Nous avons deux entités pour l'instant : User et UserLocation :
data class User(var id: Int? = null, val name: String, var isActive: Boolean = false) data class UserLocation(var id: Int? = null, val latitude: Double, val longitude: Double, val time: Long, val userId: Int) Maintenant que nous savons quelles données retourner, nous devons déclarer les interfaces de nos fournisseurs de données. Ce seront IUsersRepository et ILocationsRepository . Ils doivent être implémentés dans la couche modèle :
interface IUsersRepository { fun setActiveUser(userId: Int): OneOf<Failure, User> fun getActiveUser(): OneOf<Failure, User?> fun createUser(user: User): OneOf<Failure, User> fun removeUser(userId: Int): OneOf<Failure, User?> fun editUser(user: User): OneOf<Failure, User> fun users(): OneOf<Failure, List<User>> } interface ILocationsRepository { fun locations(userId: Int): OneOf<Failure, List<UserLocation>> fun addLocation(location: UserLocation): OneOf<Failure, UserLocation> }Cet ensemble d'actions devrait être suffisant pour fournir les données nécessaires à l'application. A ce stade, nous ne décidons pas de la manière dont les données seront stockées, c'est un détail dont nous voulons être indépendants. Pour l'instant, notre couche de domaine ne sait même pas qu'elle est sur Android. Nous essaierons de garder cet état (En quelque sorte. J'expliquerai plus tard).
La dernière (ou presque dernière) étape consiste à définir des implémentations pour nos UseCase s, qui seront utilisées par les données de présentation. Tous sont très simples (tout comme notre application et nos données sont simples) - leurs opérations sont limitées pour appeler une méthode appropriée à partir du référentiel, par exemple :
class GetLocations @Inject constructor(private val repository: ILocationsRepository) : UseCase<List<UserLocation>, UserIdParams>() { override suspend fun run(params: UserIdParams): OneOf<Failure, List<UserLocation>> = repository.locations(params.userId) } L'abstraction du Repository rend nos cas d' UseCases très faciles à tester - nous n'avons pas à nous soucier d'un réseau ou d'une base de données. Il peut être moqué de n'importe quelle manière, donc nos tests unitaires testeront des cas d'utilisation réels et non d'autres classes non liées. Cela rendra nos tests unitaires simples et rapides :
@RunWith(MockitoJUnitRunner::class) class GetLocationsTests { private lateinit var getLocations: GetLocations private val locations = listOf(UserLocation(1, 1.0, 1.0, 1L, 1)) @Mock private lateinit var locationsRepository: ILocationsRepository @Before fun setUp() { getLocations = GetLocations(locationsRepository) } @Test fun `should call getLocations locations`() { runBlocking { getLocations.run(UserIdParams(1)) } verify(locationsRepository, times(1)).locations(1) } @Test fun `should return locations obtained from locationsRepository`() { given { locationsRepository.locations(1) }.willReturn(OneOf.Success(locations)) val returnedLocations = runBlocking { getLocations.run(UserIdParams(1)) } returnedLocations shouldEqual OneOf.Success(locations) } }Pour l'instant, la couche domaine est terminée.
Modèle
En tant que développeur Android, vous choisirez probablement Room, la nouvelle bibliothèque Android pour stocker des données. Mais imaginons que le chef de projet vous demande si vous pouvez reporter la décision concernant la base de données parce que la direction essaie de choisir entre Room, Realm et une nouvelle bibliothèque de stockage ultra rapide. Nous avons besoin de certaines données pour commencer à travailler avec l'interface utilisateur, nous allons donc les garder en mémoire pour l'instant :
class MemoryLocationsRepository @Inject constructor(): ILocationsRepository { private val locations = mutableListOf<UserLocation>() override fun locations(userId: Int): OneOf<Failure, List<UserLocation>> = OneOf.Success(locations.filter { it.userId == userId }) override fun addLocation(location: UserLocation): OneOf<Failure, UserLocation> { val addedLocation = location.copy(id = locations.size + 1) locations.add(addedLocation) return OneOf.Success(addedLocation) } }Présentation
Il y a deux ans, j'ai écrit un article sur MVP en tant que très bonne structure d'application pour Android. Lorsque Google a annoncé les grands composants d'architecture, qui ont rendu le développement d'applications Android beaucoup plus facile, MVP n'est plus nécessaire et peut être remplacé par MVVM ; cependant, certaines idées de ce modèle sont toujours très utiles, comme celle sur les vues stupides. Ils ne doivent se soucier que de l'affichage des données. Pour y parvenir, nous utiliserons ViewModel et LiveData.
La conception de notre application est très simple : une activité avec une navigation inférieure, dans laquelle deux entrées de menu affichent le fragment d' locations ou le fragment d' users . Dans ces vues, nous utilisons des ViewModels, qui à leur tour utilisent des UseCase à partir de la couche de domaine, en gardant la communication claire et simple. Par exemple, voici LocationsViewModel :
class LocationsViewModel @Inject constructor(private val getLocations: GetLocations, private val saveLocation: SaveLocation) : BaseViewModel() { var locations = MutableLiveData<List<UserLocation>>() fun loadLocations(userId: Int) { getLocations.execute(UserIdParams(userId)) { it.oneOf(::handleError, ::handleLocationsChange) } } fun saveLocation(location: UserLocation, onSaved: (UserLocation) -> Unit) { saveLocation.execute(UserLocationParams(location)) { it.oneOf(::handleError) { location -> handleLocationSave(location, onSaved) } } } private fun handleLocationSave(location: UserLocation, onSaved: (UserLocation) -> Unit) { val currentLocations = locations.value?.toMutableList() ?: mutableListOf() currentLocations.add(location) this.locations.value = currentLocations onSaved(location) } private fun handleLocationsChange(locations: List<UserLocation>) { this.locations.value = locations } } Une petite explication pour ceux qui ne connaissent pas les ViewModels : nos données sont stockées dans la variable locations. Lorsque nous obtenons des données du cas d'utilisation getLocations , elles sont transmises à la valeur LiveData . Ce changement informera les observateurs afin qu'ils puissent réagir et mettre à jour leurs données. Nous ajoutons un observateur pour les données dans un fragment :
class LocationsFragment : BaseFragment() { ... private fun initLocationsViewModel() { locationsViewModel = ViewModelProviders.of(activity!!, viewModelFactory)[LocationsViewModel::class.java] locationsViewModel.locations.observe(this, Observer<List<UserLocation>> { showLocations(it ?: emptyList()) }) locationsViewModel.error.observe(this, Observer<Failure> { handleError(it) }) } private fun showLocations(locations: List<UserLocation>) { locationsAdapter.locations = locations } private fun handleError(error: Failure?) { toast(R.string.user_fetch_error).show() } }À chaque changement d'emplacement, nous transmettons simplement les nouvelles données à un adaptateur affecté à une vue de recycleur - et c'est là que va le flux Android normal pour afficher les données dans une vue de recycleur.
Parce que nous utilisons ViewModel dans nos vues, leur comportement est également facile à tester - nous pouvons simplement nous moquer des ViewModels et ne pas nous soucier de la source de données, du réseau ou d'autres facteurs :

@RunWith(RobolectricTestRunner::class) @Config(application = TestRegistryRobolectricApplication::class) class LocationsFragmentTests { private var usersViewModel = mock(UsersViewModel::class.java) private var locationsViewModel = mock(LocationsViewModel::class.java) lateinit var fragment: LocationsFragment @Before fun setUp() { UsersViewModelMock.intializeMock(usersViewModel) LocationsViewModelMock.intializeMock(locationsViewModel) fragment = LocationsFragment() fragment.viewModelFactory = ViewModelUtils.createFactoryForViewModels(usersViewModel, locationsViewModel) startFragment(fragment) } @Test fun `should getActiveUser on start`() { Mockito.verify(usersViewModel).getActiveUser() } @Test fun `should load locations from active user`() { usersViewModel.activeUserId.value = 1 Mockito.verify(locationsViewModel).loadLocations(1) } @Test fun `should display locations`() { val date = Date(1362919080000)//10-03-2013 13:38 locationsViewModel.locations.value = listOf(UserLocation(1, 1.0, 2.0, date.time, 1)) val recyclerView = fragment.find<RecyclerView>(R.id.locationsRecyclerView) recyclerView.measure(100, 100) recyclerView.layout(0,0, 100, 100) val adapter = recyclerView.adapter as LocationsListAdapter adapter.itemCount `should be` 1 val viewHolder = recyclerView.findViewHolderForAdapterPosition(0) as LocationsListAdapter.LocationViewHolder viewHolder.latitude.text `should equal` "Lat: 1.0" viewHolder.longitude.text `should equal` "Lng: 2.0" viewHolder.locationDate.text `should equal` "10-03-2013 13:38" } } Vous remarquerez peut-être que la couche de présentation est également séparée en couches plus petites avec des bordures claires. Les vues telles que activities , fragments , ViewHolders , etc. ne sont responsables que de l'affichage des données. Ils ne connaissent que la couche ViewModel et ne l'utilisent que pour obtenir ou envoyer des utilisateurs et des emplacements. C'est un ViewModel qui communique avec le domaine. Les implémentations de ViewModel sont les mêmes pour la vue que les UseCases pour le domaine. Pour paraphraser, l'architecture propre est comme un oignon - elle a des couches, et les couches peuvent aussi avoir des couches.
Injection de dépendance
Nous avons créé toutes les classes pour notre architecture, mais il reste une chose à faire : nous avons besoin de quelque chose qui relie tout ensemble. Les couches de présentation, de domaine et de modèle sont maintenues propres, mais nous avons besoin d'un module qui sera le plus sale et qui saura tout sur tout - grâce à cette connaissance, il pourra connecter nos couches. La meilleure façon de le faire est d'utiliser l'un des modèles de conception courants (l'un des principes de code propre définis dans SOLID) - l'injection de dépendances, qui crée des objets appropriés pour nous et les injecte dans les dépendances souhaitées. J'ai utilisé Dagger 2 ici (au milieu du projet, j'ai changé la version en 2.16, qui a moins de passe-partout), mais vous pouvez utiliser n'importe quel mécanisme que vous aimez. Récemment, j'ai joué un peu avec la bibliothèque Koin, et je pense que ça vaut aussi la peine d'essayer. Je voulais l'utiliser ici, mais j'ai eu beaucoup de problèmes à me moquer des ViewModels lors des tests. J'espère trouver un moyen de les résoudre rapidement - si c'est le cas, je peux présenter les différences pour cette application lors de l'utilisation de Koin et Dagger 2.
Vous pouvez vérifier l'application pour cette étape sur GitHub avec la balise architecture_v1.
Changements
Nous avons terminé nos calques, testé l'application, tout fonctionne ! À une exception près : nous devons encore savoir quelle base de données notre chef de projet souhaite utiliser. Supposons qu'ils viennent vous voir et vous disent que la direction a accepté d'utiliser Room, mais qu'ils veulent toujours avoir la possibilité d'utiliser la bibliothèque la plus récente et la plus rapide à l'avenir, vous devez donc garder à l'esprit les changements potentiels. Aussi, une des parties prenantes demande si les données peuvent être stockées dans un cloud et souhaite connaître le coût d'un tel changement. C'est donc le moment de vérifier si notre architecture est bonne et si nous pouvons changer le système de stockage des données sans aucun changement dans la présentation ou la couche de domaine.
Changement 1
La première chose à faire lors de l'utilisation de Room est de définir des entités pour une base de données. Nous en avons déjà : User et UserLocation . Tout ce que nous avons à faire est d'ajouter des annotations comme @Entity et @PrimaryKey , puis nous pouvons l'utiliser dans notre couche de modèle avec une base de données. Génial! C'est un excellent moyen de briser toutes les règles d'architecture que nous voulions conserver. En fait, l'entité de domaine ne peut pas être convertie en entité de base de données de cette manière. Imaginez que nous voulions également télécharger les données d'un réseau. Nous pourrions utiliser d'autres classes pour gérer les réponses du réseau, en convertissant nos entités simples pour les faire fonctionner avec une base de données et un réseau. C'est le chemin le plus court vers une catastrophe future (et en criant : « Qui diable a écrit ce code ? »). Nous avons besoin de classes d'entités distinctes pour chaque type de stockage de données que nous utilisons. Cela ne coûte pas cher, définissons donc correctement les entités Room :
@Entity data class UserEntity( @PrimaryKey(autoGenerate = true) var id: Long?, @ColumnInfo(name = "name") var name: String, @ColumnInfo(name = "isActive") var isActive: Boolean = false ) @Entity(foreignKeys = [ ForeignKey(entity = UserEntity::class, parentColumns = [ "id" ], childColumns = [ "userId" ], onDelete = CASCADE) ]) data class UserLocationEntity( @PrimaryKey(autoGenerate = true) var id: Long?, @ColumnInfo(name = "latitude") var latitude: Double, @ColumnInfo(name = "longitude") var longitude: Double, @ColumnInfo(name = "time") var time: Long, @ColumnInfo(name = "userId") var userId: Long )Comme vous pouvez le voir, ils sont presque les mêmes que les entités de domaine, il y a donc une grande tentation de les fusionner. Ce n'est qu'un accident - avec des données plus compliquées, la similitude sera moindre.
Ensuite, nous devons implémenter UserDAO et UserLocationsDAO , notre AppDatabase , et enfin les implémentations de IUsersRepository et ILocationsRepository . Il y a un petit problème ici ILocationsRepository devrait retourner un UserLocation , mais il reçoit un UserLocationEntity de la base de données. Il en va de même pour les classes liées à User . Dans le sens inverse, nous passons UserLocation lorsque la base de données nécessite UserLocationEntity . Pour résoudre ce problème, nous avons besoin Mappers entre notre domaine et les entités de données. J'ai utilisé l'une de mes fonctionnalités préférées de Kotlin, les extensions. J'ai créé un fichier nommé Mapper.kt et j'y ai mis toutes les méthodes de mappage entre les classes (bien sûr, c'est dans la couche modèle - le domaine n'en a pas besoin):
fun User.toEntity() = UserEntity(id?.toLong(), name, isActive) fun UserEntity.toUser() = User(this.id?.toInt(), name, isActive) fun UserLocation.toEntity() = UserLocationEntity(id?.toLong(), latitude, longitude, time, userId.toLong()) fun UserLocationEntity.toUserLocation() = UserLocation(id?.toInt(), latitude, longitude, time, userId.toInt()) Le petit mensonge que j'ai mentionné précédemment concerne les entités de domaine. J'ai écrit qu'ils ne savaient rien d'Android, mais ce n'est pas tout à fait vrai. J'ai ajouté l'annotation @Parcelize à l'entité User et y ai étendu Parcelable , permettant de passer l'entité à un fragment. Pour les structures plus complexes, nous devons fournir les propres classes de données de la couche de vue et créer des mappeurs comme entre les modèles de domaine et de données. L'ajout de Parcelable à l'entité de domaine est un petit risque que j'ai osé prendre - j'en suis conscient, et en cas de modification de l'entité User , je créerai des classes de données distinctes pour la présentation et supprimerai Parcelable de la couche de domaine.
La dernière chose à faire est de changer notre module d'injection de dépendances pour fournir l'implémentation Repository nouvellement créée au lieu de l'ancien MemoryRepository . Après avoir créé et exécuté l'application, nous pouvons accéder au PM pour afficher l'application de travail avec une base de données Room. Nous pouvons également informer le PM que l'ajout d'un réseau ne prendra pas trop de temps et que nous sommes ouverts à toute bibliothèque de stockage souhaitée par la direction. Vous pouvez vérifier quels fichiers ont été modifiés, uniquement ceux de la couche de modèle. Notre architecture est vraiment soignée ! Chaque type de stockage suivant peut être construit de la même manière, simplement en étendant nos référentiels et en fournissant les implémentations appropriées. Bien sûr, il pourrait s'avérer que nous ayons besoin de plusieurs sources de données, telles qu'une base de données et un réseau. Quoi alors ? Rien de plus, nous n'aurions qu'à créer trois implémentations de référentiel - une pour le réseau, une pour la base de données et une principale, où la bonne source de données serait sélectionnée (par exemple, si nous avons un réseau, charger à partir du réseau, et si ce n'est pas le cas, charger à partir d'une base de données).
Vous pouvez consulter l'application pour cette étape sur GitHub avec la balise architecture_v2.
Ainsi, la journée est presque terminée - vous êtes assis devant votre ordinateur avec une tasse de café, l'application est prête à être envoyée sur Google Play, quand soudain le chef de projet vient vers vous et vous demande "Pourriez-vous ajouter une fonctionnalité qui peut enregistrer la position actuelle de l'utilisateur à partir du GPS ? »
Changement 2
Tout change… surtout le logiciel. C'est pourquoi nous avons besoin d'un code propre et d'une architecture propre. Cependant, même les choses les plus propres peuvent être sales si nous codons sans réfléchir. La première pensée lors de la mise en œuvre de l'obtention d'un emplacement à partir du GPS serait d'ajouter tout le code de localisation dans l'activité, de l'exécuter dans notre SaveLocationDialogFragment et de créer un nouvel UserLocation avec les données correspondantes. Cela pourrait être le moyen le plus rapide. Mais que se passe-t-il si notre PM fou vient à nous et nous demande de changer l'obtention de l'emplacement du GPS vers un autre fournisseur (par exemple, quelque chose comme Bluetooth ou réseau) ? Les changements allaient bientôt devenir incontrôlables. Comment pouvons-nous le faire de manière propre ?
L'emplacement de l'utilisateur est constitué de données. Et obtenir un emplacement est un UseCase - donc je pense que nos couches de domaine et de modèle devraient également être impliquées ici. Ainsi, nous avons encore un UseCase à implémenter GetCurrentLocation . Nous avons également besoin de quelque chose qui nous fournira un emplacement - une interface ILocationProvider , pour rendre le UseCase indépendant de détails comme le capteur GPS :
interface ILocationProvider { fun getLocation(): OneOf<Failure, SimpleLocation> fun cancel() } class GetCurrentLocation @Inject constructor(private val locationProvider: ILocationProvider) : UseCase<SimpleLocation, UseCase.NoParams>() { override suspend fun run(params: NoParams): OneOf<Failure, SimpleLocation> = locationProvider.getLocation() override fun cancel() { super.cancel() locationProvider.cancel() } } Vous pouvez voir que nous avons une méthode supplémentaire ici : annuler. En effet, nous avons besoin d'un moyen d'annuler les mises à jour de localisation GPS. Notre implémentation de Provider , définie dans la couche modèle, va ici :
class GPSLocationProvider constructor(var activity: Activity) : ILocationProvider { private var locationManager: LocationManager? = null private var locationListener: GPSLocationListener? = null override fun getLocation(): OneOf<Failure, SimpleLocation> = runBlocking { val grantedResult = getLocationPermissions() if (grantedResult.isError) { val error = (grantedResult as OneOf.Error<Failure>).error OneOf.Error(error) } else { getLocationFromGPS() } } private suspend fun getLocationPermissions(): OneOf<Failure, Boolean> = suspendCoroutine { Dexter.withActivity(activity) .withPermission(Manifest.permission.ACCESS_FINE_LOCATION) .withListener(PermissionsCallback(it)) .check() } private suspend fun getLocationFromGPS(): OneOf<Failure, SimpleLocation> = suspendCoroutine { locationListener?.unsubscribe() locationManager = activity.getSystemService(Context.LOCATION_SERVICE) as LocationManager locationManager?.let { manager -> locationListener = GPSLocationListener(manager, it) launch(UI) { manager.requestLocationUpdates(LocationManager.NETWORK_PROVIDER, 0L, 0.0f, locationListener) } } } override fun cancel() { locationListener?.unsubscribe() locationListener = null locationManager = null } } Ce fournisseur est prêt à travailler avec les coroutines Kotlin. Si vous vous en souvenez, la méthode run de UseCase est appelée sur un thread d'arrière-plan. Nous devons donc nous en assurer et marquer correctement nos threads. Comme vous pouvez le voir, nous devons passer une activité ici - il est extrêmement important d'annuler les mises à jour et de se désinscrire des auditeurs lorsque nous n'en avons plus besoin pour éviter les fuites de mémoire. Puisqu'il implémente le ILocationProvider , nous pouvons facilement le modifier à l'avenir pour un autre fournisseur. Nous pouvons également tester facilement la gestion de l'emplacement actuel (automatiquement ou manuellement), même sans activer le GPS dans notre téléphone - tout ce que nous avons à faire est de remplacer l'implémentation pour renvoyer un emplacement construit au hasard. Pour que cela fonctionne, nous devons ajouter le UseCase nouvellement créé au LocationsViewModel . Le ViewModel, à son tour, doit avoir une nouvelle méthode, getCurrentLocation , qui appellera en fait le cas d'utilisation. Avec seulement quelques petites modifications de l'interface utilisateur pour l'appeler et enregistrer le GPSProvider dans Dagger, et voilà, notre application est terminée !
Sommaire
J'essayais de vous montrer comment nous pouvons développer une application Android facile à maintenir, à tester et à modifier. Il doit également être facile à comprendre - si quelqu'un de nouveau vient à votre travail, il ne devrait pas avoir de problème pour comprendre le flux de données ou la structure. S'ils savent que l'architecture est propre, ils peuvent être sûrs que les modifications apportées à l'interface utilisateur n'affecteront rien dans le modèle et que l'ajout d'une nouvelle fonctionnalité ne prendra pas plus que prévu. Mais ce n'est pas la fin du voyage. Même si nous avons une application bien structurée, il est très facile de la casser par des changements de code désordonnés "juste pour un moment, juste pour travailler". N'oubliez pas qu'il n'y a pas de code "juste pour l'instant". Chaque code qui enfreint nos règles peut persister dans la base de code et peut être une source de futures ruptures plus importantes. Si vous arrivez à ce code une semaine plus tard, il semblera que quelqu'un ait implémenté de fortes dépendances dans ce code et, pour le résoudre, vous devrez parcourir de nombreuses autres parties de l'application. Une bonne architecture de code n'est pas seulement un défi au début du projet, c'est un défi pour n'importe quelle partie de la vie d'une application Android. La réflexion et la vérification du code doivent être prises en compte chaque fois que quelque chose va changer. Pour vous en souvenir, vous pouvez par exemple imprimer et accrocher votre schéma d'architecture Android. Vous pouvez également forcer un peu l'indépendance des couches en les séparant en trois modules Gradle, où le module de domaine n'est pas conscient des autres et les modules de présentation et de modèle ne s'utilisent pas. Mais même cela ne peut pas remplacer la prise de conscience que le désordre dans le code de l'application prendra sa revanche sur nous lorsque nous nous y attendrons le moins.
