Arquitectura y diseño de aplicaciones de Android (mi experiencia)

Habr, hola!

Hoy quiero hablar sobre la arquitectura que sigo en mis aplicaciones de Android. Tomo Clean Architecture como base y uso los componentes de Android Architecture (ViewModel, LiveData, LiveEvent) + Kotlin Coroutines como herramientas. Se adjunta un código de ejemplo ficticio que está disponible en GitHub .

Descargo de responsabilidad


Quiero compartir mi experiencia de desarrollo, de ninguna manera pretendo que mi solución sea la única verdadera y carente de defectos. La arquitectura de la aplicación es un tipo de modelo que elegimos para resolver un problema en particular, y para el modelo seleccionado, su adecuación para aplicar a una tarea específica es importante.

Problema: ¿por qué necesitamos arquitectura?


La mayoría de los proyectos en los que he participado tienen el mismo problema: la lógica de la aplicación se coloca dentro del entorno de Android, lo que conduce a una gran cantidad de código dentro de Fragment and Activity. Por lo tanto, el código está rodeado de dependencias que no son necesarias en absoluto, las pruebas unitarias se vuelven casi imposibles, así como la reutilización. Los fragmentos se convierten en objetos de Dios con el tiempo, incluso pequeños cambios conducen a errores, apoyar un proyecto se vuelve costoso y emocionalmente costoso.

Hay proyectos que no tienen arquitectura en absoluto (todo está claro aquí, no hay preguntas para ellos), hay proyectos con un reclamo de arquitectura, pero de todos modos aparecen exactamente los mismos problemas. Ahora está de moda usar Clean Architecture en Android. A menudo vi que Clean Architecture se limita a crear repositorios y scripts que invocan estos repositorios y no hacen nada más. Peor aún: tales scripts devuelven modelos de repositorios llamados. Y en tal arquitectura no tiene ningún sentido. Y desde Dado que los scripts simplemente llaman a los repositorios necesarios, a menudo la lógica descansa en ViewModel o, lo que es peor, se asienta en fragmentos y actividades. Todo esto se convierte en un desastre, no apto para pruebas automáticas.

El propósito de la arquitectura y el diseño.


El propósito de la arquitectura es separar nuestra lógica de negocios de los detalles. Por detalles quiero decir, por ejemplo, API externas (cuando desarrollamos un cliente para un servicio REST), Android - un entorno (UI, servicios), etc. En el fondo, uso una arquitectura limpia, pero con mis supuestos de implementación.

El propósito del diseño es unir los modelos de interfaz de usuario, API, lógica de negocios, para que todo esto se preste a las pruebas automáticas, se acople libremente y se extienda fácilmente. En diseño, uso componentes de arquitectura de Android.

Para mí, la arquitectura debería satisfacer los siguientes criterios:

  1. La interfaz de usuario es lo más simple posible y solo tiene tres funciones:
  2. Presentar datos al usuario. Los datos vienen listos para mostrar. Esta es la función principal de la interfaz de usuario. Aquí hay widgets, animaciones, fragmentos, etc.
  3. . ViewModel LiveData.
  4. . framework, . .
  5. - . .


El diagrama esquemático de la arquitectura se muestra en la figura a continuación:

imagen

Nos estamos moviendo de abajo hacia arriba en capas, y la capa que está debajo no sabe nada sobre la capa de arriba. Y la capa superior se refiere solo a una capa que está un nivel más abajo. Aquellos. La capa API no puede hacer referencia a un dominio.

Una capa de dominio contiene una entidad comercial con su propia lógica. Por lo general, hay entidades que existen sin una aplicación. Por ejemplo, para un banco, puede haber entidades de préstamo con lógica compleja para calcular intereses, etc.

La capa lógica de la aplicación contiene scripts para la aplicación misma. Es aquí donde se determinan todas las conexiones de la aplicación, se construye su esencia.

La capa api, android es solo una implementación específica de nuestra aplicación en el entorno Android. Idealmente, esta capa se puede cambiar a cualquier cosa.

, , — . . 2- . , . . TDD , . Android, API ..

Android-.

imagen

Entonces, la capa lógica es la clave, es la aplicación. Solo una capa lógica puede referirse e interactuar con un dominio. Además, la capa lógica contiene interfaces que permiten que la lógica interactúe con los detalles de la aplicación (api, android, etc.). Este es el llamado principio de inversión de dependencia, que permite que la lógica no dependa de los detalles, sino viceversa. La capa lógica contiene los escenarios de uso de la aplicación (Casos de uso), que operan con diferentes datos, interactúan con el dominio, repositorios, etc. En desarrollo, me gusta pensar en guiones. Para cada acción o evento del usuario desde el sistema, se inicia una secuencia de comandos determinada que tiene parámetros de entrada y salida, así como solo un método: ejecutar la secuencia de comandos.

Alguien introduce un concepto adicional de un interactor, que puede combinar varios escenarios de uso y terminar con lógica adicional. Pero no hago esto, creo que cada script puede expandirse o incluir cualquier otro script, esto no requiere un interactor. Si observa los esquemas UML, allí puede ver la conexión de inclusión y extensión.

El esquema general de la aplicación es el siguiente:

imagen

  1. Se crea un entorno de Android (actividad, fragmentos, etc.).
  2. Se crea un ViewModel (uno o más).
  3. ViewModel crea los scripts necesarios que se pueden ejecutar desde este ViewModel. Los escenarios se inyectan mejor con DI.
  4. El usuario comete una acción.
  5. Cada componente de la IU está asociado con un comando que puede ejecutar.
  6. Se ejecuta un script con los parámetros necesarios, por ejemplo, Login.execute (inicio de sesión, contraseña).
  7. DI , . ( api, ). . , , , REST JSON . , , . , . , . - . , . , , . , , .
  8. . ViewModel, UI. LiveData (.9 10).

Aquellos. El papel clave que tenemos es la lógica y su modelo de datos. Vimos una doble conversión: la primera es la transformación del repositorio en el modelo de datos del escenario y la segunda es la conversión cuando el script entrega los datos al entorno como resultado de su trabajo. Por lo general, el resultado del script se entrega a viewModel para que se muestre en la interfaz de usuario. El script debe devolver dichos datos con los que viewModel y UI no hacen nada más.

Comandos La

IU inicia la ejecución del script con un comando. En mis proyectos utilizo mi propia implementación de comandos, no son parte de componentes arquitectónicos ni nada más. En general, su implementación es simple, como una comprensión más profunda de la idea, puede ver la implementación de comandos en reactiveui.netpara c #. Desafortunadamente, no puedo diseñar mi código de trabajo, solo una implementación simplificada por ejemplo.

La tarea principal del comando es ejecutar algún script, pasarle los parámetros de entrada y, después de la ejecución, devolver el resultado del comando (mensaje de datos o error). Normalmente, todos los comandos se ejecutan de forma asincrónica. Además, el equipo encapsula el método de cálculo de fondo. Uso corutinas, pero son fáciles de reemplazar con RX, y solo tienes que hacer esto en el conjunto abstracto de comando + caso de uso. Como beneficio adicional, un equipo puede informar su estado: si se está ejecutando ahora o no y si se puede ejecutar en principio. Los equipos resuelven fácilmente algunos problemas, por ejemplo, el problema de doble llamada (cuando el usuario hizo clic en el botón varias veces mientras se ejecuta la operación) o los problemas de visibilidad y cancelación.

Ejemplo


Implementar función: inicie sesión en la aplicación utilizando el nombre de usuario y la contraseña.
La ventana debe contener los campos de entrada y contraseña y el botón "Iniciar sesión". La lógica del trabajo es la siguiente:

  1. El botón "Iniciar sesión" debe estar inactivo si el nombre de usuario y la contraseña contienen menos de 4 caracteres.
  2. El botón "Iniciar sesión" debe estar inactivo durante el proceso de inicio de sesión.
  3. Durante el procedimiento de inicio de sesión, se debe mostrar un indicador (cargador).
  4. Si el inicio de sesión es exitoso, se debe mostrar un mensaje de bienvenida.
  5. Si el inicio de sesión o la contraseña son incorrectos, aparecerá un mensaje de error sobre el campo de inicio de sesión.
  6. Si se muestra un mensaje de error en la pantalla, cualquier entrada de un carácter en los campos de inicio de sesión o contraseña eliminará este mensaje hasta el próximo intento.

Puede resolver este problema de varias maneras, por ejemplo, poniendo todo en MainActivity.
Pero siempre sigo la implementación de mis dos reglas principales:

  1. La lógica empresarial es independiente de los detalles.
  2. La interfaz de usuario es lo más simple posible. Solo se ocupa de su tarea (presenta los datos que le fueron transferidos y también emite comandos del usuario).

Así es como se ve la aplicación:

imagen

MainActivity se ve así:

class MainActivity : AppCompatActivity() {

   private val vm: MainViewModel by viewModel()

   override fun onCreate(savedInstanceState: Bundle?) {
       super.onCreate(savedInstanceState)
       setContentView(R.layout.activity_main)

       bindLoginView()
       bindProgressBar()

       observeAuthorization()
       observeRefreshView()
   }

   private fun bindProgressBar() {
       progressBar.bindVisibleWithCommandIsExecuting(this, vm.loginCommand)
   }

   private fun bindLoginView() {
       loginEdit.bindAfterTextChangedWithCommand(vm.loginValidityCommand)
       passwordEdit.bindAfterTextChangedWithCommand(vm.passwordValidityCommand)

       loginButton.bindCommand(this, vm.loginCommand) {
           LoginParameters(loginEdit.text.toString(), passwordEdit.text.toString())
       }
   }

   private fun observeAuthorization() {
       vm.authorizationSuccessLive.observe(this, Observer {
           showAuthorizeSuccessMsg(it?.data)
       })
       vm.authorizationErrorLive.observe(this, Observer {
           showAuthorizeErrorMsg()
       })
   }

   private fun observeRefreshView() {
       vm.refreshLoginViewLive.observe(this, Observer {
           hideAuthorizeErrorMsg()
       })
   }

   private fun showAuthorizeErrorMsg() {
       loginErrorMsg.isInvisible = false
   }

   private fun hideAuthorizeErrorMsg() {
       loginErrorMsg.isInvisible = true
   }

   private fun showAuthorizeSuccessMsg(name : String?) {
       val msg = getString( R.string.success_login, name)
       Toast.makeText(this, msg, Toast.LENGTH_LONG).show()
   }
}


La actividad es bastante simple, se ejecuta la regla de la interfaz de usuario. Escribí algunas extensiones simples, como bindVisibleWithCommandIsExecuting, para asociar comandos con elementos de la interfaz de usuario y no duplicar el código.

El código de este ejemplo con comentarios está disponible en GitHub ; si está interesado, puede descargarlo y familiarizarse.

Eso es todo, ¡gracias por mirar!

All Articles