Descubra los beneficios de la arquitectura limpia de Android
Publicado: 2022-03-11¿Qué preferiría: agregar una nueva característica a una aplicación que funciona muy bien con una arquitectura horrible, o corregir un error en la aplicación de Android bien diseñada, pero con errores? Personalmente, definitivamente elegiría la segunda opción. Agregar una nueva característica, incluso una simple, puede volverse muy laborioso en una aplicación, considerando todas las dependencias de todo en cada clase. Recuerdo uno de mis proyectos de Android, donde un administrador de proyectos me pidió que agregara una pequeña función, algo como descargar datos y mostrarlos en una nueva pantalla. Era una aplicación escrita por uno de mis colegas que encontró un nuevo trabajo. La característica no debería tomar más de la mitad de un día laboral. Yo era muy optimista...
Después de siete horas de investigación sobre cómo funciona la aplicación, qué módulos hay y cómo se comunican entre sí, realicé algunas implementaciones de prueba de la función. fue un infierno Un pequeño cambio en el modelo de datos obligó a un gran cambio en la pantalla de inicio de sesión. Agregar una solicitud de red requería cambios de implementación de casi todas las pantallas y la clase GodOnlyKnowsWhatThisClassDoes
. Los cambios de color de los botones causaron un comportamiento extraño al guardar los datos en la base de datos o un bloqueo total de la aplicación. A la mitad del día siguiente, le dije a mi gerente de proyecto: “Tenemos dos formas de implementar la función. Primero, puedo pasar tres días más en él y finalmente lo implementaré de una manera muy sucia, y el tiempo de implementación de cada característica siguiente o corrección de errores crecerá exponencialmente. O bien, puedo reescribir la aplicación. Esto me llevará dos o tres semanas, pero ahorraremos tiempo para futuros cambios en la aplicación”. Afortunadamente, aceptó la segunda opción. Si alguna vez tuve dudas de por qué es importante una buena arquitectura de software en una aplicación (incluso una muy pequeña), esta aplicación las disipó por completo. Pero, ¿qué patrón de arquitectura de Android deberíamos usar para evitar tales problemas?
En este artículo, me gustaría mostrarle un ejemplo de arquitectura limpia en una aplicación de Android. Las ideas principales de este patrón, sin embargo, se pueden adaptar a cada plataforma e idioma. Una buena arquitectura debe ser independiente de detalles como plataforma, idioma, sistema de base de datos, entrada o salida.
Aplicación de ejemplo
Crearemos una aplicación Android sencilla para registrar nuestra ubicación con las siguientes características:
- El usuario puede crear una cuenta con un nombre.
- El usuario puede editar el nombre de la cuenta.
- El usuario puede eliminar la cuenta.
- El usuario puede seleccionar la cuenta activa.
- El usuario puede guardar la ubicación.
- El usuario puede ver la lista de ubicaciones de un usuario.
- El usuario puede ver una lista de usuarios.
Arquitectura limpia
Las capas son el núcleo principal de una arquitectura limpia. En nuestra aplicación, usaremos tres capas: presentación, dominio y modelo. Cada capa debe estar separada y no debería necesitar saber sobre otras capas. Debe existir en su propio mundo y, como máximo, compartir una pequeña interfaz para comunicarse.
Responsabilidades de la capa:
- Dominio: Contiene las reglas de negocio de nuestra app. Debe proporcionar casos de uso que reflejen las características de nuestra aplicación.
- Presentación: presenta datos al usuario y también recopila datos necesarios como el nombre de usuario. Este es un tipo de entrada/salida.
- Modelo: proporciona datos para nuestra aplicación. Se encarga de obtener datos de fuentes externas y guardarlos en la base de datos, servidor en la nube, etc.
¿Qué capas deben saber sobre las demás? La forma más sencilla de obtener la respuesta es pensando en los cambios. Tomemos la capa de presentación: presentaremos algo al usuario. Si cambiamos algo en la presentación, ¿deberíamos hacer también un cambio en una capa del modelo? Imagine que tenemos una pantalla de "Usuario" con el nombre del usuario y la última ubicación. Si queremos presentar las dos últimas ubicaciones del usuario en lugar de solo una, nuestro modelo no debería verse afectado. Entonces, tenemos el primer principio: la capa de presentación no conoce la capa del modelo.
Y, al contrario, ¿debería la capa del modelo conocer la capa de presentación? Nuevamente, no, porque si cambiamos, por ejemplo, la fuente de datos de una base de datos a una red, no debería cambiar nada en la interfaz de usuario (si pensó en agregar un cargador aquí, sí, pero también podemos tener un cargador de interfaz de usuario cuando se utiliza una base de datos). Así que las dos capas están completamente separadas. ¡Genial!
¿Qué pasa con la capa de dominio? Es el más importante porque contiene toda la lógica empresarial principal. Aquí es donde queremos procesar nuestros datos antes de pasarlos a la capa del modelo o presentárselos al usuario. Debe ser independiente de cualquier otra capa: no sabe nada sobre la base de datos, la red o la interfaz de usuario. Como este es el núcleo, otras capas se comunicarán solo con esta. ¿Por qué queremos tener esto completamente independiente? Las reglas comerciales probablemente cambiarán con menos frecuencia que los diseños de la interfaz de usuario o algo en la base de datos o el almacenamiento en red. Nos comunicaremos con esta capa a través de algunas interfaces provistas. No utiliza ningún modelo concreto o implementación de interfaz de usuario. Estos son detalles, y recuerda: los detalles cambian. Una buena arquitectura no está atada a los detalles.
Suficiente teoría por ahora. ¡Empecemos a codificar! Este artículo gira en torno al código, por lo que, para una mejor comprensión, debe descargar el código de GitHub y verificar qué hay dentro. Se crearon tres etiquetas Git: arquitectura_v1, arquitectura_v2 y arquitectura_v3, que corresponden a las partes del artículo.
Tecnología de aplicaciones
En la aplicación, uso Kotlin y Dagger 2 para la inyección de dependencia. Ni Kotlin ni Dagger 2 son necesarios aquí, pero facilitan mucho las cosas. Puede que se sorprenda de que no uso RxJava (ni RxKotlin), pero no lo encontré útil aquí, y no me gusta usar ninguna biblioteca solo porque está en la parte superior y alguien dice que es imprescindible. Como dije, el idioma y las bibliotecas son detalles, por lo que puede usar lo que quiera. También se utilizan algunas bibliotecas de pruebas unitarias de Android: JUnit, Robolectric y Mockito.
Dominio
La capa más importante en nuestro diseño de arquitectura de aplicaciones de Android es la capa de dominio. Comencemos con eso. Aquí es donde estará nuestra lógica de negocio y las interfaces para comunicarnos con otras capas. El núcleo principal son los UseCase
s, que reflejan lo que el usuario puede hacer con nuestra aplicación. Preparemos una abstracción para ellos:
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 }
Decidí usar corrutinas de Kotlin aquí. Cada UseCase
tiene que implementar un método de ejecución para proporcionar los datos. Este método se llama en un subproceso en segundo plano y, después de recibir un resultado, se entrega en el subproceso de la interfaz de usuario. El tipo devuelto es OneOf<F, T>
—podemos devolver un error o un éxito con los datos:
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 capa de dominio necesita sus propias entidades, por lo que el siguiente paso es definirlas. Tenemos dos entidades por ahora: User
y 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)
Ahora que sabemos qué datos devolver, tenemos que declarar las interfaces de nuestros proveedores de datos. Estos serán IUsersRepository
e ILocationsRepository
. Deben implementarse en la capa del modelo:
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> }
Este conjunto de acciones debería ser suficiente para proporcionar los datos necesarios para la aplicación. En esta etapa, no decidimos cómo se almacenarán los datos; este es un detalle del que queremos ser independientes. Por ahora, nuestra capa de dominio ni siquiera sabe que está en Android. Intentaremos mantener este estado (Más o menos. Lo explicaré más adelante).
El último (o casi último) paso es definir implementaciones para nuestros UseCase
s, que serán utilizados por los datos de presentación. Todos ellos son muy simples (al igual que nuestra aplicación y los datos son simples): sus operaciones se limitan a llamar a un método adecuado desde el repositorio, por ejemplo:
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) }
La abstracción del Repository
hace que nuestros UseCases
muy fáciles de probar: no tenemos que preocuparnos por una red o una base de datos. Se puede simular de cualquier manera, por lo que nuestras pruebas unitarias probarán casos de uso reales y no otras clases no relacionadas. Esto hará que nuestras pruebas unitarias sean simples y rápidas:
@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) } }
Por ahora, la capa de dominio está terminada.
Modelo
Como desarrollador de Android, probablemente elegirá Room, la nueva biblioteca de Android para almacenar datos. Pero imaginemos que el administrador del proyecto le preguntó si puede posponer la decisión sobre la base de datos porque la administración está tratando de decidir entre Room, Realm y alguna nueva biblioteca de almacenamiento súper rápida. Necesitamos algunos datos para comenzar a trabajar con la interfaz de usuario, así que los mantendremos en la memoria por ahora:
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) } }
Presentación
Hace dos años, escribí un artículo sobre MVP como una muy buena estructura de aplicaciones para Android. Cuando Google anunció los excelentes componentes de arquitectura, que facilitaron mucho el desarrollo de aplicaciones de Android, MVP ya no es necesario y puede ser reemplazado por MVVM; sin embargo, algunas ideas de este patrón siguen siendo muy útiles, como la de las vistas tontas. Solo deberían preocuparse por mostrar los datos. Para lograr esto, haremos uso de ViewModel y LiveData.
El diseño de nuestra aplicación es muy simple: una actividad con navegación inferior, en la que dos entradas de menú muestran el fragmento de locations
o el fragmento de users
. En estas vistas usamos ViewModels, que a su vez usan UseCase
s de la capa de dominio, manteniendo la comunicación ordenada y simple. Por ejemplo, aquí está 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 } }
Una pequeña explicación para aquellos que no están familiarizados con ViewModels: nuestros datos se almacenan en la variable de ubicaciones. Cuando obtenemos datos del caso de uso getLocations
, se pasan al valor LiveData
. Este cambio avisará a los observadores para que puedan reaccionar y actualizar sus datos. Agregamos un observador para los datos en un fragmento:
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() } }
En cada cambio de ubicación, simplemente pasamos los nuevos datos a un adaptador asignado a una vista de reciclador, y ahí es donde va el flujo normal de Android para mostrar datos en una vista de reciclador.
Debido a que usamos ViewModel en nuestras vistas, su comportamiento también es fácil de probar: podemos simplemente simular los ViewModels y no preocuparnos por la fuente de datos, la red u otros factores:

@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" } }
Puede notar que la capa de presentación también está separada en capas más pequeñas con bordes claros. Vistas como activities
, fragments
, ViewHolders
, etc. son responsables solo de mostrar datos. Solo conocen la capa ViewModel y la usan solo para obtener o enviar usuarios y ubicaciones. Es un ViewModel que se comunica con el dominio. Las implementaciones de ViewModel son las mismas para la vista que los UseCases para el dominio. Parafraseando, la arquitectura limpia es como una cebolla: tiene capas y las capas también pueden tener capas.
Inyección de dependencia
Hemos creado todas las clases para nuestra arquitectura, pero hay una cosa más que hacer: necesitamos algo que conecte todo. Las capas de presentación, dominio y modelo se mantienen limpias, pero necesitamos un módulo que será el sucio y sabrá todo sobre todo; con este conocimiento, podrá conectar nuestras capas. La mejor manera de hacerlo es usando uno de los patrones de diseño comunes (uno de los principios de código limpio definidos en SOLID): inyección de dependencia, que crea objetos adecuados para nosotros y los inyecta en las dependencias deseadas. Usé Dagger 2 aquí (en medio del proyecto, cambié la versión a 2.16, que tiene menos repeticiones), pero puedes usar cualquier mecanismo que quieras. Recientemente, jugué un poco con la biblioteca Koin, y creo que también vale la pena intentarlo. Quería usarlo aquí, pero tuve muchos problemas para burlarme de ViewModels durante la prueba. Espero encontrar una manera de resolverlos rápidamente; si es así, puedo presentar diferencias para esta aplicación cuando uso Koin y Dagger 2.
Puede verificar la aplicación para esta etapa en GitHub con la etiqueta architecture_v1.
Cambios
Terminamos nuestras capas, probamos la aplicación, ¡todo funciona! Excepto por una cosa: todavía necesitamos saber qué base de datos quiere usar nuestro PM. Supongamos que acudieron a usted y le dijeron que la gerencia acordó usar Room, pero aún quieren tener la posibilidad de usar la biblioteca ultrarrápida más nueva en el futuro, por lo que debe tener en cuenta los posibles cambios. Además, una de las partes interesadas preguntó si los datos se pueden almacenar en una nube y quiere saber el costo de tal cambio. Entonces, este es el momento de verificar si nuestra arquitectura es buena y si podemos cambiar el sistema de almacenamiento de datos sin cambios en la presentación o la capa de dominio.
Cambiar 1
Lo primero al usar Room es definir entidades para una base de datos. Ya tenemos algunos: User
y UserLocation
. Todo lo que tenemos que hacer es agregar anotaciones como @Entity
y @PrimaryKey
, y luego podemos usarlo en nuestra capa de modelo con una base de datos. ¡Genial! Esta es una excelente manera de romper todas las reglas de arquitectura que queríamos mantener. En realidad, la entidad de dominio no se puede convertir en una entidad de base de datos de esta manera. Imagínate que también queremos descargar los datos de una red. Podríamos usar algunas clases más para manejar las respuestas de la red, convirtiendo nuestras entidades simples para que funcionen con una base de datos y una red. Ese es el camino más corto hacia una catástrofe futura (y gritando: "¿Quién diablos escribió este código?"). Necesitamos clases de entidad separadas para cada tipo de almacenamiento de datos que usamos. No cuesta mucho, así que definamos correctamente las entidades 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 )
Como puede ver, son casi lo mismo que las entidades de dominio, por lo que existe una gran tentación de fusionarlos. Esto es simplemente un accidente: con datos más complicados, la similitud será menor.
Luego, tenemos que implementar UserDAO
y UserLocationsDAO
, nuestra AppDatabase
y, finalmente, las implementaciones para IUsersRepository
e ILocationsRepository
. Aquí hay un pequeño problema: ILocationsRepository
debería devolver una UserLocation
, pero recibe una UserLocationEntity
de la base de datos. Lo mismo es para las clases relacionadas con el User
. En la dirección opuesta, pasamos UserLocation
cuando la base de datos requiere UserLocationEntity
. Para resolver esto, necesitamos Mappers
entre nuestro dominio y las entidades de datos. Usé una de mis funciones favoritas de Kotlin: las extensiones. Creé un archivo llamado Mapper.kt
y puse todos los métodos para el mapeo entre las clases allí (por supuesto, está en la capa del modelo, el dominio no lo necesita):
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())
La pequeña mentira que mencioné antes es sobre entidades de dominio. Escribí que no saben nada sobre Android, pero esto no es del todo cierto. Agregué la anotación @Parcelize
a la entidad User
y Parcelable
allí, haciendo posible pasar la entidad a un fragmento. Para estructuras más complicadas, debemos proporcionar las clases de datos propias de la capa de vista y crear mapeadores como entre el dominio y los modelos de datos. Agregar Parcelable
a la entidad del dominio es un pequeño riesgo que me atreví a correr; soy consciente de ello y, en caso de que se produzca algún cambio en la entidad del User
, crearé clases de datos separadas para la presentación y eliminaré Parcelable
de la capa del dominio.
Lo último que debe hacer es cambiar nuestro módulo de inyección de dependencia para proporcionar la implementación del Repository
recién creado en lugar del MemoryRepository
anterior. Después de compilar y ejecutar la aplicación, podemos ir al PM para mostrar la aplicación en funcionamiento con una base de datos de Room. También podemos informar al PM que agregar una red no llevará demasiado tiempo y que estamos abiertos a cualquier biblioteca de almacenamiento que desee la administración. Puede verificar qué archivos se han cambiado, solo los que están en la capa del modelo. ¡Nuestra arquitectura es realmente ordenada! Cada tipo de almacenamiento siguiente se puede construir de la misma manera, simplemente ampliando nuestros repositorios y proporcionando las implementaciones adecuadas. Por supuesto, podría resultar que necesitemos múltiples fuentes de datos, como una base de datos y una red. ¿Entonces que? Nada de eso, solo tendríamos que crear tres implementaciones de repositorio: una para la red, otra para la base de datos y una principal, donde se seleccionaría la fuente de datos correcta (por ejemplo, si tenemos una red, cargar desde el red, y si no, cargar desde una base de datos).
Puede consultar la aplicación para esta etapa en GitHub con la etiqueta architecture_v2.
Entonces, el día casi ha terminado: está sentado frente a su computadora con una taza de café, la aplicación está lista para enviarse a Google Play, cuando de repente el administrador del proyecto se le acerca y le pregunta "¿Podría agregar una función que puede guardar la ubicación actual del usuario desde el GPS?”
cambio 2
Todo cambia... especialmente el software. Es por eso que necesitamos un código limpio y una arquitectura limpia. Sin embargo, incluso las cosas más limpias pueden estar sucias si codificamos sin pensar. El primer pensamiento al implementar obtener una ubicación del GPS sería agregar todo el código de ubicación en la actividad, ejecutarlo en nuestro SaveLocationDialogFragment
y crear una nueva ubicación de UserLocation
con los datos correspondientes. Esta podría ser la forma más rápida. Pero, ¿qué pasa si nuestro PM loco viene a nosotros y nos pide que cambiemos la ubicación del GPS a algún otro proveedor (por ejemplo, algo como Bluetooth o red)? Los cambios pronto se saldrían de control. ¿Cómo podemos hacerlo de una manera limpia?
La ubicación del usuario son datos. Y obtener una ubicación es un UseCase
de uso, por lo que creo que nuestras capas de dominio y modelo también deberían estar involucradas aquí. Por lo tanto, tenemos un UseCase
más para implementar GetCurrentLocation
. También necesitamos algo que nos proporcione una ubicación: una interfaz ILocationProvider
, para que el UseCase
independiente de detalles como el sensor 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() } }
Puede ver que tenemos un método adicional aquí: cancelar. Esto se debe a que necesitamos una forma de cancelar las actualizaciones de ubicación GPS. Nuestra implementación de Provider
, definida en la capa del modelo, va aquí:
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 } }
Este proveedor está preparado para trabajar con corrutinas de Kotlin. Si recuerda, el método de ejecución de UseCase
se llama en un subproceso en segundo plano, por lo que debemos asegurarnos y marcar correctamente nuestros subprocesos. Como puede ver, tenemos que pasar una actividad aquí: es de vital importancia cancelar las actualizaciones y cancelar el registro de los oyentes cuando ya no los necesitemos para evitar pérdidas de memoria. Dado que implementa ILocationProvider
, podemos modificarlo fácilmente en el futuro a algún otro proveedor. También podemos probar fácilmente el manejo de la ubicación actual (automática o manualmente), incluso sin habilitar el GPS en nuestro teléfono; todo lo que tenemos que hacer es reemplazar la implementación para devolver una ubicación construida al azar. Para que funcione, debemos agregar el UseCase
recién creado al LocationsViewModel
. ViewModel, a su vez, debe tener un nuevo método, getCurrentLocation
, que en realidad llamará al caso de uso. Con solo unos pequeños cambios en la interfaz de usuario para llamarlo y registrar GPSProvider en Dagger, ¡y listo, nuestra aplicación está terminada!
Resumen
Estaba tratando de mostrarles cómo podemos desarrollar una aplicación de Android que sea fácil de mantener, probar y cambiar. También debería ser fácil de entender: si alguien nuevo llega a su trabajo, no debería tener problemas para comprender el flujo de datos o la estructura. Si son conscientes de que la arquitectura está limpia, pueden estar seguros de que los cambios en la interfaz de usuario no afectarán nada en el modelo, y agregar una nueva función no llevará más de lo previsto. Pero este no es el final del viaje. Incluso si tenemos una aplicación bien estructurada, es muy fácil romperla con cambios de código desordenados "solo por un momento, solo para trabajar". Recuerde: no hay un código "solo por ahora". Cada código que rompe nuestras reglas puede persistir en la base de código y puede ser una fuente de futuras rupturas más grandes. Si llega a ese código solo una semana después, parecerá que alguien implementó algunas dependencias fuertes en ese código y, para resolverlo, tendrá que buscar en muchas otras partes de la aplicación. Una buena arquitectura de código es un desafío no solo al comienzo del proyecto, es un desafío para cualquier parte de la vida útil de una aplicación de Android. Pensar y verificar el código debe tenerse en cuenta cada vez que algo va a cambiar. Para recordar esto, puedes, por ejemplo, imprimir y colgar tu diagrama de arquitectura de Android. También puede forzar un poco la independencia de las capas separándolas en tres módulos Gradle, donde el módulo de dominio no es consciente de los demás y los módulos de presentación y modelo no se usan entre sí. Pero ni siquiera esto puede reemplazar la conciencia de que el desorden en el código de la aplicación se vengará de nosotros cuando menos lo esperemos.